Merge main into feat/shipwrecks

Main added the News layer at the same places: both layers are kept in the
layer names, panel order (News stays last of Events), standalone sources,
voice manifest, credits, DATA_SOURCES and the CURRENT-STATE table. News took
share-link token 00 first, so Shipwrecks moves to 01 (ledger, registry,
CURRENT-STATE and the token tests). The catalog now has 40 layers and the
share-link registry 38. Shipwreck strings are added to the new Hebrew
catalog; the other catalogs keep both sides' strings.
This commit is contained in:
Sameh Khamis committed 2026-11-10 14:03:39 -08:00
commit 8ff22f1a12
466 files changed
+57748 -1865

No files matched your search

+27
View File
@@ -325,3 +325,30 @@ 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
@@ -0,0 +1,40 @@
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,6 +2,10 @@
## [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
+70 -7
View File
@@ -30,8 +30,9 @@ 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, 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) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **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" |
| **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" |
@@ -39,6 +40,10 @@ 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)" |
@@ -105,6 +110,40 @@ 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)" |
@@ -168,7 +207,8 @@ 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, 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.
- **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`, whose page is an `https://www.planespotters.net` URL, and which names its photographer 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 is visible text next to it ("Photo © {photographer}"), 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.
- **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.
@@ -196,7 +236,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 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.
- **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.
- **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.
@@ -231,9 +271,13 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **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 |
@@ -289,11 +333,20 @@ 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 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
`/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
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.
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.
### Natural Earth physical regions (`natural_earth/`)
@@ -314,6 +367,16 @@ 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
@@ -484,4 +547,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, 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, 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.
+16 -11
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 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.
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.
</details>
@@ -236,8 +236,8 @@ the container.
### Path 4 — GitHub Codespaces (cloud, no local install)
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
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
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` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` 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-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
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.)
| Layer | What you get | Source | Auth |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
@@ -436,15 +436,16 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
| 📡 **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** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🛰️ **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 | 🟢 |
| 🌍 **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 | 🟢 |
| 📷 **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), 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), 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 | 🟢 |
| 🚲 **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 |
@@ -567,12 +568,14 @@ and configuration details.
No key needed for Wind, Rain radar, Satellite clouds, Lightning density,
Cyclone advisories, Mapped ALPR Cameras, Transit, Directions, Delaware live
video, and coordinate or bundled-place search. A layer row waiting on a key
video, the News layer's Conflicts section, and coordinate or bundled-place
search. A layer row waiting on a key
names it.
### Choose the capabilities you want
Seven keys. Five have a free tier, and the two 🔴 ones are metered:
Nine keys. Six have a free tier. Of the three 🔴 ones, Google Maps and OpenAI are
metered and Liveuamap is a paid subscription:
| | Key | Why | Get it |
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -583,6 +586,8 @@ Seven keys. Five have a free tier, and the two 🔴 ones are metered:
| 🟡 | **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)
@@ -656,8 +661,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, 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. |
| **🟢 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. |
| **🗺️ 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. |
+1 -1
View File
@@ -82,7 +82,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless 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, `/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.
- **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
@@ -0,0 +1,3 @@
- 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).
+5
View File
@@ -0,0 +1,5 @@
- 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).
@@ -0,0 +1,7 @@
- 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
@@ -0,0 +1,3 @@
- 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).
@@ -0,0 +1,3 @@
- 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
@@ -0,0 +1,4 @@
- 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
@@ -0,0 +1,4 @@
- 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
@@ -0,0 +1,4 @@
- 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
@@ -0,0 +1,4 @@
- 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
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,3 @@
- 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
@@ -0,0 +1,5 @@
- 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).
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,3 @@
- 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).
+4
View File
@@ -0,0 +1,4 @@
- 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).
+29
View File
@@ -0,0 +1,29 @@
- 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
@@ -0,0 +1,5 @@
- 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).
+2
View File
@@ -0,0 +1,2 @@
- French catalog review: missing narrow no-break spaces, apostrophes, one
mistranslated message and the Starlink shell wording (abiyeenzo, #1611).
+14
View File
@@ -0,0 +1,14 @@
- 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).
@@ -0,0 +1,3 @@
- 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
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,5 @@
- 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).
@@ -0,0 +1,7 @@
- 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).
@@ -0,0 +1,3 @@
- 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
@@ -0,0 +1,6 @@
- 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
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,7 @@
- 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
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,3 @@
- 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).
+4
View File
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1 @@
- 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
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,5 @@
- 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).
+5 -1
View File
@@ -136,6 +136,9 @@ 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
@@ -163,7 +166,8 @@ 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 query and Launch Library's recent 30-day detailed feed.
CelesTrak group/TLE and SATCAT record queries 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
+531 -82
View File
@@ -129,12 +129,28 @@ search keeps a successful geocode, with no search or view-based region recovery.
Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
Overpass reserves each normalized query before its disk-cache lookup. The
initial disk lookup, upstream quota admission, fetching and memory publication
are coalesced under that reservation. A caller that joined a reservation
rejected by another client's quota retries admission with its own quota.
Stale fallback runs per response after the reservation is released, and disk
writes finish asynchronously. Settled and rejected reservations are removed
only while they still own their map entry.
External ALPR tags, surveillance-tool fields, voice context text and compact
analyst strings become one line of at most 180 characters (scene labels: 120).
Empty ALPR values stay null, and the cap never splits a surrogate pair.
Canonical analyst identifiers such as `id` and `routeId` stay unchanged, because
voice follow-ups match on them and bikeshare uses the raw station name as its
ID. The text is still untrusted: normalization does not prevent prompt
injection.
Terrain height requests accept at most 64 points, matching the browser service's
existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
The browser continues to split larger workloads into sequential 64-point requests.
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks; one set builds at a time, and of the rebuilds asked for meanwhile only the newest runs, when that build finishes; turning lines off removes them at once); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
@@ -153,7 +169,7 @@ implementations.
## Shipwrecks — EMODnet and Wikidata
The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `00`),
The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `01`),
off by default. `/api/shipwrecks` downloads EMODnet Human Activities'
`emodnet:heritageshipwrecks` WFS layer (about 7,000 wrecks in European waters
compiled from SHOM, Ireland's National Monuments Service, Historic England and
@@ -793,7 +809,7 @@ Consult the linked official advisory for safety decisions.
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance.
Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h, or for FIRMS the 24 hours to a snapshot's `at`), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance.
Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
@@ -1389,6 +1405,30 @@ and snapshot source. The standalone adapter keeps the existing USGS daily feed,
M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or
destroying the layer cancels pending work and ignores late results.
## Rendered-mesh floor readings — November 9, 2026
On the photoreal stack the shared floor (`cachedGroundFloor`) answers from
rendered-mesh readings per coarse cell (0.001°, about 110 × 80 m), sampled by
`src/services/meshFloorSampler.js` and gated against the DEM by
`reportValidatedMeshFloorCell` in `src/services/groundFloor.js`. The probe goes
where the caller's point is (a vehicle or contact), not to the cell's centre;
the sampler reads a cell at up to eight spots about 12–17 m or more apart, and
each reading that passes the gate is kept at its point (ground snaps add theirs
the same way; a cell keeps at most sixteen). A read answers from the cell's
reading nearest the asked point, so a viaduct and the street beside it in one
cell keep their own heights; two readings within about 5 m of each other keep
the lower. A spot refused only because the cell's DEM prior was cold is tried
again later. Transit offers the sampler the latest point of each vehicle's path
in a cell (its newest fix in its own cell), and the grounded-flight display
clamp reads at the contact, held inside its sticky cell. Before this, one probe
at the cell's centre latched for the session, so a centre on a roof lifted
everything in the cell (a Haymarket bus drew 23.5 m above the street, on the
garage roof) and a centre in the river put Red Line trains on the Longfellow
Bridge 6–9 m under the deck. Residual: the probe is vertical, so a vehicle
directly under a deck or a roof overhang reads the structure above it, and a
vehicle between readings takes the nearest one, so its floor steps where the
nearest reading changes.
## Vessel components and sources
`src/data/aisLiveVessels.js` assembles `createVesselLayer` from the
@@ -1446,6 +1486,52 @@ use the supplied asset resolver, including the preload path. Enrichment receives
an abort signal and cannot update a later lifecycle after destruction. Existing
camera, terrain floor, trail, selection and measured model-size policies remain.
A tracked civil or military aircraft's trail (`src/data/trailContinuity.js`) lifts
the pen, drawing nothing, between two points more than 48 minutes or over 1,500 kt
apart (a parked aircraft within 0.1 nm keeps one line for up to three hours) and at a
waypoint with no altitude; a ground waypoint is drawn on the ground. The head joins the
aircraft only within 150 km. Each trail keeps up to 2,000 points, a full day of history
at one fix per minute plus the live session; past that the oldest recorded points go
first, and the oldest reports only once none are left. The renderer
(`src/data/trailRenderer.js`) freezes every chunk of 128 points except the newest into
static geometry, so a long trail costs about as much per frame as a short one;
`scripts/qa-trail-freeze.mjs` checks that a freeze never shows a blank frame.
While an aircraft is tracked, its trail between reports is the path its icon was
drawn along: the displayed position is recorded about once a second or every 150 m
(`recordTrailPoint`). Recorded points are thinned by shape: one stays only where
dropping it would move the line more than 30 m across the track or in height from any
position it stood for, or leave a straight piece longer than 10 km, so straight flight
adds a vertex every 10 km at most and a turn keeps its curve. A recorded point that
would turn the line back sharper than 120° over a short step is dropped. Reports stay
in the trail as anchors, in time order. Recorded points never join two reports that are
not one flight under the 48-minute and 1,500 kt rules; the points between them go and
nothing is recorded across such a pair. A report for a time the display has already
passed replaces what was drawn after it, and nothing is recorded until the icon has
eased onto it, so the line reads arc, report, corrected path. History from before tracking started, and the backfill, run report to report.
The trail never runs past the icon or through its 3D model, and the head runs from the
newest kept point outside the model to the icon (to the model's tail when the model is
the visual). A contact with no new report coasts along its reported track for 5 minutes
past its last real report, at no more than 1,500 kt and never below known ground, then stops;
one that
went quiet on the ground, or within 150 m of a known ground floor, holds at its last
report, records nothing and draws no head (with the ground unknown, a low barometric
altitude alone does not hold it). The hold-or-coast choice is made once per report (`staleHoldsForFix`
in `src/data/motionModel.js`), and the forward fix built when a repeated position carries
new speed or course follows the same rules and is never a trail report. A live report
with no altitude breaks the line and leaves no point at an invented height, and so does
such a report seeded from position history when tracking starts.
A contact that misses an update is stale from the first miss: it is drawn with the
45%-alpha stale fade (`STALE_ALPHA`), the tracked billboard and 3D model included, and the
tracked label reads STALE. For the tracked aircraft a poll that re-sends a fix no newer
than the last one is a miss too. Once its coast has ended, or it holds at its last report
after a miss, it stands still, dimmed, and the label reads SIGNAL LOST · <time since its
last report>, in seconds under a minute and minutes after (`_trackedStaleCue` in each
flight layer's `tracking.js`). The tracked aircraft is kept, so the follow camera keeps its
target, until 10 minutes after its newest fix (`TRACKED_RETENTION_MS`): 5 minutes coasting,
then 5 stopped as SIGNAL LOST. Untracked aircraft keep their missed-poll limits. A report
that arrives later eases the aircraft onto it.
The tracked-aircraft descriptor both flight layers hand to the cockpit carries
`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
@@ -1566,6 +1652,297 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti
and queued panel work stop before asynchronous layer restoration; final camera
and portal cleanup follows that restoration.
### Ship bridge view
With no aircraft tracked, Cockpit can ride the selected AIS vessel instead.
Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a
selected vessel; Contacts and every other layer may be off, before or during
the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode
change still in flight blocks entry until it settles. Double-clicking the
selected vessel enters it, and the entry button reads BRIDGE with a ship icon
both in Contacts (`#cockpit-entry`) and under the selected vessel's Intel HUD
readout in the bottom-right corner (`#cockpit-entry-readout`, shown with the
HUD on, in its Tactical and Cyber layouts). Turning Live AIS Vessels off ends
the bridge: the layer then has no subject (`getCockpitSubject` returns null).
The bridge picture uses AIS alone, so no other feed being off makes it partial.
Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only;
voice `enter` on a bridge or in an orbital view is refused before it touches
tracking, with the reason to exit it first.
The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's
sprite, card, detection bracket and trail while it is held
(`setCockpitSubject`), and
ignores scene clicks so open water cannot deselect it. The eye sits on the AIS
position at a representative bridge height for the AIS type family
(`src/layers/vessels/cockpitSubject.js`: cargo and tanker 30 m, passenger 25 m,
tug 7 m, fishing 6 m, ferry and AIS "Other" (codes 90–99, or a feed's text
type "Other") 4 m, sailing and pleasure 3 m; 12 m when the type names no
family, including the reserved and wing-in-ground codes the vessel layer also
files under OTHER), pitched 2° down with 2 m clearance over the shared ground floor
(`src/cockpitSubjects.js`). The camera faces true heading (course over ground
when heading is missing) and moves along course over ground. A ship reporting
neither has no direction: the camera keeps the heading it had on entry, the
heading readout shows `---`, the visor reads NO HEADING · AIS, the entry signal
names the ship without a heading, no bow or stern mark is drawn, and the eye
stays on the fix rather than moving along a made-up course. The vessel layer
redraws a ship only on its 60-second refresh, so the camera's target is the last
fix dead-reckoned along course over ground for up to 180 s, and the existing
inertial anchor absorbs each new fix. The fix's age is read without comparing
the two clocks (`vesselFixAgeSec`): the time since this client first received
the fix, plus how far it trailed that refresh's newest fix, so a client clock
running fast or slow neither projects the ship nor marks it stale, and a fix
the feed keeps re-serving from its cache goes on ageing into STALE FEED. A
stale vessel, or a projection past that limit, holds the camera on the target.
The vessel layer floats ships on the geoid, which on a river or lake can lie
well below the water (the Seine through Paris is ~27 m up), so the bridge also
reads the rendered surface around its ship: the ship's position and a ring of
eight probes 30 m out, the lowest probe taken as the water when it is 2 m to
4,000 m above sea level. The first read starts from the tiles already loaded, then
the full-detail read replaces it; it repeats every 5 s. Only one full-detail
read runs at a time: while one is still running, a refresh reads the loaded
tiles instead, and the slow read applies whenever it lands. Once a
full-detail read has answered, loaded-tile reads no longer change the height;
before then they may find raised water but never take it back to the sea.
A read that sampled nothing, or failed, keeps the height it had and is
tried again on the next refresh.
The HUD keeps its frame, vision styles, briefing, weather and Display/Radio
utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows
speed over ground in knots, true heading and course over ground in place of
altitude, hides the altitude tape, and shows the crew-entered AIS destination as
a Reported Voyage card without an origin or direction arrow. The arrow keys
look around the bridge in 5° steps (Shift for 45°): Left and Right turn the
view all the way round, Up and Down pitch it up to ±90°, and the camera eases
there unless the user prefers reduced motion. Home or `0`, or the LOOK AHEAD
button shown while the view is turned, bring it back over the bow at the rest
pitch. The keys stay with a focused text field, slider, tab, radio group, list
or menu. The heading tape is a straight strip that scrolls continuously with
the camera, so the caret reads the view's exact heading, while the readout
above it keeps the ship's true heading; a ring with a centre dot marks the bow
(prograde) and a ring with a cross the stern (retrograde) at their bearings
from the caret. The bridge radar
shows nearby AIS ships heading-up and opens at 3 NM
(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps
3, 6, 12 and 24 NM and the chosen range is kept across bridges. Own ship
sits where the camera rides (its fix dead-reckoned) and contacts at their
last fixes, where the vessel layer draws them, so the scope matches the view
out of the window. Contact
Previous/Next and Live Signals aircraft handoffs are refused on the bridge and
the Contact card stays hidden. Exit returns the camera to the vessel's map view
through the layer's own focus. Tracking an aircraft or losing the vessel
selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs`
covers entry, readouts, refusal and exit.
### Orbital view
With a satellite tracked and no aircraft tracked, Cockpit can ride the
satellite instead. Entry needs only the Satellites layer
(`orbitEntryAllowed` in `src/contextModePolicy.js`); Contacts and every other
layer may be off. A Context mode change in flight blocks entry until it
settles. Debris (rocket bodies, fragments and dead satellites; see the
satellite class toggles) can be tracked but never entered: no ORBIT entry is
offered and double-click refuses it. A tracked aircraft still takes
entry first, and a tracked satellite
takes it before a selected vessel. Double-clicking the tracked satellite
enters it, and the entry button reads
ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel
HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays
aircraft-only; its state reports an orbital view in use as a `satellite`
subject. `gev:cockpit-mode-changed` carries layer `satellites` and the NORAD
id.
The camera sits on the satellite's own per-frame SGP4 position
(`getCockpitPosition`, the sample the dot uses) and looks at the Earth's
centre track-up: the ground track points to the top of the frame, so the
picture scrolls along it (`nadirOrientation` in `src/cockpitOrbital.js`). A
satellite with no ground track (a geostationary one) is shown north-up, and
over a pole the previous frame's up is kept. It updates on every rendered
frame, with no ground clamp. Entry opens in the plain optical vision style
(the other styles stay one click away) and leases the map for flat imagery:
with Google 3D tiles shown, which stay coarse from hundreds of kilometres up,
the map switches to Esri imagery, and exit puts it back unless the operator
picked another map meanwhile; another owner already holding the map (an
imagery comparison) keeps it as it is. While orbit holds the lease, Recent
Imagery cannot take it: turned on in orbit, the layer reports the comparison
in use until, after exit, it is turned off and on again or its shown days
are cleared and picked again. The Cockpit cloud pass is off in orbit
(it fades out above 24 km); cloud cover from orbit is the Weather layer's,
and the saved cloud preference stands for the next aircraft or bridge. The layer hides the satellite's dot, docked
companions, its own orbit ring and its detection bracket, and draws every
other orbit ring (the ISS ring included) at 30% of its alpha; other
satellites' points and labels stay. Scene clicks and the layer's Escape are
ignored by the satellites layer while it is ridden (a click on a mapped
site focuses it; see below). The optical zoom steps 1×, 2×, 4× and 8× for
every satellite with `+` (or `=`) and `-` (or `_`), the panel's buttons and
the mouse wheel (one step per notch; trackpad scrolling is summed to a step):
the 1× frame is 60° vertical in low orbit and fits the Earth's disk with
margin from high orbits, each step divides the tangent of the half-angle, and
Cesium's `frustum.fov` is set from it for the canvas aspect. A step animates
over 400 ms with a focus-pull blur and a reticle lock tick, and cuts at once
under `prefers-reduced-motion`. An imaging satellite matched to an entry of
the bundled sensor pack (`src/data/local_data/imaging_sensors/`, by NORAD
number or name in `src/layers/satellites/sensors.js`) whose narrowest swath
is confirmed also gets 16×, 32×, 64× and 128×, each only while the view at
the satellite's live altitude and the window's shape is still at least as
wide as that swath (`orbitalMaxZoom` in `src/cockpitOrbital.js`); when the
cap falls below the zoom shown, the view steps back to it. Unconfirmed
swaths add no steps, but are still drawn, read out and offered to SENSOR.
The sensor's swath is drawn to scale as a strip along the ground track (its
width follows the view through a zoom step, not ahead of it) with
a SENSOR SWATH readout, and a SENSOR button zooms to exactly that width when
it lies between 1× and the cap. No strip is drawn for a geostationary view
(no ground track; its sensors image areas, not strips) or a SAR (it images
to one side of nadir, not under it); the readout stays. Tilted off nadir
the strip stays (view and swath widen alike), the readout scales as the swath
readout does (`SENSOR SWATH ≈ 191 KM`), and SENSOR, a nadir framing, is
hidden; tilted past the Earth's limb neither the sensor nor the swath
reads. A swath is confirmed only on the operator's, an agency's (ESA's
eoPortal included) or an academic page for its platform. From
16×, with flat imagery on the globe (not Google 3D tiles) and the view at
nadir, every 2 s the view asks the base imagery layer for the tiles it will
need 5 to 20 s ahead along the ground track (`prefetchImageryAhead` in
`src/ui/cockpitOrbitalView.js`), so the browser has them cached when the
view arrives. The level is the one Cesium will draw there
(`imageryLevelForView` in `src/cockpitOrbital.js`, following Cesium's own
choice: the terrain level whose error spans at most the globe's maximum
screen-space error, then the imagery level whose texels are nearest that
error, about one to two screen pixels per texel). It asks for at most 12
tiles at a time through Cesium's request scheduler, throttled per server and
at the lowest priority, so a request is declined while the imagery server's
slots are taken by the tiles on screen and is the first dropped when the
scheduler is full (a declined or dropped tile is asked for again later, one that failed to load is not); it remembers the last 512 tiles asked for and does not ask
for those again, starts over on another base map (cancelling the old map's
pending requests), and cancels the requests still pending on exit. Across the track it samples the centre line, a third
out and the frame's edges. While tilted off nadir the view looks away from
the track, and with the world clock more than a minute from now (paused or
played back) it will not pass where the look-ahead points, so nothing is
pre-loaded. The arrows tilt the view off nadir in 5° steps
along the frame's axes (Up/Down tilt forward/aft along the ground track,
Left/Right across it, each axis to ±45°; Shift takes no larger step); Home,
`0` or NADIR return to nadir. While tilted the panel shows the total angle
from nadir, acos(cos along · cos across), before each axis
(`OFF-NADIR 18.0° · FWD 10° · RIGHT 15°`; N, S, E and W for a north-up view)
and a NADIR button, and the swath readout turns approximate
(`SWATH ≈ 26 KM`): an estimate of the ground width at the view's centre, the
nadir footprint scaled by 1/cos² of the tilt across the frame and 1/cos of
the tilt along it (`offNadirSwathScale`, flat Earth). At nadir it reads the
footprint without ≈. After a zoom step lands, the focus pull holds until the
imagery under the view has loaded (at most 2.5 s, `zoomSettleHolding`), then
the reticle locks. The look keys and zoom keys keep the bridge's focus
guards.
In orbit the briefing's Live Signals page (OBSERVED / MAPPED PINGS) lists
what is around the satellite, as the aircraft Cockpit lists the contacts
of its Contacts snapshot (`src/ui/cockpitOrbitalSignals.js`): five rows at
most, rebuilt once a second with stable keys, so a row keeps its place and
time. TRACK ACQUIRED stays first. Then:
- **Sites**: the Mapped Installations layer's loaded records around the
sub-satellite point (`getNearby`, asked only as far as the reach extends
over the ground, and never past 3,000 km, so from a high orbit only the
sites within 3,000 km of the point below are listed) that face the
satellite and lie within the off-nadir reach, nearest to nadir first, the focused site always among them. A row
reads the site's name over its class and off-nadir angle; its button
(`Keep the camera on …`) focuses the site, and on the focused site's row,
marked as the subject is (`FOCUS · …`, `Release the camera from …`), ends
the focus. With Mapped Installations off the layer keeps its records, so
nothing is listed then: a SITES STANDBY row says to turn the layer on.
- **Satellites**: the nearest shown satellites to the ridden one, debris
left out (the satellites layer's bounded `getNearestPositions`, measured
from the satellite itself), with their bearing from the top of the frame
and distance. A row (`Ride …`) rides that satellite. While there is a
satellite ridden before this one, a RETURN · _name_ row goes back to it; if
that satellite has left the catalogue it is dropped and RETURN offers the
one before. Riding stays in orbit: the satellites layer tracks the new
satellite (`trackById`), the Cockpit takes the camera back at once and
marks it as the ridden subject, any ground focus ends with the view easing
back to nadir, and the camera hops to the new satellite along the great
circle over 1.2 s (cut under reduced motion), keeping the zoom. Until the
camera is on the new satellite the page shows only TRACK ACQUIRED and
RETURN (and SITES STANDBY with Mapped Installations off), then rebuilds
at once. The new satellite's TRACK ACQUIRED carries the time it was
ridden. The hop starts from where the camera was, which does not follow
the old satellite on.
Sites take two rows while satellites fill the rest, more when there are
fewer satellites. The Contact card stays hidden in orbit.
The focus (`src/ui/cockpitOrbitalFocus.js`, geometry in
`src/cockpitOrbitalFocus.js`) aims the look each frame from the satellite's
position to the site's Earth-fixed position in the same track-up nadir
frame the arrows tilt, so the site stays at the centre as the satellite
moves. The turn onto it (and between sites) eases what is left of the swing
with the arrows' easing on a slower clock (250 ms time constant, about
0.75 s to 95%), then holds the site exactly, and cuts under
`prefers-reduced-motion`. A single click on a site's marker focuses it too:
the pick is resolved through the layer's own pick ids (`findByPick`: a named
marker's `{ installationId }` or a site entity). A click anywhere else
(a site beyond the reach included), the
focused site's row, NADIR, Home or `0` ends the focus and the view eases
back to nadir on the same clock; an arrow ends it (or the ease back) and
tilts on from where the view is. The layer reloads its thinned view as the
camera moves (about once a second in orbit), so a site in view can leave
its records for a moment: while the layer is on, a site seen in the last
10 s stays a candidate (sites do not move), so the rows hold still and a
row's site can be focused, and a focused site keeps its last position. The focus ends by
itself, easing back, when the site leaves the reach (either tilt axis past
±45°, or the site below the satellite's horizon), when the layer is turned
off, when another satellite is ridden, and with the orbital view. A site
beyond the reach cannot be focused. While focused the readout gives the
single off-nadir angle to the site, as image metadata does
(`OFF-NADIR 23.4°`, updated on the HUD's beat and not announced), names it
in a polite live region (`FOCUS · Camp Alpha`), and the swath reads
approximate as for a tilt, scaled by the tilt onto the site. Clicks still
reach Cesium's own handler and never untrack the satellite; a click a tool
owns (drawing) is ignored, and the draw tool still borrows the click while
it draws. A double-click on a site keeps or starts the focus on it and
never reaches Cesium's double-click, which would track the site and end the
view (the Cockpit's double-click routing in `src/ui/cockpitInput.js`); any
other double-click goes on as before. `src/ui/cockpitOrbitalFocus.test.mjs`
covers the rows, the easing in and out, the reach release, switching sites
and satellites, the hop, the layer turned off, click handling and the
readout, against the layers' method shapes.
The HUD (`#cockpit-hud[data-subject='satellite']`, body class
`cockpit-orbit`) mounts `CockpitOrbital` over the picture: name, class,
altitude, orbital speed, sub-satellite point, TRACK UP (NORTH UP without a
ground track) with a pointer that turns to keep north marked N on the frame,
zoom and swath. Under
the vision selector a "CURRENTLY OVER" band names what the sub-satellite point
is over, looked up offline from the bundled Natural Earth packs
(`src/data/placeBelow.js`: on land the first-level division as Natural Earth
names it and the country in the reader's language through `Intl.DisplayNames`,
"Sichuan, China", and from 8× the most prominent populated place within half
the view's smaller side, so it is in the picture, "Sichuan, China · near
Chengdu", from a bundled pack of Natural Earth populated places loaded on
first use (each pack loads on the first lookup that needs it, and the
top-centre loading status lists "Place names" until it arrives); else the
smallest named sea, gulf, strait, channel, sound or bay;
else the ocean; nothing in a coastline gap), at most every 2 s and only after
20 km of ground travel, or at once when a zoom step changes how much ground
the view shows. A division or populated-places pack that fails to load drops
only that part (the country still shows); a lookup that fails outright hides
the band rather than leave the last place up, and the next HUD beat looks
again. The readout card puts
the owner country's flag (drawn from one 15 KB sheet of every owner
country's flag, built from `flag-icons` by `scripts/build-owner-flags.mjs`
and loaded at startup, so a ride never waits for a flag; if the sheet will
not load the country code shows as a badge) or a text badge for an organisation before the name, the operator
under it, and a NORAD · COSPAR · TLE-epoch line, ending with the element
set's age (`<1 H OLD`, then whole hours, then whole days: the gap, either
side, between the epoch and the world-clock time the satellite is shown at), whose TLE button
unfolds the two element lines; each row is left out when the satellites layer does not
know it. The
aircraft and bridge instruments, tapes, compass, radar, look controls,
position readout, route card and Contact card are hidden; the vision-style
switcher, briefing card (Live Signals as above, Local and Regional for the sub-satellite point; the
regional brief refreshes at most once a minute) and the reset and EXIT ORBIT
buttons stay, square-cornered like the bridge. Exit (`Escape`, EXIT
ORBIT) restores the camera's own field of view exactly and returns to the
satellite's follow camera through the layer's `refocusTrackedById`. Losing
the subject (tracking stopped, Satellites off) or tracking another target
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
covers entry, camera, zoom, look, HUD and exit.
## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions,
@@ -1685,6 +2062,7 @@ video; camera poses use the existing low-confidence heading prior.
The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. The app first reads the daily MOTC 2.0 CCTV.xml table with a keyless HTTPS request, a 15-second deadline, a 6 MiB response cap, and the shared 15-minute cache. If that catalog is unreachable and no last-good source list exists, the loader uses the Highway Bureau’s public provincial-road CCTV XML feed at https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml. The fallback is capped by the same Taiwan source limit and hourly retry backoff; its MJPEG feeds are restricted to the eight cctv-ss01 through cctv-ss08.thb.gov.tw HTTPS servers. The frame proxy returns one bounded JPEG from each stream, not an unbounded video response. The Highway Bureau open-data page states XML downloads are free and asks users to attribute the source; the app credits Highway Bureau, MOTC. These are provincial-road cameras, not a replacement for the national freeway cameras. CCTV_TAIWAN_ENABLED=0 disables both feeds; CCTV_TAIWAN_MAX_SOURCES sets the shared cap (default 600, bounded to 1–2000), and CCTV_TAIWAN_URL overrides the Freeway Bureau table URL.
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs.
## DriveBC CCTV source pack
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
@@ -2035,6 +2413,17 @@ Mapped-installation zoom guidance appears in the layer row without counting as
a global loading failure. Guidance statuses do not suppress independent refresh
errors.
Transit and Bike Share say the same when no feed or system covers the view,
instead of an undated `never`. Above their altitude gates the rows read
`GTFS-RT · Fly below 2,850 km to a covered region` and
`GBFS · Fly below 48 km to a covered city`; lower, over a city neither covers
(London, Tokyo), `GTFS-RT · No feed here yet · N regions available` and
`GBFS · No bike share here yet · N systems available` (covered regions,
catalogued systems). The chip stays ON. A loading or failed Bike Share city
shows its own text. Leaving coverage clears the last-update time. The voice
analyst gets the same English notes; both translate them through
`translateStatusMessage` (`src/ui/statusText.js`).
## Places and CCTV request bounds
With a Google key configured, nearby and text search reject missing, blank,
@@ -2147,8 +2536,9 @@ Overpass query validation, geometry simplification, disk caching and upstream
transport now have separate modules; military-installation search reuses that
transport. Regional briefing combines separate place, news and weather sources,
while weather effects uses only the weather source.
GDELT compact dates that do not name a real calendar instant keep the article
with an unknown publication time. Existing process-scoped
GDELT compact dates that do not name a real calendar instant normalize to an
unknown publication time, and the 24-hour regional-news window then drops the
article. Existing process-scoped
caches, rate limits, stale fallbacks and route ordering are preserved.
Local voice has separate HUD-summary, debug-log and Realtime-token handlers,
@@ -2528,6 +2918,17 @@ in `ais-store.js`. Common response caps, request coalescing and query parsing
have their own modules. `server/providers/local.js` composes these with the
remaining providers and retains existing named compatibility exports.
The tracked flight also gets a photo. The browser asks Planespotters for it
directly (`src/sources/live/standalone.js`, read by
`src/data/planespottersPhoto.js`), once per aircraft, and the CONTEXT panel's
CONTACTS view shows it under the flight's name with the photographer's credit
and a link to the photo's page. Military aircraft and a converted TR-3B are
never looked up. A flight lookup (type, route or photo) whose source failed
temporarily is asked again after a 60-second cool-down while the aircraft
stays in view or tracked; an answer, found or not, and a permanent refusal
stay final for the session. A route belongs to the callsign it was looked up
for, so a callsign change drops the old route. See `DATA_SOURCES.md`.
`gods-eye-view/server/providers/live` is a Node-only entry for the existing
plugins and shared request helpers. Importing it starts no sockets or timers.
The existing aircraft normalizer is separately available through the portable
@@ -2599,6 +3000,16 @@ reporting are unchanged.
## FIRMS source status
The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and
Aqua). It skips VIIRS Suomi NPP, which NASA stopped delivering on 2026-11-02.
`/api/firms` answers the trailing 24 hours to now, and the Active Fires row
reads `NASA FIRMS · LIVE · updated …`. A source may instead answer the 24 hours
to a set time, naming its end as `at` (epoch ms): the row then reads
`NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and
`get_active_fires` says "in the 24 hours to" that time and reports it as
`window_end`. The dev proxy never sets `at`.
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
The `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
including zero-padded hours and minutes; missing or invalid dates and times
@@ -2885,7 +3296,7 @@ This is the current runtime/source-of-truth snapshot for the project.
> `HELD_SNAP_CONTRADICTION_M` (5 m) ABOVE the held value drops the hold and the
> contact is COLD again, back to the floored 2D billboard. The rule is
> ONE-SIDED on purpose — it targets BURIAL. A cell reading BELOW a real sample
> is the floor chain's expected under-read (one-shot latch over ~111 m,
> is the floor chain's expected under-read (a few point readings over ~111 m,
> neighbours lean lowest, `displayFloorHeightM` only ever raises), not evidence
> of anything, and a two-sided cut was disproved on the track rig by a planted
> cell 66.7 m below a real sample at the same spot. Accepted residual, stated
@@ -3456,7 +3867,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row
> places the single Cockpit entry before Search Nearby Sites. Cockpit removes
> the duplicate floating map entry and topline exit; the bottom-center
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape` own exit, with entry/exit focus
> transfer and failure-safe shortcut routing. The exit control sits at the
> bottom-center compass position. A Cockpit-only control strip sits
> directly above Live Signals, anchored 12px under the REC readout it shares
@@ -3881,10 +4292,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> style-switch persistence state. Browser/GPU performance comparison remains
> operator-side; the final accounting records only comparable population
> reductions and deterministic Node allocation/solve measurements. Those
> allocation gates were calibrated on Node.js 24.14.x and run in CI on the
> pinned Node.js 24.22.2 with the same ceilings; `package.json` permits
> supported product runtimes on Node 24 or 26, while the allocation runner
> separately enforces Node 24 for these two calibrated probes. Each probe
> allocation gates run on both supported Node majors (24 and 26) with
> unchanged byte ceilings; the existing CI matrix executes both probes
> on its Node 24.22.2 and Node 26 entries. Each probe
> compiles synchronously and discards explicit-GC
> transition chunks before applying the unchanged byte ceilings. The unit
> runner executes ordinary test files with Node's default parallelism, then
@@ -3937,8 +4347,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> advances dead reckoning from the host frame.
> - **Scheduled route plausibility:** Aircraft route labels and selected-route
> context reject the distant backwards, forwards and antipodal extensions of
> an airport pair's great circle. The existing 130 km endpoint admission and
> 200 km cross-track tolerance remain; a projected fix outside the finite
> an airport pair's great circle. The existing 130 km endpoint admission
> remains; the cross-track tolerance is 200 km or 15% of the route's length,
> whichever is larger (JFK–SFO allows 623 km, as airways and winds take a
> coast-to-coast flight several hundred kilometres off the great circle); a
> projected fix outside the finite
> origin-to-destination minor arc also has a generous 200 km extension at
> either end. Missing coordinates and coincident or antipodal endpoint pairs
> retain their loose, unjudgeable behavior; vertical-trend checks are unchanged.
@@ -4327,7 +4740,9 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **he**,
**it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and
**zh-Hant**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
@@ -4341,19 +4756,21 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, *
`navigator.languages` → English. Any `pt` tag (`pt-BR`, `pt-PT`) selects `pt`. Chinese tags default to `zh-CN`; Traditional Chinese tags
select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop
it, so a recipient keeps their own language. The globe picker in the
bottom-right corner (bottom-left in Arabic) (`src/ui/localePicker.js`) stores the choice, drops
bottom-right corner (bottom-left in Arabic and Hebrew)
(`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and switches in place:
views re-render, code that published translated text words it again through
`onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup
follow. Nothing is lost; a toast already on screen keeps its words.
- **Right to left.** Arabic (`ar`) is in `RTL_LOCALES`, so `<html dir="rtl">`:
the title, style indicator, both panel stacks and their icon rails swap
sides through logical properties (`inset-inline-*`, `margin-inline-*`), the
dock and corner row reverse their order, and `src/ui/styles/rtl.css` holds
the rest: no letter-spacing on Arabic text, Noto Sans Arabic in the font
stacks, the intel HUD kept as a left-to-right instrument overlay, FM seek
and the radio dial kept left to right. Intl formatters get `ar-u-nu-latn`,
so numbers and dates keep Western digits.
- **Right to left.** Arabic (`ar`) and Hebrew (`he`) are in `RTL_LOCALES`, so
`<html dir="rtl">`: the title, style indicator, both panel stacks and their
icon rails swap sides through logical properties (`inset-inline-*`,
`margin-inline-*`), the dock and corner row reverse their order, and
`src/ui/styles/rtl.css` holds the rest: no letter-spacing on Arabic and
Hebrew text, Noto Sans Arabic and Noto Sans Hebrew in the font stacks, the
intel HUD kept as a left-to-right instrument overlay, FM seek and the radio
dial kept left to right. Intl formatters get `ar-u-nu-latn`, so numbers and
dates keep Western digits.
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, empty, unused,
placeholder-mismatched, still-English (plural forms included) or
@@ -4402,36 +4819,37 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Shipwrecks ⚓ (`shipwrecks`) | EMODnet Human Activities heritage shipwrecks (European waters, keyless WFS, CC BY 4.0) and Wikidata (notable wrecks worldwide, keyless, CC0) | `src/layers/shipwrecks/` via `src/app/layers/shipwrecks.js` | `/api/shipwrecks` | 6 h page check; server refresh once a day (stale-on-error up to 7 days) |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×2 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Shipwrecks ⚓ (`shipwrecks`) | EMODnet Human Activities heritage shipwrecks (European waters, keyless WFS, CC BY 4.0) and Wikidata (notable wrecks worldwide, keyless, CC0) | `src/layers/shipwrecks/` via `src/app/layers/shipwrecks.js` | `/api/shipwrecks` | 6 h page check; server refresh once a day (stale-on-error up to 7 days) |
| News ◆ (`news`) | The last 24 h of reports in two sections, Conflicts and Local, with hotspots, outlines and disputed areas: from a news server (`GEV_NEWS_ORIGIN`), else Core's own reading of GDELT 2.0 events (no key), Liveuamap (`LIVEUAMAP_API_KEY`) and World News API (`WORLDNEWSAPI_KEY`) | `src/layers/news/` via `src/app/layers/news.js`; panel `src/ui/newsPanel.js`, `src/ui/news/`; server `server/providers/news.js`, `server/providers/news/` | `/api/news/hotspots`, `/api/news/feed`, `/api/news/outline` (forwarded to `GEV_NEWS_ORIGIN` when set, else answered by Core) | 2 min map refresh; Local re-read when the camera settles 0.8 s in a new half-degree cell below 3,000 km |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4459,6 +4877,10 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
The incident card rounds the WFIGS size to whole acres, except under one acre,
where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not
`0 ac`.
Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in
Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA
FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last
@@ -4866,9 +5288,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord
places Transit directly after Street Traffic and before Bike Share. Cameras and
utilities retain their own groups. The layer polls
registered feeds near the camera every 15 seconds, below a 3,000 km altitude
gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
gate. Forty-nine keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
(municipal buses and the Mobi-Rio BRT, two feeds), and nine in
(municipal buses and the Mobi-Rio BRT, two feeds), Szczecin, the Lisbon Metropolitan Area and the city of Lisbon, Prague and Central Bohemia, Warsaw, Kraków (buses and trams, two feeds), Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses, two feeds), 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 (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Mode silhouettes
@@ -4935,8 +5357,11 @@ visual inspection.
**Appearance.** Normal fleet frames are 20 px, growing to 30 px on selection;
CRT, NVG, thermal and noir frames are 26/39 px. Source rasters are 48/96 px before padding.
External halos are 1/1.5 px in normal, 1.25/1.25 px in CRT and 2/2 px in sensors, calculated from the
final unpadded display size. Six modes, two sizes and three profiles cap the
raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram
final unpadded display size. Each mode has an outline with a front (a nose, a
bow or a chamfered cab) for a vehicle with a known course and a symmetric
outline for one without, which would otherwise sit at rotation 0 and point
north; a vehicle switches to the pointed glyph when a course arrives. Six modes, two sizes, three
profiles and the two outlines cap the raster cache at 72 variants. Normal mode colours are bus `#5EF08A`, tram
`#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown
`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact
outer path, filled opaque white without interior panel lines, with an opaque
@@ -5413,14 +5838,16 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026)
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal, except the tracked aircraft, which is kept until 10 minutes after its newest fix. When a position epoch pauses, both layers coast for five minutes on the last speed and heading (at most 1,500 kt), then stop dimmed as SIGNAL LOST; contacts on or near known ground hold instead. Source backoff marks each contact and the cockpit `STALE` and dims the tracked aircraft, as a missed update does; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
- **Satellite classes** (`src/data/satelliteClass.js`): every satellite is classified from the CelesTrak group it was ingested with — no extra fetch and no heuristics — into STATION (warm white), NAV (cyan; GPS/GLONASS/Galileo deliberately share one color so the GNSS family reads as one thing), GEO (violet), VISUAL (muted blue-gray catch-all), and COMMS (dim slate, dense Starlink shell only). That module is the single source of truth for class, label, and color, so the dot, the card, and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and COMMS stays far below VISUAL in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATION color — it carries a permanent name label and its card still reads `STATION · ISS`. `satelliteClassOf` is the single owner of that ISS rule, so the card label and the legend tally can never disagree: during a stations-feed outage the ISS is ingested as `visual`, and both surfaces still file it under STATION.
- Class is also a **text field**, not just a color: `NAV · GPS` / `GEO` / `COMMS · STARLINK` / `STATION · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened.
- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): a **DENSE** chip exposing the existing `catalog` param, and a swatch legend with live per-class counts. Default is the sparse core catalog. The chip is stateless — it declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not.
- **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale.
- **Satellites**: core catalog from CelesTrak's stations, visual, GPS, GLONASS, Galileo, GEO, resource, weather, planet, SAR and science groups, tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
- **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class.
- Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened.
- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not.
- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/<group>`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Debris can be tracked but is never ridden: the orbital entry gate (`orbitEntryAllowed`) refuses it, so no ORBIT entry is offered and double-click does nothing for it. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead.
- The satellite Cockpit subject (`getCockpitSubject()`) also carries `satelliteClass`, `cosparId` (from SATCAT, else TLE line 1), `tle` (`{line1, line2, epochMs}`), `owner` (`{countryCode, label}`: ISO 3166-1 alpha-2 for a single country, `null` code for organisations such as ESA, EUMETSAT or Intelsat and for joint owners) and `operator` (UCS; SpaceX for Starlink). UCS names the operator's country and wins over the SATCAT owner code; CelesTrak's CIS (former USSR) is filed under Russia.
- **The STARLINK chip (formerly DENSE) reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale.
- **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert.
- The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe.
- **Catalog numbers above 99999** can be written in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. CelesTrak's TLE feed carries no satellite above 99999 (that needs the OMM feed, #1191), so this is compatibility with Alpha-5 TLEs. `src/sources/tle.js` decodes the field through one shared helper: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. Plain digits read as before (`noradIdFromSatnum()` accepts any length, as an OMM satrec carries the number unpadded); a malformed field — a sign, decimal point, hex, exponent, embedded space, stray or lowercase letter, or blank — is rejected rather than partly read, as `NaN` in the layer and `null` in the tools.
@@ -5548,7 +5975,7 @@ silently demoting every later lookup for the session.
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
@@ -5670,6 +6097,13 @@ and unreachable upstream (502/504) separately from road geometry.
explicit-empty form); unknown option tokens are ignored. Restoration
settles independently per layer so one failed or unavailable source cannot
block its siblings.
- An application that registers fewer layers (an embedder with a smaller
catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids
the application did not register before it normalizes a share, saved state or
explicit change, so such a layer is never restored, written to local storage
or carried into a link this application shares. Options that depend on a
dropped layer (a tracked selection) go with it. An application with every
layer registered is unchanged.
- Layer tokens are permanent public compatibility identifiers. The reservation
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
one-character assignments (all letters plus `0`, `1` and
@@ -5804,12 +6238,11 @@ and unreachable upstream (502/504) separately from road geometry.
> **Reading `npm test` totals:** the count depends on the Node major. The two
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the
> calibrated Node 24 runtime; on any other major the runner skips both files and
> their 14 tests are absent from the total. A branch total quoted without its
> Node version is therefore not reproducible. As of the fly_route cinematic
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) RUN on both
> supported Node 24 and Node 26. Unsupported majors may still skip those files
> unless `GEV_REQUIRE_ALLOCATION_GATE=1` is set; every current supported CI
> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25
> runs can therefore remain lower because their 14 allocation tests were skipped.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
@@ -6053,7 +6486,9 @@ request viewbox and continues searching without that geographic preference.
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
- **The pointer, for an application's own tool.** `createApplicationTools` also returns `pointer` (`data/inputOwnership.js`): `claim(owner)` takes the pointer as Draw does and returns its lease, or `null` while another tool holds it; `release(lease)` gives it back; `isFree()` lets the application's own click handlers yield, as the layers' do. An owner without a name in `pointerOwnerNames.js` is named by its id when another tool reports who holds the pointer.
- **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
@@ -6069,6 +6504,15 @@ request viewbox and continues searching without that geographic preference.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
### News (November 2026)
- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. A selected report's card reads "Source: host ↗" and opens the article.
- **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report.
- **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working.
- **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN` when it is set.
- **Built-in backend.** With `GEV_NEWS_ORIGIN` unset, Core answers the same routes itself (`server/providers/news/keyed.js`). **Conflicts** needs no key: the last 24 h of GDELT 2.0 event exports (15-minute files on `data.gdeltproject.org`, about 100 KB each), physical violence only (CAMEO roots 18–20), one report per story with copies of one agency story merged, placed at GDELT's location for it (a town the article's words name beats a bare country). Titles are the article's page title from GDELT's GKG export of the same quarter hour (about 6 MB each) for the newest 3 h, else the words of the article's address; GDELT's actor coding is never shown. With `LIVEUAMAP_API_KEY`, Liveuamap's mapped items stand beside GDELT's. **Local** reads World News API (`WORLDNEWSAPI_KEY`) by the source country at the view centre, once per country every 30 minutes (about 1.5 points a read, at most `WORLDNEWSAPI_DAILY_READS` reads a day, default 30), plus the Conflicts reports placed inside the area. A country's own stories keep World News API's category (politics, business, else other) and no severity: the rules read fighting and crises, and everyday headlines mislead them. Areas come from the bundled Natural Earth countries and disputed areas (`server/providers/news/areas.js`). Conflicts' categories, relevance and severity are bundled rules (`categorize.js`); a news server may replace them. A read with nothing current to serve (GDELT's, Liveuamap's or a country's) is waited for at most 10 s, under the client's 15 s timeout, and goes on past that for the next refresh. Meanwhile GDELT serves the exports read so far and Conflicts serves GDELT without Liveuamap; Local in a country answers unavailable (502) until its outlets' read lands, and serves them without the Conflicts reports while those are missing. A section left with nothing answers unavailable, and a refused key stays refused. A country's World News API news is kept for at most 1 hour and then deleted; a request that never reached World News API is not counted against the day's reads. A story with neither a page title nor words in its address is minor (GDELT's code alone), so it is not listed under Conflicts, and the hotspot counts leave minor reports out. The routes sit behind the cross-site gate for key-spending routes (`sameSiteGated`), a request line that is no URL answers 400, and a news server's 503 key states (`no_key`, `key_rejected`, `key_quota`) and `news_not_configured` are relayed as they are.
- **State.** Share/restore token `00`, enabled-only. Voice is off for this layer (`VOICE_OFF_LAYERS`).
### S2 GhostMaps (October 2026)
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
@@ -6229,6 +6673,8 @@ apply to `public/` assets or built-preview serving.
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
- `/api/flights`, `/api/military`, and `/api/route` answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET`, before rate limiting and any upstream call.
- `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding.
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail).
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
@@ -6313,21 +6759,24 @@ apply to `public/` assets or built-preview serving.
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
layer, Live AIS Vessels and every other layer may be turned on or off before
or during a session. A Context mode change still in flight blocks entry until
it settles. Turning off the layer the view rides ends it. Riding an aircraft
while Contacts is on adopts its layer as the user's own, as a direct selection
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
its layers going off, restores the pre-Contacts layers but keeps the ridden
one and the view; Live Signals then drops the rows Contacts built and shows
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
the same gate; voice entry still opens Contacts first, as its layer-targeted
entry navigates through Contacts.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
it settles. Turning off the layer the view rides ends it. Riding an aircraft,
or a vessel from its bridge, while Contacts is on adopts its layer as the
user's own, as a direct selection does (`adoptLayerVisibility`), so Contacts
ending, by its own switch or one of its layers going off, restores the
pre-Contacts layers but keeps the ridden one and the view; Live Signals then
drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible
Cockpit actions, double-click and voice use the same gate; voice entry still
opens Contacts first, as its layer-targeted entry navigates through Contacts.
The ship bridge and the orbital view have their own own-layer gates
(`bridgeEntryAllowed`, `orbitEntryAllowed`; see
[Ship bridge view](#ship-bridge-view) and [Orbital view](#orbital-view)).
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Regional News holds its list to the last 24 hours (`newsWindowHours` in the brief; the client drops anything older or undated even if a server sends it) and shows four distinct states: fresh results with no banner and a `{SOURCE} · LAST 24 H` footer; earlier results kept after a failed refresh under `CACHED · REFRESH FAILED` with the cached age in the footer; `NO NEWS IN THE LAST 24 H` when a source answered wiLine truncated
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query for the last day (`when:1d`) first, with the existing GDELT query (24 h timespan) retained only as a fail-soft fallback; both keep only articles published in the last 24 hours, newest first; linked headlines remain reporting, not verified incidents or risk intelligence.
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
+44 -1
View File
@@ -1,6 +1,6 @@
# KNOWN ISSUES
Updated: September 22, 2026
Updated: November 5, 2026
This file tracks active runtime issues only.
@@ -145,6 +145,49 @@ 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
+5
View File
@@ -119,6 +119,7 @@ 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.
@@ -132,6 +133,10 @@ 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).
+5 -4
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`), **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`), **Hebrew** (`he`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works
@@ -45,9 +45,10 @@ 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`.
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json` and in `scripts/format-scope.json`.
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
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.
## Rules
@@ -63,4 +64,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), [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), [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.
+64
View File
@@ -0,0 +1,64 @@
# 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&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&family=Noto+Sans+Hebrew: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,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,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,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" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+8
View File
@@ -28,6 +28,7 @@
"@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",
@@ -2411,6 +2412,13 @@
}
}
},
"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,6 +76,7 @@
"@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",
@@ -90,6 +91,7 @@
"./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"
},
@@ -97,6 +99,7 @@
"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"
},
@@ -135,6 +138,9 @@
"./server/providers/regional": {
"node": "./server/providers/regional.js"
},
"./server/providers/news": {
"node": "./server/providers/news.js"
},
"./server/providers/openai": {
"node": "./server/providers/openai.js"
},
@@ -258,6 +264,7 @@
"./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",
+57 -1
View File
@@ -7,13 +7,16 @@
* 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]
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties|places]
*
* The provenance READMEs beside each pack record these parameters.
*/
@@ -101,6 +104,9 @@ 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);
@@ -688,6 +694,47 @@ 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');
@@ -768,4 +815,13 @@ 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
@@ -0,0 +1,101 @@
#!/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
@@ -0,0 +1,118 @@
#!/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()
+17 -1
View File
@@ -112,6 +112,8 @@
"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",
@@ -328,6 +330,12 @@
"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",
@@ -1069,6 +1077,7 @@
"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",
@@ -1120,6 +1129,7 @@
"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",
@@ -1292,6 +1302,7 @@
"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",
@@ -1365,5 +1376,10 @@
"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"
"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"
]
File diff suppressed because it is too large. Load diff
+3
View File
@@ -4,6 +4,7 @@ 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');
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
await stubPlanespotters(page);
const failures = [];
const consoleErrors = [];
const localHttpErrors = [];
@@ -825,6 +827,7 @@ 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,6 +28,7 @@
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);
@@ -77,6 +78,7 @@ 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,6 +27,7 @@
*/
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';
@@ -223,6 +224,7 @@ 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,6 +21,7 @@ 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) => {
@@ -651,6 +652,7 @@ async function main() {
],
});
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport(VIEWPORT);
const consoleMessages = [];
page.on('console', (message) => {
+2
View File
@@ -99,6 +99,7 @@
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)
@@ -466,6 +467,7 @@ 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 only for a parseable Node 24.
* @returns {boolean} true for a parseable supported Node 24 or 26.
*/
function isCalibratedAllocationRuntime(version) {
const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10));
return maj === 24;
return maj === 24 || maj === 26;
}
/**
@@ -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 are pinned to Node 24)',
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)',
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 Node 24.
// runs that gate under a discovered calibrated Node 24 fallback.
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 (they silently skip off Node 24)',
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors',
run: async () => {
// npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still
// 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.
// 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.
//
// ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm
// ALWAYS invoke the calibrated Node 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 Node 24,
// run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors,
// 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 — 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 or 26 — 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) return runGate(process.execPath, 'this runtime');
if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime');
if (env.node24) return runGate(env.node24.bin, env.node24.label);
return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN');
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');
},
});
+78
View File
@@ -0,0 +1,78 @@
/**
* 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,6 +32,7 @@
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
@@ -186,6 +187,7 @@ 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') {
+241
View File
@@ -0,0 +1,241 @@
#!/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,
styling.iconCacheSize <= 6 * 2 * 3 * 2,
`${styling.iconCacheSize} rasters`,
);
}
+11 -11
View File
@@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([
'src/overlays/worldOverlayAllocation.test.mjs',
]);
/** Whether this runtime matches the one the allocation budgets were calibrated on. */
/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */
export function isCalibratedAllocationRuntime(version = process.versions.node) {
return Number.parseInt(String(version).split('.')[0], 10) === 24;
const major = Number.parseInt(String(version).split('.')[0], 10);
return major === 24 || major === 26;
}
/** Require the runtime on which allocation budgets were calibrated. */
export function assertNode24AllocationRuntime(version = process.versions.node) {
/** Require a runtime on which allocation budgets are calibrated. */
export function assertCalibratedAllocationRuntime(version = process.versions.node) {
if (!isCalibratedAllocationRuntime(version)) {
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`);
throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`);
}
return version;
}
@@ -92,19 +93,18 @@ export function runUnitTests() {
const parallelStatus = runTests(['--test', ...plan.parallel]);
if (parallelStatus !== 0) return parallelStatus;
// 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
// The GC-bracketed budgets are calibrated on every supported Node major.
// Unsupported runtimes may still run ordinary tests, 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') {
assertNode24AllocationRuntime();
assertCalibratedAllocationRuntime();
}
console.warn(
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
+ `budgets are calibrated for Node 24, running ${process.versions.node}. `
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
+ `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).',
);
return 0;
}
+165 -21
View File
@@ -42,8 +42,9 @@
* 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
* (3 missed polls via the shim), tracking clears and
* the camera is RELEASED IN PLACE: viewer.trackedEntity
* 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
* undefined, NO jump (owner decision 2026-07-02 —
* the old ~80 km overview flyTo is gone).
*
@@ -94,6 +95,7 @@
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';
@@ -287,6 +289,8 @@ 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,
@@ -359,7 +363,12 @@ async function main() {
'Synthetica', // 2 origin_country
Math.floor(Date.now() / 1000), // 3 time_position
Math.floor(Date.now() / 1000), // 4 last_contact
f.lon, // 5 longitude
// 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.lat, // 6 latitude
f.alt, // 7 baro_altitude (m)
f.onGround === true, // 8 on_ground (ground-traffic phase flips this live)
@@ -409,6 +418,15 @@ 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
@@ -1486,6 +1504,110 @@ 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.
@@ -1732,7 +1854,8 @@ async function main() {
// ============================================================
// REGRESSION M3 (rewritten 2026-07-02 — deliberate behavior change):
// when the TRACKED plane ages out (3 missed polls), tracking must clear
// 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
// 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
@@ -1753,27 +1876,48 @@ 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);
// 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.
// 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.
await fl.update(v);
await fl.update(v);
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,
};
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;
}
}, 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,6 +83,15 @@ 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');
+69 -22
View File
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import { encodedBody, gzipBody } from '../common/compress.js';
import {
coalesceProxyRequest,
readResponseJsonCapped,
@@ -18,6 +19,11 @@ let _openskyTokenExpiry = 0;
let _openskyTokenPromise = null;
/** @type {string|null} Cached upstream response body (JSON text). */
let _openskyCacheBody = null;
/**
* @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it.
* Null when the body is not worth compressing.
*/
let _openskyCacheGzip = null;
/** @type {number} HTTP status of the cached response. */
let _openskyCacheStatus = 0;
/** @type {number} Epoch-ms when the response was cached. */
@@ -399,8 +405,12 @@ async function fetchAdsbLolPointFallback(req) {
ADSBLOL_POINT_MAX_RESPONSE_BYTES,
);
const normalized = normalizeAdsbLolPointResponse(payload);
const body = JSON.stringify(normalized);
const record = {
body: JSON.stringify(normalized),
body,
// Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the
// extra memory.
gzip: await gzipBody(body),
cachedAt: Date.now(),
count: normalized.states.length,
};
@@ -429,6 +439,7 @@ async function fetchAdsbLolPointFallback(req) {
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
const fallback = await fetchAdsbLolPointFallback(req);
if (!fallback) return false;
const encoded = encodedBody(req, fallback.body, fallback.gzip);
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: fallback.cacheStatus,
@@ -439,8 +450,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
'X-Flight-Source': 'adsb.lol',
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
'X-Flight-Count': String(fallback.count),
...encoded.headers,
});
res.end(fallback.body);
res.end(encoded.body);
return true;
}
@@ -484,6 +496,15 @@ export function openSkyProxy() {
// 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;
}
res.setHeader('X-Feed-Source', 'OpenSky Network');
const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
@@ -514,9 +535,13 @@ export function openSkyProxy() {
reason: 'cached',
};
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
cacheStatus: isStale ? 'STALE' : 'HIT',
requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown',
@@ -532,8 +557,9 @@ export function openSkyProxy() {
? (_openskyCooldownUntil - now) / 1000
: undefined,
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
// Cooling down with nothing cached (cold start into a rate limit):
@@ -628,7 +654,11 @@ export function openSkyProxy() {
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
// Compress before writing either slot, so a request arriving
// mid-refresh still reads a body with its own frame.
const frame = await gzipBody(body);
_openskyCacheBody = body;
_openskyCacheGzip = frame;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
@@ -681,9 +711,13 @@ export function openSkyProxy() {
// Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) {
res.writeHead(
200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode,
usedMode,
@@ -691,8 +725,9 @@ export function openSkyProxy() {
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
}
@@ -734,16 +769,23 @@ export function openSkyProxy() {
}
}
res.writeHead(
upstream.status,
buildOpenSkyHeaders({
// The frame belongs to this body only if this body was cached. An
// uncached error must not borrow the previous snapshot's frame.
const encoded = encodedBody(
req,
body,
_openskyCacheBody === body ? _openskyCacheGzip : null,
);
res.writeHead(upstream.status, {
...buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode,
reason,
}),
);
res.end(body);
...encoded.headers,
});
res.end(encoded.body);
} catch (e) {
console.error('[OpenSky Proxy]', e.message);
if (_openskyCacheBody) {
@@ -754,17 +796,22 @@ export function openSkyProxy() {
usedMode: 'unknown',
reason: 'cached_stale',
};
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode:
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
usedMode: cachedMeta.usedMode || 'unknown',
reason: cachedMeta.reason || 'cached_stale',
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
const requestedMode = normalizeOpenSkyAuthMode(
+81
View File
@@ -0,0 +1,81 @@
import { gzip } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
/**
* Opt-in gzip for text bodies a proxy already holds in memory.
*
* Not a middleware: `cctv/media.js` streams bodies and answers range requests,
* and several providers forward binary upstream types that gzip only grows.
* `encodedBody` returns the body with its headers so the two cannot disagree.
*/
/** Skip compression for bodies smaller than 1 KiB. */
const MIN_COMPRESSED_BYTES = 1024;
/**
* RFC 9110 section 12.4.2 `qvalue`. Matching the whole token keeps `q=1oops`
* and `q=2` unreadable instead of rounding them into acceptance.
*/
const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
/**
* Whether `Accept-Encoding` offers gzip. Anything unreadable (no header, a bad
* q value, `x-gzip`) reads as no: every client can decode identity.
*
* @param {import('node:http').IncomingMessage} request
* @returns {boolean}
*/
export function acceptsGzip(request) {
const header = request?.headers?.['accept-encoding'];
if (typeof header !== 'string') return false;
return header.split(',').some((part) => {
const [token, ...parameters] = part.split(';');
if (token.trim().toLowerCase() !== 'gzip') return false;
const quality = parameters
.map((parameter) => parameter.trim().toLowerCase())
.find((parameter) => parameter.startsWith('q='));
if (quality === undefined) return true;
// `q=0` names gzip and refuses it.
const value = quality.slice(2);
return QVALUE.test(value) && Number(value) > 0;
});
}
/**
* Gzip a body once for a caller that caches it. Async, so the ~860 KB OpenSky
* snapshot does not block other requests while it compresses.
*
* Returns null, meaning "serve identity", when the body is under 1 KiB or gzip
* does not shrink it.
*
* @param {string|Buffer} body - the exact bytes the identity response sends.
* @returns {Promise<Buffer|null>}
*/
export async function gzipBody(body) {
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body);
if (raw.byteLength < MIN_COMPRESSED_BYTES) return null;
const compressed = await gzipAsync(raw);
return compressed.byteLength < raw.byteLength ? compressed : null;
}
/**
* Pick the body and the headers that describe it. `Vary` is on both branches
* so a shared cache never serves one encoding to a client that asked for the
* other.
*
* @param {import('node:http').IncomingMessage} request
* @param {string|Buffer} identity - the uncompressed body.
* @param {Buffer|null} compressed - `gzipBody(identity)`, or null.
* @returns {{body: string|Buffer, headers: Record<string,string>}}
*/
export function encodedBody(request, identity, compressed) {
if (compressed && acceptsGzip(request)) {
return {
body: compressed,
headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' },
};
}
return { body: identity, headers: { Vary: 'Accept-Encoding' } };
}
+11 -9
View File
@@ -131,9 +131,9 @@ export async function fetchFirms(
* NASA FIRMS live active-fire proxy with a memory + disk cache.
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
*
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
* satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua,
* ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy)
* Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no
* cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, ~1 km, numeric
* 0-100 confidence), fetched sequentially (quota courtesy)
* with `days=2` (`days=1` means "current UTC day", nearly empty just after
* 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS
* quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
@@ -141,6 +141,8 @@ export async function fetchFirms(
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
*
* VIIRS_SNPP_NRT is left out: NASA stopped delivering Suomi NPP on 2026-11-02.
*
* Routes:
* GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires}
* GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions}
@@ -148,17 +150,17 @@ export async function fetchFirms(
* Keyless (no FIRMS_MAP_KEY): /api/firms → 503 {error:'no_key'}; status →
* {hasKey:false}. Upstream is never touched without a key.
*
* /api/firms answers the trailing 24 hours to now. A source may instead
* answer the 24 hours to a set time, naming its end as `at` (epoch ms); the
* layer's row then shows that time rather than LIVE. This proxy never sets
* `at`.
*
* @returns {import('vite').Plugin}
*/
export function firmsProxy() {
const TTL_MS = 30 * 60_000;
const STATUS_TTL_MS = 5 * 60_000;
const SOURCES = [
'VIIRS_NOAA20_NRT',
'VIIRS_NOAA21_NRT',
'VIIRS_SNPP_NRT',
'MODIS_NRT',
];
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT'];
/**
* Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about
* 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a
+2
View File
@@ -30,6 +30,7 @@ import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { ghostMapsProxy } from './ghostMaps.js';
import { shipwrecksProxy } from './shipwrecks.js';
import { droneAirspaceProxy } from './droneAirspace.js';
import { newsProxy } from './news.js';
import { inaturalistProxy } from './inaturalist/observations.js';
import { acartiaProxy } from './acartia.js';
import { cycloneProxy } from './cyclones.js';
@@ -85,6 +86,7 @@ function localProviderPlugins({
ghostMapsProxy(),
shipwrecksProxy(),
droneAirspaceProxy(),
newsProxy(),
inaturalistProxy(),
acartiaProxy(),
keySetupEndpoint(),
+191
View File
@@ -0,0 +1,191 @@
import { newsQueryAllowed } from '../../src/layers/news/source.js';
import { readResponseTextCapped } from './common/http.js';
import { sameSiteGated } from './common/same-site.js';
import { createKeyedNews } from './news/keyed.js';
// News (/api/news/*). With GEV_NEWS_ORIGIN set (a news server such as
// https://news.example), this forwards exactly the GET requests the news
// layer sends there and relays the JSON. Without it (or with a value that
// is no plain origin, warned of once), Core answers the same routes itself
// (news/keyed.js): Conflicts from GDELT's event exports with no key, and
// Liveuamap's items beside them with LIVEUAMAP_API_KEY; Local from World
// News API, "key required" while WORLDNEWSAPI_KEY is unset. A host that
// serves news only from its own server mounts it with `builtIn: false`.
const TIMEOUT_MS = 15_000;
const MAX_BYTES = 4 * 1024 * 1024;
/**
* The configured news server's origin, or null: http(s) only, no
* credentials, query or fragment; a path is ignored.
* @param {string|undefined} raw
*/
export function newsOrigin(raw = process.env.GEV_NEWS_ORIGIN) {
const text = String(raw ?? '').trim();
if (!text) return null;
try {
const url = new URL(text);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
)
return null;
return url.origin;
} catch {
return null;
}
}
/** Text for the log: no address (a key may ride in one), one short line. */
const logText = (error) =>
`${error?.name || 'Error'}: ${String(error?.message ?? '')}`
.replace(/[a-z][a-z0-9+.-]*:\/\/\S*/gi, '<url>')
.replace(/\s+/g, ' ')
.slice(0, 200);
/** GEV_NEWS_ORIGIN as newsOrigin reads it; a value it refuses is warned of once. */
function configuredOrigin(env, log) {
let warned = null;
return () => {
const raw = String(env().GEV_NEWS_ORIGIN ?? '').trim();
const base = newsOrigin(raw);
if (!base && raw && warned !== raw) {
warned = raw;
// Not the value: it may carry credentials.
log(
'[news] GEV_NEWS_ORIGIN is not a plain http(s) origin; answering with the built-in news backend',
);
}
return base;
};
}
/** A 503's known key or configuration state, rebuilt from its fields; else null. */
async function upstreamState(upstream) {
let body = null;
try {
body = JSON.parse(await readResponseTextCapped(upstream, 16 * 1024));
} catch {
return null;
}
if (body?.error === 'news_not_configured')
return { error: body.error, unavailable: true, retryable: false };
if (
['no_key', 'key_rejected', 'key_quota'].includes(body?.error) &&
typeof body.key === 'string' &&
/^[a-z][a-z0-9-]{0,31}$/.test(body.key)
)
return { error: body.error, key: body.key };
return null;
}
/**
* A thin, validated GET proxy for the news layer's three read routes, or
* their keyed answers without a news server. A host that serves news only
* from its own server passes `builtIn: false`: without GEV_NEWS_ORIGIN the
* routes then answer "not configured" and Core reads nothing itself.
* @param {{env?: () => object, log?: (message: string) => void, origin?: () => string|null, fetchImpl?: typeof fetch, timeoutMs?: number, builtIn?: boolean, keyed?: {answer: (path: string, params: URLSearchParams) => Promise<{status: number, body: object}>}|null}} [options]
*/
export function newsProxy({
env = () => process.env,
log = (message) => console.warn(message),
origin = configuredOrigin(env, log),
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = TIMEOUT_MS,
builtIn = true,
keyed = builtIn ? createKeyedNews({ env, fetchImpl, log }) : null,
} = {}) {
const json = (res, status, value) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
});
res.end(typeof value === 'string' ? value : JSON.stringify(value));
};
async function handler(req, res) {
if (req.method !== 'GET')
return json(res, 405, { error: 'method_not_allowed' });
let local, target;
try {
// `//[` and the like are no URL: a 400, not an unhandled rejection.
local = new URL(req.url || '/', 'http://local');
target = new URL(
`/api/news${local.pathname}${local.search}`,
'http://local',
);
} catch {
return json(res, 400, { error: 'invalid_news_request' });
}
if (!newsQueryAllowed(target))
return json(res, 400, { error: 'invalid_news_request' });
const base = origin();
if (!base) {
if (!keyed)
return json(res, 503, {
error: 'news_not_configured',
unavailable: true,
retryable: false,
});
try {
const { status, body } = await keyed.answer(
local.pathname,
target.searchParams,
);
return json(res, status, body);
} catch (error) {
log(`[news] built-in news answer failed: ${logText(error)}`);
return json(res, 502, { error: 'news_unavailable' });
}
}
try {
const upstream = await fetchImpl(
new URL(`${target.pathname}${target.search}`, base),
{
headers: { Accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs),
},
);
if (upstream.status === 503) {
// The news server's own key and configuration states reach the
// panel as they are, so it can say which key is missing.
const state = await upstreamState(upstream);
if (state) return json(res, 503, state);
}
if (!upstream.ok) {
await upstream.body?.cancel().catch(() => {});
return json(res, 502, {
error: 'news_upstream_error',
status: upstream.status,
});
}
const text = await readResponseTextCapped(upstream, MAX_BYTES);
// Relayed only as JSON; the browser validates every record.
JSON.parse(text);
return json(res, 200, text);
} catch (error) {
return json(res, error?.name === 'TimeoutError' ? 504 : 502, {
error: 'news_unavailable',
});
}
}
// The routes spend keys and upstream reads: no other site's page, and no
// proxied request, may drive them (SECURITY.md, cross-site gate).
const gated = sameSiteGated(handler);
return {
name: 'news',
configureServer({ middlewares }) {
middlewares.use('/api/news', gated);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/news', gated);
},
};
}
+294
View File
@@ -0,0 +1,294 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newsOrigin, newsProxy } from './news.js';
import { createKeyedNews } from './news/keyed.js';
/** Mount the proxy on a fake middleware stack and call it like Connect does. */
function mount(options) {
let handler = null;
newsProxy(options).configureServer({
middlewares: {
use(prefix, fn) {
assert.equal(prefix, '/api/news');
handler = fn;
},
},
});
return async (url, method = 'GET', headers = {}) => {
const res = {
destroyed: false,
status: 0,
headers: {},
body: '',
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
if (this.statusCode) this.status = this.statusCode;
this.body = body;
},
};
await handler({ url, method, headers }, res);
return { ...res, json: res.body ? JSON.parse(res.body) : null };
};
}
test('the origin must be a plain http(s) origin', () => {
assert.equal(
newsOrigin('http://news.example:8080'),
'http://news.example:8080',
);
assert.equal(
newsOrigin('https://news.example/base/'),
'https://news.example',
);
for (const bad of [
undefined,
'',
' ',
'ftp://x',
'http://u:p@x',
'http://x/?a=1',
'http://x/#f',
'not a url',
])
assert.equal(newsOrigin(bad), null, String(bad));
});
test('without GEV_NEWS_ORIGIN Core answers itself: Conflicts from GDELT, Local saying it needs its key', async () => {
const hosts = [];
const fetchImpl = async (url) => {
hosts.push(new URL(String(url)).host);
throw new Error('offline');
};
const call = mount({
origin: () => null,
fetchImpl,
keyed: createKeyedNews({ env: () => ({}), fetchImpl, log: () => {} }),
});
// GDELT's exports need no key; offline, the read fails like any upstream.
const hotspots = await call('/hotspots');
assert.equal(hotspots.status, 502);
assert.deepEqual(hotspots.json, { error: 'news_upstream_error' });
const local = await call('/feed?hotspot=all&at=31.95,35.93');
assert.equal(local.status, 503);
assert.deepEqual(local.json, { error: 'no_key', key: 'worldnewsapi' });
// The area is read from the bundled countries, with no key.
const area = await call('/outline?at=31.95,35.93&v=4');
assert.equal(area.status, 200);
assert.equal(area.json.area.name, 'Jordan');
// The client's allowlist still holds.
assert.equal((await call('/feed?side=UA')).status, 400);
assert.ok(hosts.length > 0);
assert.ok(hosts.every((host) => host === 'data.gdeltproject.org'));
});
test('forwards only the client routes, by GET, and relays the JSON', async () => {
const calls = [];
const call = mount({
origin: () => 'https://news.example',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return new Response('{"reports":[],"nextBefore":null}', { status: 200 });
},
});
const ok = await call('/feed?hotspot=all&limit=60&today=1&events=1');
assert.equal(ok.status, 200);
assert.deepEqual(ok.json, { reports: [], nextBefore: null });
assert.equal(ok.headers['Cache-Control'], 'no-store');
assert.equal(
calls[0].url,
'https://news.example/api/news/feed?hotspot=all&limit=60&today=1&events=1',
);
assert.equal(calls[0].init.redirect, 'error');
assert.ok(calls[0].init.signal instanceof AbortSignal);
for (const url of [
'/air-alerts',
'/feed?side=UA',
'/feed?limit=9999',
'/../admin',
'/hotspots?x=1',
]) {
const refused = await call(url);
assert.equal(refused.status, 400, url);
}
const post = await call('/hotspots', 'POST');
assert.equal(post.status, 405);
assert.equal(post.headers.Allow, 'GET');
assert.equal(calls.length, 1);
});
test('upstream failures, bad JSON and timeouts become bounded errors', async () => {
const failing = mount({
origin: () => 'https://news.example',
fetchImpl: async () => new Response('down', { status: 500 }),
});
const down = await failing('/hotspots');
assert.equal(down.status, 502);
assert.deepEqual(down.json, { error: 'news_upstream_error', status: 500 });
const garbled = mount({
origin: () => 'https://news.example',
fetchImpl: async () => new Response('<html>', { status: 200 }),
});
assert.equal((await garbled('/hotspots')).status, 502);
const slow = mount({
origin: () => 'https://news.example',
timeoutMs: 10,
// AbortSignal.timeout does not hold the event loop open: the hanging
// fetch does, until its signal aborts, so the run cannot end before it.
fetchImpl: (_url, { signal }) =>
new Promise((_, reject) => {
const alive = setTimeout(() => {}, 60_000);
signal.addEventListener('abort', () => {
clearTimeout(alive);
reject(signal.reason);
});
}),
});
const timedOut = await slow('/hotspots');
assert.equal(timedOut.status, 504);
assert.deepEqual(timedOut.json, { error: 'news_unavailable' });
});
test('a GEV_NEWS_ORIGIN that is no plain origin is warned of once, and Core answers itself', async () => {
const logs = [];
const asked = [];
const call = mount({
env: () => ({ GEV_NEWS_ORIGIN: 'https://user:[email protected]' }),
log: (line) => logs.push(line),
fetchImpl: async () => assert.fail('nothing is forwarded'),
keyed: {
answer: async (path) => {
asked.push(path);
return { status: 200, body: { reports: [], nextBefore: null } };
},
},
});
assert.equal((await call('/feed?hotspot=all')).status, 200);
assert.equal((await call('/feed?hotspot=all')).status, 200);
assert.deepEqual(asked, ['/feed', '/feed']);
assert.equal(logs.length, 1);
assert.doesNotMatch(logs[0], /secret|news\.example/);
});
test('a built-in answer that throws is a 502, logged in one line without addresses', async () => {
const logs = [];
const call = mount({
env: () => ({}),
log: (line) => logs.push(line),
keyed: {
answer: async () => {
throw new TypeError('bad https://api.example/?key=abc\nmore');
},
},
});
const failed = await call('/hotspots');
assert.equal(failed.status, 502);
assert.deepEqual(failed.json, { error: 'news_unavailable' });
assert.equal(logs.length, 1);
assert.match(logs[0], /TypeError/);
assert.doesNotMatch(logs[0], /key=abc|\n/);
});
test('a host that serves news only from its own server turns the built-in backend off', async () => {
let fetches = 0;
const call = mount({
origin: () => null,
builtIn: false,
fetchImpl: async () => {
fetches++;
throw new Error('unexpected');
},
});
for (const url of [
'/hotspots',
'/feed?hotspot=all',
'/outline?at=31.95,35.93&v=4',
]) {
const answer = await call(url);
assert.equal(answer.status, 503, url);
assert.deepEqual(answer.json, {
error: 'news_not_configured',
unavailable: true,
retryable: false,
});
}
assert.equal(fetches, 0);
});
test('a request line that is no URL is a 400, not a crash', async () => {
const call = mount({
origin: () => null,
keyed: { answer: async () => assert.fail('nothing is answered') },
});
// `GET /api/news//[`: Connect strips the mount prefix and leaves `//[`.
const bad = await call('//[');
assert.equal(bad.status, 400);
assert.deepEqual(bad.json, { error: 'invalid_news_request' });
});
test('cross-site and proxied requests are refused before anything is read', async () => {
let answered = 0;
const call = mount({
origin: () => null,
keyed: {
answer: async () => {
answered++;
return { status: 200, body: { reports: [], nextBefore: null } };
},
},
});
const host = 'localhost:5173';
for (const headers of [
{ host, origin: 'https://evil.example' },
{ host, origin: 'null' },
{ host, 'sec-fetch-site': 'cross-site' },
{ host, 'x-forwarded-for': '203.0.113.9' },
]) {
const refused = await call('/feed?hotspot=all', 'GET', headers);
assert.equal(refused.status, 403, JSON.stringify(headers));
}
assert.equal(answered, 0);
for (const headers of [
{ host },
{ host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin' },
]) {
const passed = await call('/feed?hotspot=all', 'GET', headers);
assert.equal(passed.status, 200, JSON.stringify(headers));
}
assert.equal(answered, 2);
});
test("a news server's key and configuration states are relayed as they are", async () => {
for (const body of [
{ error: 'no_key', key: 'worldnewsapi' },
{ error: 'key_rejected', key: 'worldnewsapi' },
{ error: 'key_quota', key: 'worldnewsapi' },
{ error: 'news_not_configured', unavailable: true, retryable: false },
]) {
const call = mount({
origin: () => 'https://news.example',
fetchImpl: async () =>
new Response(JSON.stringify(body), { status: 503 }),
});
const relayed = await call('/feed?hotspot=all&at=31.95,35.93');
assert.equal(relayed.status, 503, body.error);
assert.deepEqual(relayed.json, body);
}
// Any other 503 stays a bounded upstream error.
const other = mount({
origin: () => 'https://news.example',
fetchImpl: async () =>
new Response('{"error":"maintenance","detail":"x"}', { status: 503 }),
});
const down = await other('/hotspots');
assert.equal(down.status, 502);
assert.deepEqual(down.json, { error: 'news_upstream_error', status: 503 });
});
+232
View File
@@ -0,0 +1,232 @@
// Areas for the News layer, from the bundled Natural Earth packs (public
// domain): the disputed area or else the country at a point, its outline in
// the /api/news/outline form, and a point inside a country to pin its
// national news at. A disputed area is checked first and named as itself,
// so no one country's name stands for it; so is a territory the countries
// pack gives no code (Somaliland), rather than read as sea. Read once, on
// first use.
import { readFileSync } from 'node:fs';
import {
containsAnchor,
geometryOf,
pointInRing,
} from '../../../src/data/adminBoundaries.js';
const PACKS = '../../../src/data/local_data/natural_earth/';
const PACK = new URL(`${PACKS}countries.json`, import.meta.url);
const DISPUTED = new URL(`${PACKS}disputed_areas.json`, import.meta.url);
const ISO2 = /^[A-Z]{2}$/;
let countries = null;
let territories = [];
/**
* One entry per ISO code: its name and every Country part's geometry. The
* Country features without a code ("-99": Somaliland, Northern Cyprus,
* sovereign base areas, no man's lands) are kept apart as territories.
*/
function loadCountries(read = () => JSON.parse(readFileSync(PACK, 'utf8'))) {
if (countries) return countries;
const pack = read();
const decimals = pack.meta?.decimals ?? 3;
const byCode = new Map();
const codeless = [];
for (const feature of pack.features || []) {
// The constituent countries of the United Kingdom repeat its part.
if (feature.type !== 'Country') continue;
if (!ISO2.test(feature.iso2 || '')) {
if (feature.name)
codeless.push({ name: feature.name, feature, decimals });
continue;
}
const entry = byCode.get(feature.iso2) || {
code: feature.iso2,
name: null,
size: 0,
parts: [],
};
entry.parts.push({ feature, decimals });
// Named after its largest feature: Kazakhstan, not the Baikonur lease.
const size = JSON.stringify(feature.polygons).length;
if (size > entry.size) {
entry.name = feature.name;
entry.size = size;
}
byCode.set(feature.iso2, entry);
}
countries = byCode;
territories = codeless;
return countries;
}
/** Test seam: read another pack (or the bundled one again). */
export function resetCountries(read) {
countries = null;
if (read) loadCountries(read);
}
const geometries = (entry) => entry.parts.map((part) => geometryOf(part));
let disputed = null;
const slug = (name) =>
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
/** The disputed areas: `{key, name, kind, heldBy, claimedBy, rings}`. */
function loadDisputed() {
if (disputed) return disputed;
const pack = JSON.parse(readFileSync(DISPUTED, 'utf8'));
disputed = (pack.areas || []).map((area) => ({
key: `disputed-${slug(area.name)}`,
name: area.name,
kind: area.kind,
heldBy: area.heldBy ?? null,
claimedBy: area.claimedBy ?? null,
rings: area.rings,
}));
return disputed;
}
/** The disputed area at a point, or null. */
export function disputedAt(lat, lon) {
return (
loadDisputed().find((area) =>
area.rings.some((ring) => pointInRing(ring, lat, lon)),
) || null
);
}
/**
* The area a point is in, as the News layer names it: a disputed area, or
* a territory without a code, as itself (`kind: 'disputed'`, `code` null),
* else its country (`kind: 'country'`, with `country` the countryAt
* entry); null at sea.
*/
export function areaAt(lat, lon) {
const area = disputedAt(lat, lon);
if (area)
return {
...area,
kind: 'disputed',
held: area.kind,
code: null,
polygons: area.rings.map((outer) => ({ outer, holes: [] })),
};
const country = countryAt(lat, lon);
if (country)
return {
key: country.code,
name: country.name,
kind: 'country',
code: country.code,
country,
};
const territory = territoryAt(lat, lon);
return territory
? {
key: `disputed-${slug(territory.name)}`,
name: territory.name,
kind: 'disputed',
held: null,
code: null,
polygons: geometryOf(territory).polygons.map(([outer, ...holes]) => ({
outer,
holes,
})),
}
: null;
}
/** Whether a point lies in a geometry (geometryOf), across the antimeridian. */
function inGeometry(geometry, lat, lon) {
const [west, south, east, north] = geometry.bbox;
const x = lon - 360 * Math.round((lon - (west + east) / 2) / 360);
if (x < west || x > east || lat < south || lat > north) return false;
return containsAnchor(geometry.polygons, lat, lon);
}
/** The country at a point, or null at sea or in a territory without a code. */
export function countryAt(lat, lon) {
for (const entry of loadCountries().values())
for (const geometry of geometries(entry))
if (inGeometry(geometry, lat, lon)) return entry;
return null;
}
/** The territory without a code at a point (Somaliland), or null. */
function territoryAt(lat, lon) {
loadCountries();
return (
territories.find((part) => inGeometry(geometryOf(part), lat, lon)) || null
);
}
/** A country by its ISO code, or null. */
export const countryByCode = (code) => loadCountries().get(code) || null;
/**
* A country's outline as /api/news/outline answers it (`{outer, holes}`),
* the disputed areas inside it left out as holes (Russia's without Crimea).
*/
export function countryPolygons(entry) {
const areas = loadDisputed();
return geometries(entry).flatMap((geometry) =>
geometry.polygons.map(([outer, ...holes]) => ({
outer,
holes: [
...holes,
...areas
.flatMap((area) => area.rings)
// By a point inside it: its corners lie on the neighbours' borders.
.filter((ring) => {
const inside = ringInterior(ring);
return inside && pointInRing(outer, inside.lat, inside.lon);
}),
],
})),
);
}
/**
* A point inside the country's largest part, for news placed in the country
* alone: its label point when that lies inside, else the middle of the
* widest span across the part at its middle latitude.
*/
export function countryAnchor(entry) {
const largest = geometries(entry).sort((a, b) => b.areaKm2 - a.areaKm2)[0];
const { label, polygons, bbox } = largest;
if (containsAnchor(polygons, label.lat, label.lon)) return label;
const [outer] = polygons.reduce((a, b) =>
b[0].length > a[0].length ? b : a,
);
return ringInterior(outer, (bbox[1] + bbox[3]) / 2) || label;
}
/**
* A point inside a ring: the middle of its widest span at `lat` (its middle
* latitude by default); null when the ring is not crossed there.
*/
function ringInterior(outer, lat = middleLat(outer)) {
const crossings = [];
for (let i = 0, j = outer.length - 1; i < outer.length; j = i++) {
const [x1, y1] = outer[j];
const [x2, y2] = outer[i];
if (y1 > lat !== y2 > lat)
crossings.push(x1 + ((lat - y1) * (x2 - x1)) / (y2 - y1));
}
crossings.sort((a, b) => a - b);
let best = null;
for (let i = 0; i + 1 < crossings.length; i += 2)
if (!best || crossings[i + 1] - crossings[i] > best[1] - best[0])
best = [crossings[i], crossings[i + 1]];
return best ? { lat, lon: (best[0] + best[1]) / 2 } : null;
}
const middleLat = (ring) => {
const lats = ring.map(([, y]) => y);
return (Math.min(...lats) + Math.max(...lats)) / 2;
};
+872
View File
@@ -0,0 +1,872 @@
// Rule-based report categories, relevance and severity: the News layer's
// base reading, shared by every news server. Multilingual stems (en, uk, ru,
// ar, he and more) matched at a word start on normalised text; a trailing `$`
// makes a stem whole-word. A title hit counts double. Ties go to the earlier
// category in CATEGORY_ORDER, which runs from most to least specific.
//
// A news server may replace these readings with its own; the rules stay
// as the fallback.
/** Text as the rules match it: folded case, one apostrophe, no vowel marks. */
function normaliseForMatch(text) {
return String(text || '')
.normalize('NFKC')
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[’ʼ`´]/g, "'")
.replace(/[ً-ٰٟ֑-ׇ]/g, '')
.replace(/[أإآ]/g, 'ا');
}
export const CATEGORY_ORDER = Object.freeze([
'strike',
'alert',
'air-defence',
'ground',
'casualties',
'humanitarian',
'infrastructure',
'disaster',
'protest',
'political',
// Business and trade: the rules' reading, or World News API's business.
'economy',
]);
// [stem, weight]; weight defaults to 1.
const RULES = {
strike: [
'strike',
'airstrike',
'air strike',
'missile',
'rocket',
'drone',
'shelling',
'shelled',
'bomb',
'explosion',
'blast',
'artillery',
'shahed',
['attack', 0.5],
'удар',
'ракет',
'дрон',
'бпла',
'обстріл',
'обстрел',
'вибух',
'взрыв',
'шахед',
'бомб',
['атак', 0.5],
'غارة',
'غارات',
'قصف',
'صاروخ',
'صواريخ',
'مسيرة',
'انفجار',
['استهداف', 0.7],
['هجوم', 0.5],
'תקיפה',
'טיל',
'רקט',
'כטב"מ',
'פיצוץ',
'הפצצ',
],
'air-defence': [
'air defen',
'intercept',
'shot down',
'downed',
'air raid',
'ппо',
'збит',
'сбит',
'перехоп',
'перехват',
'повітряна тривога',
'воздушная тревога',
'الدفاع الجوي',
'اعتراض',
'יירוט',
'יורט',
'כיפת ברזל',
'אזעק',
],
ground: [
'advance$',
'advances$',
'advancing',
'offensive',
'captur',
'liberat',
'assault',
'clash',
'fighting',
'counteroffensive',
'frontline',
'front line',
'battle',
'encircl',
['troops', 0.5],
'наступ',
'штурм',
'звільн',
'освобо',
'бої$',
'бои$',
'контрнаступ',
'захоп',
'захват',
'оточ',
'окруж',
'продвиж',
'просуван',
'اشتباك',
'معارك',
'تقدم',
'سيطر',
'اقتحام',
'توغل',
'לחימה',
'קרב',
'פשיט',
'תמרון',
],
casualties: [
'killed',
'dead$',
'deaths',
'wounded',
'injured',
'casualt',
'death toll',
'загин',
'поранен',
'жертв',
'вбит',
'погиб',
'ранен',
'убит',
'قتلى',
'شهداء',
'شهيد',
'جرحى',
'مقتل',
'اصاب',
'הרוג',
'נהרג',
'פצוע',
'נפצע',
],
humanitarian: [
'evacuat',
'aid$',
'refugee',
'displac',
'humanitarian',
'famine',
'hunger',
'convoy',
'shelter',
'евакуац',
'эвакуац',
'гуманітар',
'гуманитар',
'біженц',
'беженц',
'انسانية',
'نازح',
'نزوح',
'مساعدات',
'اجلاء',
'مجاعة',
'הומניטרי',
'פינוי',
'סיוע',
],
infrastructure: [
'power outage',
'blackout',
'electricity',
'energy',
'grid$',
'substation',
'water supply',
'bridge',
'railway',
'refinery',
'port$',
'dam$',
'power plant',
'енерг',
'энерг',
'знеструм',
'обесточ',
'підстанц',
'подстанц',
'залізниц',
'железн',
'нпз',
'الكهرباء',
'محطة',
'مياه',
'جسر',
'חשמל',
'תשתית',
],
disaster: [
'earthquake',
'flood',
'wildfire',
'landslide',
'cyclone',
'hurricane',
'typhoon',
'tsunami',
'eruption',
'glof$',
'glacial lake',
'storm$',
'lightning',
'землетрус',
'землетрясен',
'повін',
'паводк',
'наводнен',
'زلزال',
'فيضان',
'سيول',
'רעידת אדמה',
'שיטפון',
],
protest: [
'protest',
'demonstrat',
'rally$',
'rallies',
'riot',
// Not a bare "протест": "протестировал" is "tested".
'протест$',
'протеста',
'протесту',
'протестом',
'протесте$',
'протесты',
'протестов',
'протестн',
'протестую',
'протестова',
'мітинг',
'митинг',
'احتجاج',
'مظاهر',
'تظاهر',
'הפגנ',
'מחאה',
],
political: [
'talks$',
'negotiat',
'sanction',
'summit',
'ceasefire',
'truce',
'peace',
'election',
'parliament',
['president', 0.5],
['minister', 0.5],
['statement', 0.5],
// A state visit ("Emir of Qatar arrives in Amman").
['visit', 0.5],
['delegation', 0.5],
['agreement', 0.5],
'memorandum',
'diplomat',
'bilateral',
'ambassador',
['визит', 0.5],
['делегац', 0.5],
'дипломат',
'переговор',
'санкц',
'перемир',
'вибори',
'выборы',
['президент', 0.5],
['міністр', 0.5],
['министр', 0.5],
'مفاوضات',
'عقوبات',
'هدنة',
'وقف اطلاق النار',
['وزير', 0.5],
['رئيس', 0.5],
'משא ומתן',
'הפסקת אש',
'סנקצי',
['ראש הממשלה', 0.5],
],
// Elections and party politics ("How Gina Hinojosa Captured Texas" is a
// governor's race, not a battle). They count toward political only when
// nothing physical happened (categorizeText): a strike in an election week
// is still a strike.
election: [
'midterm',
'electoral',
'elect$',
'ballot',
'voter',
'vote$',
'votes$',
'voting',
'polls$',
'candidate',
'senate race',
'referendum',
'inaugurat',
// Plurals only: "Democratic Republic of Congo", "Republican Guard".
'democrats$',
'republicans$',
'gop$',
'избира',
'голосован',
'виборц',
'голосуван',
'انتخاب',
'בחירות',
],
};
// Business, trade, industry and energy, counted only when nothing physical
// happened (categorizeText): an oil depot struck is a strike. English,
// Russian, Ukrainian, Turkish and the European stems.
RULES.economy = [
'econom',
'gdp$',
'inflation',
'investment',
'investor',
'export',
'imports$',
'import$',
'tariff',
'trade deal',
'trade turnover',
'central bank',
'stock market',
'budget',
'oil and gas',
'oilfield',
'oil field',
'gas field',
'exploration',
'drilling',
'mining',
'industr',
'manufactur',
'business',
'company',
'companies',
'revenue',
'profit',
'merger',
'tourism',
'agricultur',
'harvest',
'экономи',
'инвест',
'экспорт',
'импорт',
'инфляц',
'ввп$',
'бюджет',
'промышлен',
'бизнес',
'компани',
'товарооборот',
'економі',
'інвест',
'експорт',
'промисло',
'ekonomi',
'yatırım',
'ihracat',
'ithalat',
'enflasyon',
'sanayi',
'şirket',
'wirtschaft',
'konjunktur',
'unternehm',
'inversi',
'investiss',
'investiment',
'empresa',
'entreprise',
'impresa',
];
// Attack words beyond PHYSICAL's harm, which keep a report off economy.
const ATTACK = new RegExp(
String.raw`(?<![\p{L}])(?:attack|assault|target|hit(?![\p{L}])|fire|plot|war(?![\p{L}])|wartime|атак|удар|пораж|пожар|обстр|війн|войн|هجوم|استهداف|حرب|מתקפ|ataque|attaque|attacco|angriff|saldır)`,
'u',
);
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Spanish, French, Portuguese, Italian and German stems, for outlets that
// report in their own language (a train crash in Spain, in Spanish).
const EUROPEAN = {
strike: [
'bombarde',
'misil',
'míssil',
'dron$',
'explosión',
'explosão',
'esplosi',
'ataque',
'attaque',
'attacco',
'angriff',
'rakete',
'luftangriff',
],
'air-defence': ['antiaére', 'défense aérienne', 'flugabwehr', 'abatid'],
ground: [
'combate',
'combats',
'enfrentamiento',
'affrontement',
'scontri',
'gefecht',
],
casualties: [
'muert',
'fallecid',
'herid',
'morts$',
'tué',
'blessé',
'morti',
'ferit',
'mortos',
'ferido',
'muere',
'mueren',
'fallece',
'meurt',
'morre',
'tote$',
'verletzt',
'víctimas',
'victimes',
'vittime',
],
humanitarian: [
'evacua',
'évacu',
'refugiad',
'réfugi',
'flüchtling',
'desplazad',
],
infrastructure: [
'apagón',
'panne',
'blackout',
'descarril',
'déraill',
'deragliament',
'entgleis',
'accidente',
'accident',
'incidente',
'choque',
'collision',
'collisione',
'zusammenstoß',
'incendio',
'incendie',
],
disaster: [
'terremoto',
'séisme',
'tremblement',
'erdbeben',
'inundaci',
'inondation',
'alluvion',
'überschwemm',
'huracán',
'ouragan',
'uragano',
'tormenta',
'tempête',
'tempesta',
'sturm',
'cicl',
'erupci',
'éruption',
'eruzione',
],
protest: [
'protesta',
'manifesta',
'disturbio',
'émeute',
'huelga',
'grève',
'sciopero',
'streik',
'demonstration',
],
};
for (const [category, stems] of Object.entries(EUROPEAN))
RULES[category] = [...(RULES[category] || []), ...stems];
const COMPILED = Object.entries(RULES).map(([category, stems]) => ({
category,
rules: stems.map((entry) => {
const [stem, weight = 1] = Array.isArray(entry) ? entry : [entry];
const whole = stem.endsWith('$');
const body = escapeRegex(
normaliseForMatch(whole ? stem.slice(0, -1) : stem),
).replace(/\s+/g, '\\s+');
const arabic = /\p{Script=Arabic}/u.test(stem) ? '(?:[وفبلك]?(?:ال)?)' : '';
const hebrew = /\p{Script=Hebrew}/u.test(stem) ? '(?:[ובלמהשכ]{0,2})' : '';
return {
weight,
regex: new RegExp(
`(?<![\\p{L}\\p{N}])${arabic}${hebrew}${body}${whole ? '(?![\\p{L}\\p{N}])' : ''}`,
'u',
),
};
}),
}));
const EVENT_CATEGORIES = new Set([
'strike',
'air-defence',
'ground',
'casualties',
'disaster',
]);
// Words that tie a report to armed conflict or crisis, whatever its category
// (en, fr, ru, uk, ar, he, es).
const CONFLICT_TERMS = new RegExp(
[
'war',
'army',
'military',
'militar',
'troops',
'soldier',
'militant',
'jihad',
'terror',
'insurg',
'rebel',
'ceasefire',
'truce',
'hostage',
'sanction',
'shell',
'drone',
'missile',
'airstrike',
'frappe',
'armée',
'soldat',
'djihad',
'fama',
'jnim',
'houthi',
'hezbollah',
'hamas',
'irgc',
'rsf',
'wagner',
'africa corps',
'войн',
'війн',
'армі',
'арми',
'всу',
'зсу',
'бпла',
'обстр',
'фронт',
'حرب',
'الجيش',
'هجوم',
'قصف',
'الحوثي',
'مسلح',
'מלחמה',
'צה"ל',
'guerra',
'ejército',
// Violence, coercion and their aftermath.
'massacre',
'atrocit',
'genocide',
'arms',
'weapon',
'detain',
'abduct',
'kidnap',
'air raid',
'alert',
'jamming',
'тривог',
'тревог',
'відбій',
'отбой',
'occupied',
'occupation',
'territor',
'convoy',
'demining',
'forces',
'raid',
'conflict',
'putin',
'zelensk',
'окуп',
'оккуп',
// Diplomacy and strategic stakes of the conflict.
'ambassador',
'mediat',
'election',
'talks',
'negotiat',
'envoy',
'diplomat',
'foreign minister',
'condemn',
'nuclear',
'hormuz',
'blockade',
'négociation',
'переговор',
'перемир',
'مفاوضات',
'هدنة',
]
// Short words are whole words ("war" is not "warning" or "Warsaw").
.map((w) => `(?<![\\p{L}])${w}${w.length < 5 ? '(?![\\p{L}])' : ''}`)
.join('|'),
'iu',
);
/**
* How much a report matters to its hotspot, by rule: `core` for a physical conflict or crisis event, `related` for
* news tied to the conflict (by wording, or because the source covers
* nothing else), `minor` for the rest of what a broad outlet publishes
* (prices, sport, health, culture).
*/
const termPatterns = new Map();
/** A word-start pattern for a hotspot term ("pow" must not match "power"). */
function termPattern(term) {
if (!termPatterns.has(term))
termPatterns.set(
term,
new RegExp(
// Short terms are whole words ("pow" is not "power"); longer ones are
// stems ("mobiliz" → mobilized, mobilization).
`(?<![\\p{L}])${normaliseForMatch(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${term.length < 5 ? '(?![\\p{L}])' : ''}`,
'u',
),
);
return termPatterns.get(term);
}
export function relevanceOf({
category,
tags = [],
title = '',
summary = '',
dedicated = false,
relatedTerms = [],
floor = 'minor',
}) {
const text = normaliseForMatch(`${title} ${summary}`);
// A hotspot's own vocabulary counts as conflict wording there (Iran:
// "Qalibaf", "IAEA"; Israel: "settler"; Ukraine: "prisoner exchange").
const conflict =
CONFLICT_TERMS.test(text) ||
relatedTerms.some((term) => termPattern(term).test(text));
// Any event reading counts, even when another category wins the title
// ("the accident toll rises to 74": infrastructure first, casualties too).
if (
EVENT_CATEGORIES.has(category) ||
tags.some((tag) => EVENT_CATEGORIES.has(tag))
)
return 'core';
if (conflict && ['humanitarian', 'infrastructure'].includes(category))
return 'core';
if (conflict || dedicated || category === 'humanitarian') return 'related';
return floor;
}
// Alerts: air-raid alerts and reports of drones or missiles on their way
// ("Реактивний БпЛА курсом на Полтаву", "КАБи на Донеччину", "drone heading
// for Kyiv", "➡️"). Something is in the air; nothing has hit yet. Most of an
// air force's channel is these, and as "strikes" they would merge into real
// strike events and inflate them.
// A direction counts only after a weapon ("БпЛА … з півдня"), so a convoy
// leaving "in the direction of Oleshky" is not an alert.
const WEAPON =
'(?:бпла|дрон|шахед|герань|ракет|каб|балістик|крилат|боєприпас|боеприпас|uav|drone|missile|shahed|ballistic|cruise|munition)';
const DIRECTION =
'(?:курсом на|у напрямку|в напрямку|в направлении|у бік|в сторону|повз |з (?:північ|півден|схід|сход|захід|заход)|с (?:север|юг|восток|запад)|на |над |heading |on course|toward)';
const ALERT = new RegExp(
`${WEAPON}[^.!?]{0,40}${DIRECTION}|^[^\\p{L}]*курсом на|тривог|тревог|загроза застосування|угроза применения|air[- ]raid|threat of (?:missile|drone|ballistic|uav)|[➡⬅⬆⬇↗↘↙↖]`,
'u',
);
// Wording that something did hit: then it is a strike (or air defence), not an alert.
const IMPACT =
/влуч|вибух|уражен|пошкодж|загин|поранен|зруйн|пожеж|атак|удар по|збит|знищ|попадан|взрыв|поражен|поврежд|погиб|ранен|разруш|сбит|уничтож|\bhit\b|\bhits\b|struck|explosion|blast|killed|injur|wounded|damage|destroy|fire\b|shot down|intercept|attack/u;
/** Whether a report's title is an alert: a threat on its way, nothing hit yet. */
function isAlertText(title = '', summary = '') {
const t = normaliseForMatch(`${title} ${summary}`.slice(0, 400));
return ALERT.test(t) && !IMPACT.test(t);
}
// "Hit" or "struck" says nothing on its own: what hit decides (a missile is a
// strike, a flood or lightning a disaster), and those words score by
// themselves. A few phrases use a category's word for another thing.
const PHRASES = [
[/storm shadow/gu, 'missile'],
[/lightning strikes?/gu, 'lightning'],
[
/(?:general|hunger|labou?r|workers?|nationwide|national|transport|rail|teachers?|doctors?|nurses?) strikes?|on strike|strike action/gu,
'protest',
],
];
const rephrase = (text) =>
PHRASES.reduce((out, [re, word]) => out.replace(re, word), text);
// An election story borrows the words of war and disaster ("‘An Earthquake’:
// A Democratic Rout", "captured Texas", "a bloodbath for Republicans"). With
// election wording and nothing physical (no weapon, no one hurt, no quake's
// magnitude), the event categories give way to political.
const ELECTION =
/(?<![\p{L}])(?:election|midterm|electoral|ballot|voters?(?![\p{L}])|votes?(?![\p{L}])|voting|polls(?![\p{L}])|senate race|referendum|democrats|republicans|gop(?![\p{L}])|[\p{L}]+-elect(?![\p{L}])|выбор|избира|вибор|голосув|голосов|انتخاب|בחירות)/u;
const PHYSICAL =
/(?<![\p{L}])(?:kill|dead(?![\p{L}])|fighting|clash|air ?strike|strikes?(?![\p{L}])|struck|destroy|downed|ablaze|on fire|catch(?:es)? fire|caught fire|naval mine|landmine|shot down|sunk|sank|deaths?|injur|wounded|casualt|troops|soldier|missile|drone|rocket|shell(?:ing|ed)|artiller|explosi|blast|bomb|gunfire|shot(?![\p{L}])|shooting|magnitude|richter|tremor|evacuat|flood(?:ed|ing|waters)|убит|погиб|ранен|ракет|дрон|обстрел|загин|поранен|обстріл|قتل|جرح|صاروخ|הרג|פצוע|טיל)/u;
/** The categories of a physical event (an alert is a threat, not yet one). */
const EVENT_KINDS = new Set([
'strike',
'air-defence',
'ground',
'casualties',
'disaster',
'infrastructure',
]);
/**
* Categorise a report from its title and summary.
* @returns {{category: string, tags: string[]}}
*/
export function categorizeText({ title = '', summary = '' } = {}) {
if (isAlertText(title)) return { category: 'alert', tags: ['alert'] };
const t = rephrase(normaliseForMatch(title));
const s = rephrase(normaliseForMatch(summary));
const scores = new Map();
for (const { category, rules } of COMPILED) {
let score = 0;
for (const { regex, weight } of rules) {
if (regex.test(t)) score += 2 * weight;
else if (regex.test(s)) score += weight;
}
if (score > 0) scores.set(category, score);
}
const all = `${t} ${s}`;
const election = scores.get('election') || 0;
scores.delete('election');
const physical = PHYSICAL.test(all);
// Business words and no harm or attack: "advance exploration" is no
// front-line advance; "attack an industrial facility" stays a strike.
if (physical || ATTACK.test(all)) scores.delete('economy');
else if (scores.has('economy'))
for (const kind of EVENT_KINDS) scores.delete(kind);
if (!physical) {
if (election)
scores.set('political', (scores.get('political') || 0) + election);
if (ELECTION.test(all)) {
for (const kind of EVENT_KINDS) scores.delete(kind);
scores.set('political', Math.max(scores.get('political') || 0, 1));
}
}
if (!scores.size) return { category: 'other', tags: [] };
const ranked = CATEGORY_ORDER.filter((c) => scores.has(c)).sort(
(a, b) => scores.get(b) - scores.get(a),
);
return { category: ranked[0], tags: ranked };
}
// Severity by rule, for reports without a severity of their own (0 none,
// 1 minor incident, 2 significant: deaths, injuries or real damage, 3 major:
// mass casualties). Wording in en, uk, ru, ar, he, es, fr, de.
const SEVERITY_EVENTS = new Set([...EVENT_CATEGORIES, 'infrastructure']);
const MAJOR_HARM =
/mass casualt|massacre|dozens (?:of \p{L}+ )?(?:killed|dead)|hundreds (?:of \p{L}+ )?(?:killed|dead)|масов\p{L}* (?:жертв|загибел|гибел)|десятки (?:загибл|погибш|убит|вбит|людей загин|человек погиб)|مجزرة|عشرات (?:القتلى|الشهداء)|טבח/u;
const HARM_WORDS =
'kill|dead|died|death|injur|wound|casualt|загин|загибл|погиб|убит|вбит|поранен|ранен|постражд|пострадал|травм|قتل|قتيل|شهيد|شهداء|جرح|جريح|إصاب|اصاب|נהרג|הרוג|פצוע|muert|herid|tué|mort|bless|getötet|tote|verletz';
const HARM = new RegExp(String.raw`(?<![\p{L}])(?:${HARM_WORDS})`, 'u');
// "No casualties", "без жертв": the harm word is there, the harm is not.
const NO_HARM =
/(?:no|without|zero) (?:casualt|injur|deaths|victims|one (?:was )?(?:hurt|injured))|без (?:жертв|постраждал|пострадавш|загибл|погибш)|не постраждал|не пострадал|لا (?:إصابات|اصابات)|אין נפגעים|sin (?:víctimas|heridos)|sans (?:victime|blessé)/u;
const DAMAGE =
/destroy|damag|ablaze|on fire|fire broke|explosion|collapse|зруйн|разруш|пошкодж|поврежд|пожеж|пожар|вибух|взрыв|دمار|تدمير|انفجار|حريق|הרס|פיצוץ|שריפה|destru|incendi|explos|zerstör/u;
// An age ("a 71-year-old woman", "71-річна", "71-летний") is not a count.
const NUMBER = String.raw`(\d{1,3}(?:[,.\s]\d{3})+|\d{1,5})(?![\d,.]*[-\s]*(?:year|yr|річн|рок|летн|год))`;
const COUNTS = [
// "14 killed", "three people dead" is left to the words; "14 civilians dead".
new RegExp(String.raw`${NUMBER}\s+(?:\p{L}+\s+){0,2}(?:${HARM_WORDS})`, 'gu'),
// "killing 14", "death toll rises to 74", "kills at least 20".
new RegExp(
String.raw`(?:kill\p{L}*|toll\p{L}*(?:\s+\p{L}+){0,3}|загинул\p{L}*|погибл\p{L}*|жертв\p{L}*)\s+(?:at least\s+|не менее\s+|щонайменше\s+|до\s+|to\s+)?${NUMBER}`,
'gu',
),
];
function largestCount(text) {
let most = 0;
for (const pattern of COUNTS)
for (const match of text.matchAll(pattern)) {
const n = Number(match[1].replace(/[,.\s]/g, ''));
// A year ("since 2022 killed…") is not a toll.
if (n < 1900 || n > 2100) most = Math.max(most, n);
}
return most;
}
/** A report's severity by rule, 0–3. */
export function severityByRules({
category,
tags = [],
title = '',
summary = '',
}) {
const text = normaliseForMatch(`${title} ${summary}`);
const harmed = HARM.test(text) && !NO_HARM.test(text);
if (MAJOR_HARM.test(text) || (harmed && largestCount(text) >= 10)) return 3;
const event =
SEVERITY_EVENTS.has(category) ||
tags.some((tag) => SEVERITY_EVENTS.has(tag));
if (harmed && (event || category === 'humanitarian')) return 2;
if (event && DAMAGE.test(text)) return 2;
return event ? 1 : 0;
}
+546
View File
@@ -0,0 +1,546 @@
// GDELT 2.0 events for the Conflicts section, from the 15-minute export
// files (data.gdeltproject.org, plain downloads; no key, and not the
// rate-limited search API). Each export is one zipped tab-separated table of
// machine-coded events, each placed by GDELT with a point and a place type.
//
// A story counts when GDELT coded physical violence in it (CAMEO roots 18
// assault, 19 fight, 20 mass violence); its place is read from all its
// placed events (choosePlace). GDELT's actor fields are machine-coded and
// often wrong, so no report says who did what: a report is the story (its
// article, and the copies other outlets ran under the same address words).
// The events carry no headline: a story's title is its page title from the
// GKG export of the same quarter hour (read for the newest hours only, it
// runs to 6 MB), else the words of its address. All of it is rules over
// GDELT's own fields; nothing reads the articles.
import { promisify } from 'node:util';
import { inflateRaw } from 'node:zlib';
import { readResponseBytesCapped } from '../common/http.js';
import { categorizeText, relevanceOf, severityByRules } from './categorize.js';
import { UpstreamError, unescapeHtml, webLink } from './upstream.js';
const EXPORTS_URL = 'https://data.gdeltproject.org/gdeltv2/';
const STEP_MS = 15 * 60_000;
// An events export runs to about 100 KB zipped and 500 KB unzipped; a GKG
// export to about 6 MB and 18 MB. Unzipped off the main thread.
const MAX_ZIP_BYTES = 8 * 1024 * 1024;
const MAX_TABLE_BYTES = 8 * 1024 * 1024;
const MAX_GKG_ZIP_BYTES = 32 * 1024 * 1024;
const MAX_GKG_TABLE_BYTES = 32 * 1024 * 1024;
const inflate = promisify(inflateRaw);
// The most words an address's words make a title of.
const MAX_ADDRESS_WORDS = 24;
const TIMEOUT_MS = 30_000;
const MAX_PLACES = 3;
// Columns of the 2.0 events table (61).
const COL = {
id: 0,
eventCode: 26,
rootCode: 28,
quadClass: 29,
mentions: 31,
geoType: 51,
place: 52,
lat: 56,
lon: 57,
added: 59,
url: 60,
};
// CAMEO event codes as the News layer's categories, and as words for an
// article whose address carries none of its own.
const EVENTS = [
// Aerial weapons, artillery and tanks, bombings.
[/^19[45]|^183/, 'strike', 'Strike reported'],
[/^186|^185/, 'casualties', 'Killing reported'],
[/^181/, 'humanitarian', 'Abduction reported'],
[/^20/, 'casualties', 'Mass violence reported'],
[/^19/, 'ground', 'Fighting reported'],
[/^18/, 'other', 'Violence reported'],
];
const eventKind = (code) =>
EVENTS.find(([pattern]) => pattern.test(code)) || EVENTS.at(-1);
// GDELT's place types: 1 country, 2 US state, 3 US city, 4 world city,
// 5 world state.
const PRECISION = {
1: 'country',
2: 'region',
3: 'city',
4: 'city',
5: 'region',
};
/** The export holding the events GDELT added in the 15 minutes before `ms`. */
export function exportUrl(ms) {
const at = new Date(Math.floor(ms / STEP_MS) * STEP_MS);
const stamp = at.toISOString().replace(/[-:T]/g, '').slice(0, 14);
return `${EXPORTS_URL}${stamp}.export.CSV.zip`;
}
/** An events export's quarter hour (ms), from its name, or NaN. */
export function exportAt(url) {
const match = /(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)00\.export\.CSV\.zip$/.exec(
url,
);
return match
? Date.UTC(match[1], match[2] - 1, match[3], match[4], match[5])
: NaN;
}
const LASTUPDATE_URL = `${EXPORTS_URL}lastupdate.txt`;
/**
* The time of the newest events export GDELT lists as published, in ms, or
* null when its list cannot be read. Asking for one not listed yet would
* be answered 404, and GDELT's CDN keeps that 404 for an hour.
*/
export async function fetchLatestExport({ fetchImpl, signal } = {}) {
try {
const response = await fetchImpl(LASTUPDATE_URL, {
redirect: 'error',
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
: AbortSignal.timeout(TIMEOUT_MS),
});
if (!response.ok) {
await response.body?.cancel?.().catch?.(() => {});
return null;
}
const text = new TextDecoder().decode(
await readResponseBytesCapped(response, 16 * 1024, signal),
);
const at = exportAt(
/\S+\.export\.CSV\.zip/.exec(text)?.[0].replace(/^http:/, 'https:') || '',
);
return Number.isFinite(at) ? at : null;
} catch {
return null;
}
}
/** The GKG export of the same quarter hour as an events export. */
export const gkgUrl = (eventsUrl) =>
eventsUrl.replace(/\.export\.CSV\.zip$/, '.gkg.csv.zip');
/** The day's exports up to `now`, newest first. */
export function exportsOfDay(now, hours = 24) {
const urls = [];
const last = Math.floor(now / STEP_MS) * STEP_MS;
for (let ms = last; ms > now - hours * 3_600_000; ms -= STEP_MS)
urls.push(exportUrl(ms));
return urls;
}
/** The one table inside an export's zip, as text, up to `maxBytes`. */
export async function unzipExport(bytes, maxBytes = MAX_TABLE_BYTES) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const fail = () => {
throw new UpstreamError('GDELT export unreadable', { unreadable: true });
};
if (bytes.length < 30 || view.getUint32(0, true) !== 0x04034b50) fail();
// The central directory has the sizes; a local header may leave them 0.
let eocd = -1;
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 0x10015); i--)
if (view.getUint32(i, true) === 0x06054b50) {
eocd = i;
break;
}
if (eocd < 0) fail();
const dir = view.getUint32(eocd + 16, true);
if (dir + 46 > bytes.length || view.getUint32(dir, true) !== 0x02014b50)
fail();
const method = view.getUint16(dir + 10, true);
const compressed = view.getUint32(dir + 20, true);
const local = view.getUint32(dir + 42, true);
if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50)
fail();
const start =
local +
30 +
view.getUint16(local + 26, true) +
view.getUint16(local + 28, true);
if (start + compressed > bytes.length) fail();
const data = bytes.subarray(start, start + compressed);
try {
if (method === 0 && data.length > maxBytes) fail();
const table =
method === 0
? data
: method === 8
? await inflate(data, { maxOutputLength: maxBytes })
: fail();
return new TextDecoder().decode(table);
} catch (error) {
if (error instanceof UpstreamError) throw error;
return fail();
}
}
/**
* An article's address as the reports carry it: https, an http one
* upgraded (GDELT keeps the address as it found it), else null.
*/
export const articleLink = (value) =>
webLink(value) || webLink(String(value ?? '').replace(/^http:/, 'https:'));
/**
* The placed events of an export's table, of the stories with physical
* violence in them: those events, and every other placed event of the same
* stories (they help place it).
*/
export function parseEvents(table) {
const all = [];
for (const line of table.split('\n')) {
const f = line.split('\t');
if (f.length < 61) continue;
const violent =
f[COL.quadClass] === '4' && ['18', '19', '20'].includes(f[COL.rootCode]);
const precision = PRECISION[f[COL.geoType]];
const lat = Number(f[COL.lat]);
const lon = Number(f[COL.lon]);
const url = articleLink(f[COL.url]);
const added = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)$/.exec(f[COL.added]);
if (
!precision ||
!f[COL.lat] ||
!f[COL.lon] ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180 ||
(lat === 0 && lon === 0) ||
!url ||
!added
)
continue;
all.push({
id: f[COL.id],
code: f[COL.eventCode],
violent,
lat,
lon,
place: f[COL.place].trim() || null,
precision,
mentions: Math.max(1, Number(f[COL.mentions]) || 1),
addedAt: `${added[1]}-${added[2]}-${added[3]}T${added[4]}:${added[5]}:${added[6]}.000Z`,
url,
});
}
const stories = new Set(all.filter((e) => e.violent).map(storyKey));
return all.filter((e) => stories.has(storyKey(e)));
}
/**
* The words of an article's address, when it is written in them
* ("three-children-die-in-peshawar-cylinder-exp"), as a sentence; else null.
*/
export function addressWords(url) {
let path;
try {
path = decodeURIComponent(new URL(url).pathname);
} catch {
return null;
}
const segment = path
.split('/')
.map((part) => part.replace(/\.(?:s?html?|php|aspx?)$/i, ''))
.filter((part) => /[-_]/.test(part))
.sort((a, b) => b.length - a.length)[0];
if (!segment) return null;
const words = segment.split(/[-_]+/).filter(Boolean);
const lettered = words.filter((word) => /^\p{L}+$/u.test(word));
if (lettered.length < 4 || lettered.length < 0.7 * words.length) return null;
// Numbers stay ("killed nearly 500"); an id does not: a word of letters
// and digits, five digits or more, or four or more at either end.
const kept = words.filter(
(word, i) =>
/^\p{L}+$/u.test(word) ||
(/^\d{1,4}$/.test(word) &&
!(word.length === 4 && (i === 0 || i === words.length - 1))),
);
const text = kept.slice(0, MAX_ADDRESS_WORDS).join(' ').toLowerCase();
return text.charAt(0).toUpperCase() + text.slice(1);
}
/**
* Address words with the places the story is at written as names
* ("killed in amman" → "killed in Amman"), and "u s" as "US".
*/
export function withNames(words, places) {
let text = words.replace(/(?<![\p{L}])u s(?![\p{L}])/gu, 'US');
const names = new Set(
places.flatMap((place) =>
String(place.place ?? '')
.split(',')
.map((part) => part.replace(/\(.*?\)/g, '').trim())
.filter((part) => part.length > 2 && !/\(general\)/.test(part)),
),
);
for (const name of names) {
const pattern = new RegExp(
`(?<![\\p{L}])${name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}])`,
'gu',
);
text = text.replace(pattern, name);
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
// A page title's site part: " | The Daily Example", " - Wire Service".
const SITE_PART = /\s+[|–—-]\s+([^|–—-]{2,60})$/u;
/**
* A page title as a headline: entities decoded, the site's name after a
* bar or dash taken off (when the headline before it is still one), or
* null when it is too short to be one.
*/
export function cleanTitle(raw) {
// An agency's tag set on with a bare hyphen ("…attack-Newswire", "…-X").
let title = unescapeHtml(raw).replace(
// After a word in lower case: "Jean-Pierre" keeps its name.
/(?<=(?:^|\s)\p{Ll}\p{L}*|[\p{N}'")])-\p{Lu}\p{L}{0,15}$/u,
'',
);
for (let i = 0; i < 2; i++) {
const match = SITE_PART.exec(title);
if (!match) break;
const rest = title.slice(0, match.index);
if (rest.split(/\s+/).length < 4 || match[1].split(/\s+/).length > 6) break;
title = rest;
}
title = title.trim().slice(0, 300);
return title.length >= 12 && title.split(/\s+/).length >= 3 ? title : null;
}
/**
* GKG page titles by article address (as articleLink writes it), for the
* addresses in `wanted`.
*/
export function parseTitles(table, wanted) {
const titles = new Map();
for (const line of table.split('\n')) {
const f = line.split('\t');
const url = f[4] && articleLink(f[4]);
if (!url || !wanted.has(url)) continue;
const match = /<PAGE_TITLE>(.*?)<\/PAGE_TITLE>/s.exec(f.at(-1) || '');
const title = match && cleanTitle(match[1]);
if (title) titles.set(url, title);
}
return titles;
}
/**
* The story an event belongs to: its article's address words, which the
* copies other outlets run share (an agency story), else its address.
*/
export function storyKey(event) {
return addressWords(event.url)?.toLowerCase() || event.url;
}
const PLACE_WORD = (name) =>
new RegExp(
`(?<![\\p{L}])${name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}s?(?![\\p{L}])`,
'u',
);
/** Whether the words name a place's town or its country ("jordans" too). */
function namedIn(words, place) {
if (!words || !place) return false;
const parts = place
.split(',')
.map((part) => part.replace(/\(.*?\)/g, '').trim())
.filter((part) => part.length > 2);
return [parts[0], parts.at(-1)].some(
(part) => part && PLACE_WORD(part).test(words.toLowerCase()),
);
}
/**
* A story's places, best first, from all its placed events. GDELT places
* an event at a bare country when the text only names a nationality ("a
* Sri Lankan embassy", an Australian outlet's own country), so a town or
* region wins over a country; of those, the ones the article's own words
* name; then the most mentioned.
*/
export function choosePlaces(events, words) {
const byPlace = new Map();
for (const event of events) {
const key = `${event.lat},${event.lon}`;
const place = byPlace.get(key) || { ...event, mentions: 0 };
place.mentions += event.mentions;
byPlace.set(key, place);
}
let places = [...byPlace.values()];
const specific = places.filter((p) => p.precision !== 'country');
if (specific.length) places = specific;
const named = places.filter((p) => namedIn(words, p.place));
if (named.length) places = named;
return places.sort((a, b) => b.mentions - a.mentions).slice(0, MAX_PLACES);
}
// The earlier of two events: by the time GDELT added it, then by its id.
const earlier = (a, b) =>
b.addedAt < a.addedAt ||
(b.addedAt === a.addedAt && Number(b.id) < Number(a.id))
? b
: a;
/**
* One report per story (storyKey), its address and id the first copy's (an
* export adding a more-mentioned event keeps both), placed by choosePlaces,
* titled by a copy's page title (`titleOf`), else its address words, else
* its violent event.
*/
export function eventReports(events, { titleOf = () => null } = {}) {
const stories = new Map();
for (const event of events) {
const key = storyKey(event);
const list = stories.get(key) || [];
list.push(event);
stories.set(key, list);
}
const reports = [];
for (const list of stories.values()) {
const violent = list
.filter((e) => e.violent !== false)
.sort(
(a, b) => b.mentions - a.mentions || a.addedAt.localeCompare(b.addedAt),
);
if (!violent.length) continue;
const [lead] = violent;
const first = list.reduce(earlier);
const { url } = first;
const address = addressWords(url);
const places = choosePlaces(list, address);
const [, eventCategory, words] = eventKind(lead.code);
const page = [url, ...list.map((e) => e.url)]
.map((copy) => titleOf(copy))
.find(Boolean);
const own = page || (address ? withNames(address, places) : null);
const title = own || eventTitle(words, places[0].place);
// The article's words decide whether it is conflict, as any headline's
// would: GDELT codes an election story as "fighting" too. GDELT's code
// names the kind only where the words read as conflict without naming
// one. A story with no words at all has GDELT's code alone to go by,
// which is not enough to list it under Conflicts: it is minor (kept
// for Local) until a page title comes in.
const read = own ? categorizeText({ title: own }) : null;
const tags = read?.tags || [];
const relevance = read
? relevanceOf({ category: read.category, tags, title: own })
: 'minor';
const category = !read
? eventCategory
: read.category !== 'other'
? read.category
: relevance === 'minor'
? 'other'
: eventCategory;
const publisher = new URL(url).hostname.replace(/^www\./, '');
const geo = ({ lat, lon, place, precision }) => ({
lat,
lon,
place,
precision,
confidence: precision === 'city' ? 0.6 : 0.3,
mappable: true,
method: 'source',
});
reports.push({
id: `gdelt-${first.id}`,
title,
url,
publisher,
source: { name: publisher, attribution: 'GDELT Project' },
kind: 'gdelt',
side: 'unknown',
category,
relevance,
severity: Math.max(
1,
severityByRules({ category, tags, title: own || '' }),
),
severityBy: 'rules',
publishedAt: first.addedAt,
geo: geo(places[0]),
locations: places.slice(1).map(geo),
media: [],
mentions: list.reduce((sum, e) => sum + e.mentions, 0),
copies: new Set(list.map((e) => e.url)).size,
// A title of the event's words and its place, when the address had
// none: a renamed place renames it (eventTitle).
...(own ? {} : { eventWords: words }),
titled: page ? 'page' : own ? 'address' : 'event',
});
}
return reports;
}
/** The title of a report without address words, at its (renamed) place. */
export const eventTitle = (words, place) =>
place ? `${words} in ${place}` : words;
/**
* One export's table, or null while GDELT has not published it (GDELT
* lists the newest in lastupdate.txt; see fetchLatestExport).
*/
async function fetchTable(url, { fetchImpl, signal, maxBytes, tableBytes }) {
let response;
try {
response = await fetchImpl(url, {
redirect: 'error',
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
: AbortSignal.timeout(TIMEOUT_MS),
});
} catch {
throw new UpstreamError('GDELT exports unreachable');
}
if (response.status === 404) {
await response.body?.cancel?.().catch?.(() => {});
return null;
}
if (!response.ok) {
await response.body?.cancel?.().catch?.(() => {});
throw new UpstreamError(`GDELT exports answered ${response.status}`, {
status: response.status,
});
}
let bytes;
try {
bytes = await readResponseBytesCapped(response, maxBytes, signal);
} catch (error) {
// Over the cap stays over it; a read cut short may succeed again.
throw new UpstreamError('GDELT export unreadable', {
unreadable: error?.code === 'RESPONSE_TOO_LARGE',
});
}
return unzipExport(bytes, tableBytes);
}
/** One events export's events, or null while it is not published yet. */
export async function fetchExport(url, { fetchImpl, signal } = {}) {
const table = await fetchTable(url, {
fetchImpl,
signal,
maxBytes: MAX_ZIP_BYTES,
tableBytes: MAX_TABLE_BYTES,
});
return table === null ? null : parseEvents(table);
}
/**
* The page titles a GKG export holds for the addresses in `wanted`, or null
* while it is not published yet.
*/
export async function fetchTitles(url, wanted, { fetchImpl, signal } = {}) {
const table = await fetchTable(url, {
fetchImpl,
signal,
maxBytes: MAX_GKG_ZIP_BYTES,
tableBytes: MAX_GKG_TABLE_BYTES,
});
return table === null ? null : parseTitles(table, wanted);
}
+407
View File
@@ -0,0 +1,407 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
addressWords,
cleanTitle,
gkgUrl,
parseTitles,
withNames,
eventReports,
exportsOfDay,
exportUrl,
fetchExport,
parseEvents,
unzipExport,
} from './gdeltEvents.js';
import { eventRow, gkgRow, zipTable } from './gdeltFixtures.mjs';
const ARTICLE =
'https://www.wire.example/rubric-ato/3912-russian-drones-attack-kharkiv-overnight-three-killed.html';
test('the day is 96 exports, one each quarter hour, newest first', () => {
const now = Date.parse('2026-11-05T15:07:30Z');
assert.equal(
exportUrl(now),
'https://data.gdeltproject.org/gdeltv2/20261105150000.export.CSV.zip',
);
const day = exportsOfDay(now);
assert.equal(day.length, 96);
assert.equal(
day[1],
'https://data.gdeltproject.org/gdeltv2/20261105144500.export.CSV.zip',
);
assert.equal(
day.at(-1),
'https://data.gdeltproject.org/gdeltv2/20261104151500.export.CSV.zip',
);
});
test('a story with physical violence is kept with all its placed events', () => {
const rows = [
eventRow({ id: 1, code: '195', url: ARTICLE }),
eventRow({ id: 2, code: '183', url: ARTICLE }),
eventRow({ id: 3, code: '2001', url: ARTICLE }),
// Not physical, but of the same story: kept to place it by.
eventRow({ id: 4, code: '150', url: ARTICLE }),
eventRow({ id: 7, code: '190', quad: '3', url: ARTICLE }),
// A story with no violence in it is left out.
eventRow({
id: 5,
code: '163',
url: 'https://x.example/talks-resume-in-geneva-today-officials',
}),
eventRow({
id: 6,
code: '173',
url: 'https://x.example/talks-resume-in-geneva-today-officials',
}),
// Unplaced, at 0,0, or without a link.
eventRow({ id: 8, geoType: '0', url: ARTICLE }),
eventRow({ id: 9, lat: 0, lon: 0, url: ARTICLE }),
eventRow({ id: 10, url: 'javascript:alert(1)' }),
'short\trow',
];
const events = parseEvents(rows.join('\n'));
assert.deepEqual(
events.map((e) => [e.id, e.violent]),
[
['1', true],
['2', true],
['3', true],
['4', false],
['7', false],
],
);
assert.equal(events[0].precision, 'city');
assert.equal(events[0].addedAt, '2026-11-05T11:45:00.000Z');
});
test("an article is one report, at its most-mentioned event, in its address's words", () => {
const events = parseEvents(
[
eventRow({ id: 11, code: '195', mentions: 2, url: ARTICLE }),
eventRow({
id: 12,
code: '190',
mentions: 9,
url: ARTICLE,
place: "Kupiansk, Kharkivs'ka Oblast', Ukraine",
lat: 49.71,
lon: 37.61,
}),
eventRow({
id: 13,
code: '190',
geoType: '1',
place: 'Ukraine',
lat: 49,
lon: 32,
url: 'https://example.com/a/b/12345',
}),
].join('\n'),
);
const [drones, bare] = eventReports(events);
// The story's first event (by time, then id) names it.
assert.equal(drones.id, 'gdelt-11');
// Its address words, with the place it is at written as a name.
assert.equal(
drones.title,
'Russian drones attack Kharkiv overnight three killed',
);
// Kupiansk has more mentions; the article's own words name Kharkiv.
assert.equal(drones.geo.place, "Kharkiv, Kharkivs'ka Oblast', Ukraine");
assert.equal(drones.geo.precision, 'city');
assert.equal(drones.locations.length, 0, 'Kupiansk is not named');
assert.equal(drones.category, 'strike');
assert.equal(drones.relevance, 'core');
assert.equal(drones.severity, 2);
assert.equal(drones.publisher, 'wire.example');
assert.equal(drones.source.attribution, 'GDELT Project');
assert.equal(drones.side, 'unknown', 'no actor is said');
// No words in the address: GDELT's coding alone, so minor (not listed
// under Conflicts), at least severity 1.
assert.equal(bare.title, 'Fighting reported in Ukraine');
assert.equal(bare.category, 'ground');
assert.equal(bare.relevance, 'minor');
assert.equal(bare.severity, 1);
assert.equal(bare.geo.precision, 'country');
});
test('an article GDELT codes as fighting whose words say otherwise stays out of Conflicts', () => {
const [report] = eventReports(
parseEvents(
eventRow({
url: 'https://news.example.com/2026/11/05/city-council-approves-new-budget-for-schools',
id: 20,
}),
),
);
assert.equal(report.relevance, 'minor');
});
test('address words need four lettered words, most of the address', () => {
assert.equal(addressWords('https://x.example/news/1234567'), null);
assert.equal(addressWords('https://x.example/a/lueop1pzfi'), null);
assert.equal(
addressWords('https://x.example/2026/11/05/a-b-1-2-3-4-5'),
null,
);
assert.equal(
addressWords(
'https://x.example/story/2638789/three-children-die-in-peshawar.html',
),
'Three children die in peshawar',
);
});
test('an export is unzipped and read; one not published yet is null', async () => {
const zip = zipTable(eventRow({ id: 30, code: '193', url: ARTICLE }));
assert.deepEqual(
parseEvents(await unzipExport(zip)).map((e) => e.id),
['30'],
);
const served = await fetchExport('https://data.gdeltproject.org/x.zip', {
fetchImpl: async () => new Response(zip, { status: 200 }),
});
assert.deepEqual(
served.map((e) => e.id),
['30'],
);
const later = await fetchExport('https://data.gdeltproject.org/y.zip', {
fetchImpl: async () => new Response('not found', { status: 404 }),
});
assert.equal(later, null);
await assert.rejects(
fetchExport('https://data.gdeltproject.org/z.zip', {
fetchImpl: async () => new Response('busy', { status: 503 }),
}),
{ name: 'UpstreamError' },
);
await assert.rejects(unzipExport(new Uint8Array([1, 2, 3])), {
name: 'UpstreamError',
});
});
test("a nationality is no place: a town the article's words name wins over a bare country", () => {
const url =
'https://www.sun.example/world/2026/11/05/security-officer-attacked-and-killed-near-sri-lankan-embassy-in-jordans-capital';
const amman = { place: 'Amman, (JO11), Jordan', lat: 31.95, lon: 35.9333 };
const cairo = { place: 'Cairo, Al Qahirah, Egypt', lat: 30.05, lon: 31.25 };
const [report] = eventReports(
parseEvents(
[
// "Sri Lankan embassy": GDELT's most-mentioned place, a bare country.
eventRow({
id: 1,
mentions: 12,
geoType: '1',
place: 'Sri Lanka',
lat: 7,
lon: 81,
url,
}),
eventRow({ id: 2, mentions: 6, ...cairo, url }),
eventRow({ id: 3, mentions: 4, ...amman, url }),
eventRow({ id: 4, mentions: 2, ...amman, url }),
].join('\n'),
),
);
assert.equal(report.geo.place, 'Amman, (JO11), Jordan');
assert.equal(report.geo.precision, 'city');
});
test('an agency story run by several outlets is one report, placed by all their events', () => {
const words = 'jordan-security-officer-killed-near-sri-lanka-embassy';
const times = `https://www.times.example/story/9386807/${words}/`;
const chronicle = `https://www.chronicle.example/story/9386807/${words}/`;
const reports = eventReports(
parseEvents(
[
// The outlet's own country, at country level, on its violent events.
eventRow({
id: 1,
mentions: 6,
geoType: '1',
place: 'Australia',
lat: -25,
lon: 135,
url: times,
added: '20261105141500',
}),
// Its other events are placed in Jordan.
eventRow({
id: 2,
code: '120',
quad: '3',
mentions: 8,
place: 'Karak, Jordan (general), Jordan',
lat: 31.18,
lon: 35.7,
url: times,
added: '20261105141500',
}),
eventRow({
id: 3,
mentions: 4,
geoType: '1',
place: 'Syria',
lat: 35,
lon: 38,
url: chronicle,
added: '20261105151500',
}),
].join('\n'),
),
);
assert.equal(reports.length, 1);
const [story] = reports;
assert.equal(story.copies, 2);
assert.equal(story.url, times, 'the first copy');
assert.equal(story.publishedAt, '2026-11-05T14:15:00.000Z');
assert.equal(story.geo.place, 'Karak, Jordan (general), Jordan');
});
test("a story's title is a copy's page title from the GKG export", () => {
const titles = parseTitles(
[
gkgRow(
ARTICLE,
'Russian drones attack Kharkiv overnight, three killed | Wire',
),
gkgRow('https://elsewhere.example/a', 'Not one of ours, not kept here'),
].join('\n'),
new Set([ARTICLE]),
);
assert.deepEqual(
[...titles],
[[ARTICLE, 'Russian drones attack Kharkiv overnight, three killed']],
);
const [report] = eventReports(
parseEvents(eventRow({ id: 1, url: ARTICLE })),
{
titleOf: (url) => titles.get(url) || null,
},
);
assert.equal(
report.title,
'Russian drones attack Kharkiv overnight, three killed',
);
assert.equal(report.titled, 'page');
assert.equal(
gkgUrl(
'https://data.gdeltproject.org/gdeltv2/20261105141500.export.CSV.zip',
),
'https://data.gdeltproject.org/gdeltv2/20261105141500.gkg.csv.zip',
);
});
test('a page title loses its site name, keeps its words, and must be a headline', () => {
assert.equal(
cleanTitle(
'Yemen&#039;s president sacks ministers | Yemen | The Daily Example',
),
"Yemen's president sacks ministers",
);
assert.equal(
cleanTitle('Strike in Kharkiv - live updates - Wire Service'),
'Strike in Kharkiv - live updates',
);
// Too little before the dash: the dash is part of the headline.
assert.equal(cleanTitle('Iran - Wire Service'), 'Iran - Wire Service');
assert.equal(cleanTitle('Home'), null);
assert.equal(
cleanTitle('Jordanian security officer killed in stabbing attack-Newswire'),
'Jordanian security officer killed in stabbing attack',
);
assert.equal(
cleanTitle('Five killed in Gaza, sources say-X'),
'Five killed in Gaza, sources say',
);
// A hyphenated name is no tag.
assert.equal(
cleanTitle('Talks resume with Jean-Pierre'),
'Talks resume with Jean-Pierre',
);
});
test('address words keep their numbers and name the places the story is at', () => {
const words = addressWords(
'https://x.example/2026/11/05/jordan-says-u-s-envoy-met-officials-in-amman-after-500-killed',
);
assert.equal(
words,
'Jordan says u s envoy met officials in amman after 500 killed',
);
assert.equal(
withNames(words, [{ place: 'Amman, (JO11), Jordan' }]),
'Jordan says US envoy met officials in Amman after 500 killed',
);
});
test("a story's id is its first event's: a later, more-mentioned event keeps it", () => {
const first = eventRow({ id: 40, mentions: 1, url: ARTICLE });
const [before] = eventReports(parseEvents(first));
const [after] = eventReports(
parseEvents(
[
first,
eventRow({
id: 41,
code: '195',
mentions: 30,
url: ARTICLE,
added: '20261105120000',
}),
].join('\n'),
),
);
assert.equal(before.id, 'gdelt-40');
assert.equal(after.id, 'gdelt-40');
assert.equal(after.mentions, 31);
});
test('words that read as no conflict leave GDELT no kind to name', () => {
const [report] = eventReports(
parseEvents(
eventRow({
id: 42,
code: '195',
url: 'https://x.example/2026/11/05/local-bakery-celebrates-fifty-years-of-baking',
}),
),
);
assert.equal(report.relevance, 'minor');
assert.equal(report.category, 'other');
});
test('a GKG address written with http is the https one the events carry', () => {
const plain = ARTICLE.replace('https:', 'http:');
const [event] = parseEvents(eventRow({ id: 43, url: plain }));
assert.equal(event.url, ARTICLE);
const titles = parseTitles(
gkgRow(plain, 'Russian drones attack Kharkiv overnight, three killed'),
new Set([event.url]),
);
assert.equal(
titles.get(ARTICLE),
'Russian drones attack Kharkiv overnight, three killed',
);
});
test('a corrupt or oversized export is an UpstreamError, never a RangeError', async () => {
const zip = zipTable(eventRow({ id: 44, url: ARTICLE }));
const view = new DataView(zip.buffer);
// The central directory's local header offset, pointing past the end.
const dir = view.getUint32(zip.length - 22 + 16, true);
const far = zip.slice();
new DataView(far.buffer).setUint32(dir + 42, zip.length - 10, true);
await assert.rejects(unzipExport(far), { name: 'UpstreamError' });
// The end record cut short.
await assert.rejects(unzipExport(zip.subarray(0, zip.length - 30)), {
name: 'UpstreamError',
});
// Unzipped past the table's cap.
await assert.rejects(unzipExport(zip, 16), { name: 'UpstreamError' });
assert.equal((await unzipExport(zip)).length > 16, true);
});
+72
View File
@@ -0,0 +1,72 @@
// Test fixtures: GDELT 2.0 export rows and their zip, as the export files
// carry them (61 tab-separated columns, one zipped table).
import { deflateRawSync } from 'node:zlib';
/** One events row; only the columns the reader looks at are filled. */
export function eventRow({
id,
code = '190',
quad = '4',
mentions = 1,
geoType = '4',
place = "Kharkiv, Kharkivs'ka Oblast', Ukraine",
lat = 49.9935,
lon = 36.2304,
added = '20261105114500',
url,
}) {
const f = new Array(61).fill('');
f[0] = String(id);
f[26] = code;
f[28] = code.slice(0, 2);
f[29] = quad;
f[31] = String(mentions);
f[51] = geoType;
f[52] = place;
f[56] = String(lat);
f[57] = String(lon);
f[59] = added;
f[60] = url;
return f.join('\t');
}
/** One GKG 2.1 row: an article's address and its page title (27 columns). */
export function gkgRow(url, title) {
const f = new Array(27).fill('');
f[4] = url;
f[26] = `<PAGE_PRECISEPUBTIMESTAMP>20261105114500</PAGE_PRECISEPUBTIMESTAMP><PAGE_TITLE>${title}</PAGE_TITLE>`;
return f.join('\t');
}
/** A single-entry zip of `text`, deflated, sizes in the central directory. */
export function zipTable(text, name = 'table.export.CSV') {
const data = Buffer.from(text, 'utf8');
const packed = deflateRawSync(data);
const nameBytes = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x08, 6); // sizes follow the data (left 0 here)
local.writeUInt16LE(8, 8);
local.writeUInt16LE(nameBytes.length, 26);
const dir = Buffer.alloc(46);
dir.writeUInt32LE(0x02014b50, 0);
dir.writeUInt16LE(20, 4);
dir.writeUInt16LE(20, 6);
dir.writeUInt16LE(0x08, 8);
dir.writeUInt16LE(8, 10);
dir.writeUInt32LE(packed.length, 20);
dir.writeUInt32LE(data.length, 24);
dir.writeUInt16LE(nameBytes.length, 28);
dir.writeUInt32LE(0, 42);
const dirAt = local.length + nameBytes.length + packed.length;
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(1, 8);
end.writeUInt16LE(1, 10);
end.writeUInt32LE(dir.length + nameBytes.length, 12);
end.writeUInt32LE(dirAt, 16);
return new Uint8Array(
Buffer.concat([local, nameBytes, packed, dir, nameBytes, end]),
);
}
+909
View File
@@ -0,0 +1,909 @@
// The News layer's routes answered by Core itself, when no news server is
// configured (GEV_NEWS_ORIGIN). Conflicts: the day's GDELT events (no key;
// gdeltEvents.js), one report per article at GDELT's place for it, and with
// LIVEUAMAP_API_KEY Liveuamap's mapped items too. Local: World News API
// (WORLDNEWSAPI_KEY) by the country at the view centre; without its key the
// section answers 503 {error: 'no_key', key} and reads nothing, and past
// its day's reads (WORLDNEWSAPI_DAILY_READS) 503 {error: 'key_quota', key}.
// Conflicts' categories, relevance and severity are the shared rules
// (categorize.js); a country's own news keeps World News API's category.
// A news server may replace these readings with its own.
//
// A read that has something to serve is not waited for: what was read is
// served while it is read again. A section that cannot be read answers 502,
// never an empty list.
//
// The answers are the same /api/news contract a news server serves
// (src/layers/news/records.js reads both).
import {
CATEGORY_ORDER,
categorizeText,
relevanceOf,
severityByRules,
} from './categorize.js';
import {
areaAt,
countryAnchor,
countryByCode,
countryPolygons,
} from './areas.js';
import {
eventReports,
fetchLatestExport,
eventTitle,
exportAt,
exportsOfDay,
fetchExport,
fetchTitles,
gkgUrl,
storyKey,
} from './gdeltEvents.js';
import { fetchLiveuamap, fetchWorldNews, UpstreamError } from './upstream.js';
/** The keys each section reads with, by the key registry's id. */
export const NEWS_KEYS = Object.freeze({
liveuamap: 'LIVEUAMAP_API_KEY',
worldnewsapi: 'WORLDNEWSAPI_KEY',
});
/** The one hotspot: the world, its reports drawn as markers at every zoom. */
export const WORLDWIDE_HOTSPOT = 'worldwide';
const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS;
// GDELT publishes an export every 15 minutes and lists the newest in
// lastupdate.txt: the day's missing ones up to it are read every 5 minutes,
// newest first, a dozen a read (the first day's 96 fill in over the next
// reads, a few seconds apart, while each reads something new). One listed
// but missing (404) an hour and a half after its time is taken as never
// published; one that fails GDELT_EXPORT_TRIES times (a try a round), or
// cannot be read at all, is given up on, so one bad file never holds the
// rest back. Only while GDELT itself answers is a file given up on. The first
// answer waits at most FIRST_ANSWER_MS for the first exports.
const GDELT_FRESH_MS = 5 * 60_000;
const GDELT_EXPORTS_PER_READ = 12;
const GDELT_PARALLEL = 4;
const GDELT_NEXT_MS = 2_000;
const GDELT_GONE_MS = 90 * 60_000;
const GDELT_EXPORT_TRIES = 3;
const MAX_GDELT_REPORTS = 5000;
// Events kept of one story, to place it by.
const MAX_STORY_EVENTS = 200;
// Page titles are read from the GKG exports of the newest three hours: one
// runs to 6 MB, so the day's 96 are not fetched. A file that fails is tried
// with the next reads, a few times.
const GDELT_TITLED_EXPORTS = 12;
const GKG_TRIES = 3;
const LIVE_FRESH_MS = 5 * 60_000;
const COUNTRY_FRESH_MS = 30 * 60_000;
// A failed read is not tried again for a minute; what was read before is
// served meanwhile, up to these ages (GDELT's: since an export last came in).
const RETRY_MS = 60_000;
// A read with nothing to serve yet is waited for at most this long, well under
// the browser's own 15 s: it goes on, and the next ask is served from it.
const FIRST_ANSWER_MS = 10_000;
const GDELT_STALE_MS = 2 * HOUR_MS;
const LIVE_STALE_MS = HOUR_MS;
// World News API's terms allow a cache of at most an hour, deleted after:
// a country's news is kept no longer, served or not (dropExpiredCountries).
const COUNTRY_STALE_MS = HOUR_MS;
const MAX_COUNTRIES = 64;
// World News API reads a UTC day, across all countries: each is about 1.5
// of the free plan's 50 daily points.
const DEFAULT_DAILY_READS = 30;
const DEFAULT_LIMIT = 50;
const EMPTY_OUTLINE = Object.freeze({
polygons: [],
borders: [],
disputed: [],
area: null,
});
// A country's own news keeps World News API's category: the rules read
// fighting and crises, and in everyday news their words mislead ("advances
// in the rankings" is no ground fighting). Fighting in the country reaches
// Local as the Conflicts reports placed there.
const WORLD_NEWS_CATEGORY = { politics: 'political', business: 'economy' };
const list = (value) =>
value ? new Set(value.split(',').filter(Boolean)) : null;
/** The rules' reading of a Conflicts headline: category, relevance, severity. */
function readTitle(title) {
const read = categorizeText({ title });
const { category } = read;
return {
category,
relevance: relevanceOf({
category,
tags: read.tags,
title,
dedicated: true,
}),
severity: severityByRules({ category, tags: read.tags, title }),
};
}
/** A one-report event carrying the report's picture, for its card. */
function pictureEvent(id, report, image, video, publisher) {
return {
id,
status: 'single',
size: 1,
reports: 1,
severity: report.severity,
firstAt: report.publishedAt,
lastAt: report.publishedAt,
sides: [],
image: { url: image, publisher, ...(video ? { video } : {}) },
};
}
const media = ({ image, video }) =>
video ? ['video'] : image ? ['photo'] : [];
/** A Liveuamap item as a report of the Conflicts section. */
export function liveuamapReport(item) {
const id = `lum-${item.id}`;
const report = {
id,
hotspotId: WORLDWIDE_HOTSPOT,
hotspotIds: [WORLDWIDE_HOTSPOT],
title: item.title,
url: item.url,
publisher: 'Liveuamap',
source: { name: 'Liveuamap' },
kind: 'liveuamap',
side: 'unknown',
// A conflict map: everything it posts is at least related.
...readTitle(item.title),
severityBy: 'rules',
publishedAt: item.publishedAt,
geo: {
lat: item.lat,
lon: item.lon,
place: item.place,
precision: 'city',
confidence: 1,
mappable: true,
method: 'source',
},
media: media(item),
};
if (item.image)
report.event = pictureEvent(
id,
report,
item.image,
item.video,
'Liveuamap',
);
return report;
}
/**
* A GDELT place named by its town and the area Natural Earth has there (a
* disputed area as itself, else the country): GDELT's own names follow old
* FIPS regions ("Bureij, Yerushalayim, Israel" for a camp in Gaza, "Abuja,
* …, Niger" for Nigeria's capital). Its point stays. A town a disputed
* area is named after ("Jerusalem" in East Jerusalem) lies on both sides
* of its line: GDELT puts the whole city at one point, so it is named as
* the town alone.
*/
export function gdeltPlace(geo) {
const area = areaAt(geo.lat, geo.lon);
const town = String(geo.place ?? '')
.split(',')[0]
.trim();
const straddles =
area?.kind === 'disputed' &&
town &&
area.name.toLowerCase().includes(town.toLowerCase());
const place =
geo.precision === 'country' && area?.kind === 'country'
? area.name
: straddles
? town
: [town, area?.name].filter(Boolean).join(', ');
return { ...geo, place: place || null };
}
/**
* A World News API article as a report of the Local section: placed in its
* publisher's country alone, at a point inside it.
*/
export function worldNewsReport(article, country) {
const id = `wna-${article.id}`;
const anchor = countryAnchor(country);
const report = {
id,
hotspotId: null,
hotspotIds: [],
title: article.title,
url: article.url,
publisher: article.publisher,
// World News API's free plan asks to be credited where its news shows.
source: { name: article.publisher, attribution: 'World News API' },
kind: 'worldnewsapi',
side: 'unknown',
category: WORLD_NEWS_CATEGORY[article.category] || 'other',
// Local news: what the country's own outlets publish, with no severity
// of its own.
relevance: 'minor',
publishedAt: article.publishedAt,
geo: {
lat: anchor.lat,
lon: anchor.lon,
place: country.name,
precision: 'country',
confidence: 1,
mappable: true,
method: 'source',
},
media: media(article),
};
if (article.image)
report.event = pictureEvent(
id,
report,
article.image,
article.video,
article.publisher,
);
return report;
}
const AT = /^(-?\d{1,2}(?:\.\d{1,2})?),(-?\d{1,3}(?:\.\d{1,2})?)$/;
function point(value) {
const match = AT.exec(String(value ?? ''));
if (!match) return null;
const [lat, lon] = [Number(match[1]), Number(match[2])];
return Math.abs(lat) <= 90 && Math.abs(lon) <= 180 ? { lat, lon } : null;
}
const noKey = (key) => ({ status: 503, body: { error: 'no_key', key } });
const rejectedKey = (key) => ({
status: 503,
body: { error: 'key_rejected', key },
});
const spentKey = (key) => ({ status: 503, body: { error: 'key_quota', key } });
/**
* The keyed news routes. `answer(path, params)` resolves to `{status, body}`
* for `/hotspots`, `/feed` and `/outline`, whose queries the proxy has
* already checked against the client's allowlist. `later(run, ms)` runs
* the next GDELT read of a day still filling in (an unref'd timer).
* `firstAnswerMs` bounds the wait for a read with nothing to serve yet.
* @param {{env?: () => object, fetchImpl?: typeof fetch, now?: () => number, log?: (message: string) => void, later?: (run: () => void, ms: number) => void, firstAnswerMs?: number}} [options]
*/
export function createKeyedNews({
env = () => process.env,
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
log = (message) => console.warn(message),
later = (run, ms) => setTimeout(run, ms).unref(),
firstAnswerMs = FIRST_ANSWER_MS,
} = {}) {
const keyOf = (id) => String(env()[NEWS_KEYS[id]] ?? '').trim();
const inFlight = new Map();
// GDELT's stories of the last day (their events and report, by
// storyKey), and the exports read.
const gdelt = {
stories: new Map(),
reports: new Map(),
// Page titles by article address, of the stories kept.
titles: new Map(),
read: new Set(),
// Exports still missing well after their time: not looked for again.
gone: new Set(),
// Failed reads of each export not read yet: `{tries, at}`.
failures: new Map(),
// Exports read among the newest whose GKG titles are not in yet, with
// the tries made: `{tries, triedAt}`.
untitled: new Map(),
titling: null,
// When a read last brought an export in.
readAt: null,
checkedAt: null,
failedAt: null,
};
const live = new Map();
const countries = new Map();
// The keys refused so far, by section: logged once each.
const refused = new Set();
// World News API reads of the UTC day; `spent` once it answered 402.
const budget = { day: null, reads: 0, spent: false, logged: false };
// The areas (disputed area or country) of a placed Conflicts report's
// pins, its first and the others, looked up once.
const reportAreas = new WeakMap();
const areasOf = (report) => {
if (!reportAreas.has(report))
reportAreas.set(
report,
new Set(
[report.geo, ...(report.locations || [])]
.filter(Boolean)
.map((pin) => areaAt(pin.lat, pin.lon)?.key),
),
);
return reportAreas.get(report);
};
/** A failed read in the log: a refused key once, never the key itself. */
function logFailure(section, what, key, error) {
// The day's reads spent: said once, by spendRead.
if (error.quota && error.status === null) return;
if (error.rejected) {
const id = `${section}\0${key}`;
if (refused.has(id)) return;
refused.add(id);
log(
`[news] ${NEWS_KEYS[section]} was refused (${error.status})${section === 'liveuamap' ? "; Conflicts are GDELT's alone" : ''}`,
);
return;
}
log(`[news] ${what} read failed: ${error.message}`);
}
/**
* A cached read (`{key, value, readAt, failedAt, error}`, kept in `store`
* under `id`): served as it is within `freshMs`; past that read again,
* and served meanwhile up to `staleMs`, so only a read with nothing to
* serve is waited for. One read at a time per key; a failed read is tried
* again after RETRY_MS. A new key drops what the old one read.
*/
async function cached(
store,
id,
{ section, key, freshMs, staleMs, read, what },
) {
const at = now();
const stored = store.get(id);
const prior = stored?.key === key ? stored : { key };
if (prior.value && at - prior.readAt < freshMs) return prior;
if (prior.failedAt && at - prior.failedAt < RETRY_MS) return prior;
const flight = `${id}\0${key}`;
if (!inFlight.has(flight))
inFlight.set(
flight,
read(key)
.then(
(value) => ({ key, value, readAt: now() }),
(error) => {
logFailure(section, what, key, error);
// What is held now: news deleted meanwhile stays deleted.
const held = store.get(id);
return {
...(held?.key === key ? held : { key }),
failedAt: now(),
error,
};
},
)
.then((entry) => {
// A read with a key replaced meanwhile is dropped.
if (keyOf(section) === key) keep(store, id, entry);
return entry;
})
.finally(() => inFlight.delete(flight)),
);
if (prior.value && at - prior.readAt < staleMs) return prior;
// Meanwhile a refused key stays refused; an older failure is not this one.
return firstAnswer(inFlight.get(flight), {
...prior,
error: prior.error?.rejected ? prior.error : null,
});
}
/** An entry stored as the most recently used (the countries are capped). */
function keep(store, id, entry) {
store.delete(id);
store.set(id, entry);
while (store.size > MAX_COUNTRIES) store.delete(store.keys().next().value);
}
/** `read`, waited for at most firstAnswerMs; then `fallback`, as it goes on. */
async function firstAnswer(read, fallback) {
let timer;
try {
return await Promise.race([
read,
new Promise((resolve) => {
timer = setTimeout(() => resolve(fallback), firstAnswerMs);
timer.unref?.();
}),
]);
} finally {
clearTimeout(timer);
}
}
/** An entry's reports while they may be served, else why there are none. */
function served(entry, staleMs, id) {
if (entry.value && now() - entry.readAt < staleMs)
return { reports: entry.value, readAt: entry.readAt };
if (entry.error?.rejected) return { rejected: id };
if (entry.error?.quota) return { quota: id };
return { failed: true };
}
async function liveuamapReports() {
const key = keyOf('liveuamap');
if (!key) return { missing: 'liveuamap' };
const entry = await cached(live, 'liveuamap', {
section: 'liveuamap',
key,
freshMs: LIVE_FRESH_MS,
staleMs: LIVE_STALE_MS,
what: 'liveuamap',
read: async (k) =>
(await fetchLiveuamap({ key: k, fetchImpl, now: now() }))
.map(liveuamapReport)
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
});
return served(entry, LIVE_STALE_MS, 'liveuamap');
}
/**
* A story placed (again) from all its events and the titles in: an
* agency story run by several outlets is one report.
*/
function placeStory(key) {
const [report] = eventReports(gdelt.stories.get(key), {
titleOf: (url) => gdelt.titles.get(url) || null,
});
if (!report) return;
const { eventWords, ...rest } = report;
const geo = gdeltPlace(report.geo);
gdelt.reports.set(key, {
...rest,
...(eventWords ? { title: eventTitle(eventWords, geo.place) } : {}),
hotspotId: WORLDWIDE_HOTSPOT,
hotspotIds: [WORLDWIDE_HOTSPOT],
geo,
locations: report.locations.map(gdeltPlace),
});
}
/** An export's events into their stories, each placed again. */
function addEvents(events) {
const changed = new Set();
for (const event of events) {
const key = storyKey(event);
const list = gdelt.stories.get(key) || [];
if (list.length < MAX_STORY_EVENTS) list.push(event);
gdelt.stories.set(key, list);
changed.add(key);
}
for (const key of changed) placeStory(key);
}
/** The stories a GKG export titled, re-titled. */
function addTitles(titles) {
for (const [key, list] of gdelt.stories) {
const titled = list.filter((event) => titles.has(event.url));
if (!titled.length) continue;
for (const { url } of titled) gdelt.titles.set(url, titles.get(url));
placeStory(key);
}
}
/**
* The page titles of the newest exports read, from their GKG exports one
* at a time (each runs to 18 MB unzipped), in the background: no answer
* waits for them, and a story is re-titled as its title comes in. A file
* that fails or is not up yet only leaves address words: it is tried
* again with a later read while among the newest, up to GKG_TRIES times.
*/
function readTitles() {
gdelt.titling ??= (async () => {
let failed = null;
for (const [url, tried] of [...gdelt.untitled]) {
if (!gdelt.untitled.has(url)) continue;
if (tried.triedAt !== null && now() - tried.triedAt < GDELT_FRESH_MS)
continue;
tried.tries += 1;
tried.triedAt = now();
const wanted = new Set();
for (const list of gdelt.stories.values())
for (const event of list) wanted.add(event.url);
let titles = null;
try {
titles = await fetchTitles(gkgUrl(url), wanted, { fetchImpl });
} catch (error) {
failed ??= error;
}
if (titles || tried.tries >= GKG_TRIES) gdelt.untitled.delete(url);
if (titles?.size) addTitles(titles);
}
if (failed) log(`[news] gdelt titles read failed: ${failed.message}`);
})()
.catch((error) => log(`[news] gdelt titles failed: ${error.message}`))
.finally(() => {
gdelt.titling = null;
});
return gdelt.titling;
}
/**
* Up to a dozen of the day's exports not read yet, newest first; whether
* to carry on at once (the round brought one in, and more are missing).
* One not up yet is looked for again; one still missing GDELT_GONE_MS
* after its time is not.
*/
async function readExports() {
const at = now();
const day = exportsOfDay(at);
// Up to the newest GDELT lists; all of the day when its list fails.
const latest = await fetchLatestExport({ fetchImpl });
const listed = (url) => latest === null || exportAt(url) <= latest;
const newest = new Set(day.slice(0, GDELT_TITLED_EXPORTS));
const wanted = (url) =>
listed(url) && !gdelt.read.has(url) && !gdelt.gone.has(url);
const missing = day.filter(wanted).slice(0, GDELT_EXPORTS_PER_READ);
let failed = null;
let added = 0;
const failing = [];
const unreadable = [];
const unpublished = [];
for (let i = 0; i < missing.length; i += GDELT_PARALLEL)
await Promise.all(
missing.slice(i, i + GDELT_PARALLEL).map(async (url) => {
let events;
try {
events = await fetchExport(url, { fetchImpl });
} catch (error) {
failed = error;
(error.unreadable ? unreadable : failing).push(url);
return;
}
if (!events) {
if (at - exportAt(url) >= GDELT_GONE_MS) unpublished.push(url);
return;
}
// The events count now; the titles follow (readTitles).
gdelt.read.add(url);
gdelt.failures.delete(url);
added += 1;
gdelt.readAt = now();
addEvents(events);
if (newest.has(url))
gdelt.untitled.set(url, { tries: 0, triedAt: null });
}),
);
if (failed) log(`[news] gdelt read failed: ${failed.message}`);
// A file is blamed only while GDELT itself answers (its list read, or
// another export of the round read): then a 404 long after its time is
// never published, an unreadable file stays so, and a failing one gets a
// few tries, one a GDELT_FRESH_MS. With GDELT down, no file is blamed.
const answering = added > 0 || latest !== null;
if (answering)
for (const url of [...unpublished, ...unreadable]) gdelt.gone.add(url);
if (added)
for (const url of failing) {
const prior = gdelt.failures.get(url);
if (prior && now() - prior.at < GDELT_FRESH_MS) continue;
const tries = (prior?.tries || 0) + 1;
gdelt.failures.set(url, { tries, at: now() });
if (tries >= GDELT_EXPORT_TRIES) gdelt.gone.add(url);
}
// Nothing read and a failure: tried again after RETRY_MS, not after
// GDELT_FRESH_MS. Something read: the round counts.
if (failed && !added) gdelt.failedAt = now();
else {
gdelt.checkedAt = now();
gdelt.failedAt = null;
}
// Only the day is kept, and titled while among the newest.
const kept = new Set(day);
for (const set of [gdelt.read, gdelt.gone])
for (const url of set) if (!kept.has(url)) set.delete(url);
for (const url of gdelt.failures.keys())
if (!kept.has(url)) gdelt.failures.delete(url);
for (const url of gdelt.untitled.keys())
if (!newest.has(url)) gdelt.untitled.delete(url);
return (
added > 0 && missing.length === GDELT_EXPORTS_PER_READ && day.some(wanted)
);
}
/** GDELT's reports of the last day, newest first, or `{failed}`. */
async function gdeltReports() {
const at = now();
const fresh = gdelt.checkedAt && at - gdelt.checkedAt < GDELT_FRESH_MS;
const resting = gdelt.failedAt && at - gdelt.failedAt < RETRY_MS;
if (!fresh && !resting && !inFlight.has('gdelt')) {
const round = readExports()
.then(
(more) => {
// More of the day to fill in: carry on shortly, without holding
// the answer.
if (!more) return;
gdelt.checkedAt = null;
later(() => void gdeltReports(), GDELT_NEXT_MS);
},
(error) => {
log(`[news] gdelt read failed: ${error.message}`);
gdelt.failedAt = now();
},
)
.finally(() => {
inFlight.delete('gdelt');
void readTitles();
});
inFlight.set('gdelt', round);
}
// Only a read with nothing (recent) to serve is waited for: the
// newest exports' events, not their titles.
const current = () =>
gdelt.readAt !== null && now() - gdelt.readAt < GDELT_STALE_MS;
// A slow GDELT must not outlast the browser's own timeout: what has come
// in by then is served.
if (!current() && inFlight.has('gdelt'))
await firstAnswer(inFlight.get('gdelt'));
if (!current()) return { failed: true };
const dayAgo = new Date(now() - DAY_MS).toISOString();
const reports = [...gdelt.reports.values()]
.filter((r) => r.publishedAt >= dayAgo)
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
.slice(0, MAX_GDELT_REPORTS);
if (reports.length !== gdelt.reports.size) {
const kept = new Set(reports);
for (const [key, report] of gdelt.reports)
if (!kept.has(report)) {
for (const event of gdelt.stories.get(key) || [])
gdelt.titles.delete(event.url);
gdelt.reports.delete(key);
gdelt.stories.delete(key);
}
}
return { reports, readAt: gdelt.readAt };
}
/**
* The Conflicts section's reports: GDELT's, with Liveuamap's when its key
* is set. Liveuamap failing (or refusing its key) leaves GDELT's.
*/
async function conflictReports() {
const [articles, items] = await Promise.all([
gdeltReports(),
keyOf('liveuamap') ? liveuamapReports() : {},
]);
if (!articles.reports && !items.reports) return { failed: true };
return {
reports: [...(articles.reports || []), ...(items.reports || [])].sort(
(a, b) => b.publishedAt.localeCompare(a.publishedAt),
),
readAt: Math.max(articles.readAt || 0, items.readAt || 0),
};
}
const dailyReads = () => {
const raw = String(env().WORLDNEWSAPI_DAILY_READS ?? '').trim();
const reads = raw ? Number(raw) : NaN;
return Number.isInteger(reads) && reads >= 0 ? reads : DEFAULT_DAILY_READS;
};
/** Whether a World News API read is left today (UTC); counts it if so. */
function spendRead() {
const day = new Date(now()).toISOString().slice(0, 10);
if (budget.day !== day)
Object.assign(budget, { day, reads: 0, spent: false, logged: false });
if (!budget.spent && budget.reads < dailyReads()) {
budget.reads += 1;
return true;
}
if (!budget.logged)
log(
"[news] World News API's reads for today are spent; Local reads again after 00:00 UTC",
);
budget.logged = true;
return false;
}
/** World News API's news an hour old is deleted, as its terms ask. */
function dropExpiredCountries() {
for (const [code, entry] of countries)
if (entry.value && now() - entry.readAt >= COUNTRY_STALE_MS) {
const { value, readAt, ...rest } = entry;
countries.set(code, rest);
}
}
async function countryReports(country) {
const key = keyOf('worldnewsapi');
if (!key) return { missing: 'worldnewsapi' };
const stored = countries.get(country.code);
if (stored) keep(countries, country.code, stored);
const entry = await cached(countries, country.code, {
section: 'worldnewsapi',
key,
freshMs: COUNTRY_FRESH_MS,
staleMs: COUNTRY_STALE_MS,
what: `worldnewsapi:${country.code}`,
read: async (k) => {
// Not asked: no status, unlike an answer.
if (!spendRead())
throw new UpstreamError("World News API's reads for today spent", {
quota: true,
});
const { day } = budget;
try {
const reports = (
await fetchWorldNews({
key: k,
country: country.code,
fetchImpl,
now: now(),
})
).map((article) => worldNewsReport(article, country));
// Deleted an hour on, whether or not anything is asked then.
setTimeout(dropExpiredCountries, COUNTRY_STALE_MS + 1000).unref?.();
return reports;
} catch (error) {
// Out of points: nothing more is asked today.
if (error.status === 402) budget.spent = true;
// Never reached (offline): no read of the day it was counted in.
if (error.unreached && budget.day === day && budget.reads > 0)
budget.reads -= 1;
throw error;
}
},
});
return served(entry, COUNTRY_STALE_MS, 'worldnewsapi');
}
const refusal = (read) =>
read.missing
? noKey(read.missing)
: read.rejected
? rejectedKey(read.rejected)
: read.quota
? spentKey(read.quota)
: read.failed
? { status: 502, body: { error: 'news_upstream_error' } }
: null;
async function hotspots(params) {
const read = await conflictReports();
const refused = refusal(read);
if (refused) return refused;
const hours = Number(params.get('hours')) || 24;
const since = new Date(now() - hours * HOUR_MS).toISOString();
// The reports Conflicts lists: a minor one is in no count.
const shown = read.reports.filter(
(r) => r.publishedAt >= since && r.relevance !== 'minor',
);
return {
status: 200,
body: {
hotspots: [
{
id: WORLDWIDE_HOTSPOT,
name: 'Worldwide',
kind: 'conflict',
catchAll: true,
bbox: [-180, -90, 180, 90],
parties: [],
developing: [],
counts: {
last24h: shown.length,
mapped24h: shown.filter((r) => r.geo).length,
prev24h: null,
},
latestAt: shown[0]?.publishedAt ?? null,
},
],
categories: CATEGORY_ORDER,
ingest: { lastRunAt: new Date(read.readAt).toISOString() },
windowHours: hours,
maxWindowHours: 24,
},
};
}
/** Reports filtered, newest first, and paged by `before`. */
function page(reports, params) {
const since = params.get('since');
const before = params.get('before');
const dayAgo = new Date(now() - DAY_MS).toISOString();
const categories = list(params.get('category'));
const relevance = list(params.get('relevance'));
const hotspot = params.get('hotspot');
const limit = Number(params.get('limit')) || DEFAULT_LIMIT;
const kept = reports.filter(
(r) =>
(!hotspot || hotspot === 'all' || r.hotspotIds.includes(hotspot)) &&
(!since || r.publishedAt >= since) &&
(!before || r.publishedAt < before) &&
(!params.has('today') || r.publishedAt >= dayAgo) &&
(!params.has('geo') || r.geo) &&
(!categories || categories.has(r.category)) &&
(!relevance || relevance.has(r.relevance)),
);
const shown = kept.slice(0, limit);
// A page ends between two times: the next asks for those `before` the
// last, and a GDELT export's reports all share its quarter hour.
while (
shown.length < kept.length &&
kept[shown.length].publishedAt === shown.at(-1).publishedAt
)
shown.push(kept[shown.length]);
const events = params.has('events');
return {
reports: events ? shown : shown.map(({ event, ...report }) => report),
nextBefore: kept.length > shown.length ? shown.at(-1).publishedAt : null,
};
}
async function feed(params) {
const at = params.has('at') ? point(params.get('at')) : null;
const area = at ? areaAt(at.lat, at.lon) : null;
if (!area) {
// Conflicts, or Local at sea (by distance, in the browser).
const read = await conflictReports();
return refusal(read) || { status: 200, body: page(read.reports, params) };
}
// Local in a country: its own outlets, with the Conflicts reports
// placed inside it (those failing leave the outlets'). In a disputed
// area no country's outlets speak for it: the Conflicts reports there
// alone, so their failing is the answer's.
const country = area.kind === 'country';
const [national, conflicts] = await Promise.all([
country ? countryReports(area.country) : { reports: [] },
conflictReports(),
]);
const refused = refusal(national) || (!country && refusal(conflicts));
if (refused) return refused;
const inside = (conflicts.reports || []).filter((r) =>
areasOf(r).has(area.key),
);
const reports = [...national.reports, ...inside].sort((a, b) =>
b.publishedAt.localeCompare(a.publishedAt),
);
return { status: 200, body: page(reports, params) };
}
function outline(params) {
if (params.has('at')) {
const at = point(params.get('at'));
const area = at ? areaAt(at.lat, at.lon) : null;
if (!area) return { status: 200, body: EMPTY_OUTLINE };
return {
status: 200,
body: {
...EMPTY_OUTLINE,
polygons:
area.kind === 'country'
? countryPolygons(area.country)
: area.polygons,
area: {
key: area.key,
name: area.name,
kind: area.kind,
country: area.code,
},
},
};
}
// A developing situation's country; the world has no outline.
const country = countryByCode(
String(params.get('country') ?? '').slice(0, 2),
);
return {
status: 200,
body: country
? { ...EMPTY_OUTLINE, polygons: countryPolygons(country) }
: EMPTY_OUTLINE,
};
}
return {
async answer(path, params) {
dropExpiredCountries();
if (path === '/hotspots') return hotspots(params);
if (path === '/feed') return feed(params);
if (path === '/outline') return outline(params);
return { status: 404, body: { error: 'not_found' } };
},
};
}
File diff suppressed because it is too large. Load diff
+283
View File
@@ -0,0 +1,283 @@
// The keyed news APIs Core reads itself when no news server is configured:
// Liveuamap's worldwide feed (`a=mpts`) beside GDELT's events in the
// Conflicts section (gdeltEvents.js), World News API's search by source
// country for Local. Each key stays on the server: Liveuamap's rides in the query, so
// neither a URL nor an error body is ever logged or relayed.
import { readResponseTextCapped } from '../common/http.js';
const LIVEUAMAP_URL = 'https://a.liveuamap.com/api';
const WORLD_NEWS_URL = 'https://api.worldnewsapi.com/search-news';
// 100 Liveuamap items run to about 500 KB.
const LIVEUAMAP_COUNT = 500;
const LIVEUAMAP_MAX_BYTES = 16 * 1024 * 1024;
// One search per country: a point, and a hundredth of one per article, so
// about 1.5 points a read (the free plan has 50 points a day).
const WORLD_NEWS_ARTICLES = 50;
const WORLD_NEWS_MAX_BYTES = 8 * 1024 * 1024;
// World News API's categories that are not the news of the day. Its
// `categories` filter is not asked for: it classifies few articles outside
// English, so it would drop most of a country's own outlets; an article it
// left unclassified is kept.
export const WORLD_NEWS_LEFT_OUT = Object.freeze(
new Set(['sports', 'entertainment', 'lifestyle', 'travel']),
);
const DAY_MS = 86_400_000;
const TIMEOUT_MS = 20_000;
const ENTITIES = {
amp: '&',
quot: '"',
apos: "'",
lt: '<',
gt: '>',
nbsp: ' ',
};
/** Text with HTML entities decoded and whitespace collapsed, or ''. */
export function unescapeHtml(value) {
return String(value ?? '')
.replace(
/&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z]{2,6}));/gi,
(all, dec, hex, name) => {
if (name) return ENTITIES[name.toLowerCase()] ?? all;
const code = dec ? Number(dec) : parseInt(hex, 16);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : all;
},
)
.replace(/\s+/g, ' ')
.trim();
}
/** An https URL without credentials, or null. */
export function webLink(value) {
try {
const url = new URL(String(value ?? '').trim());
return url.protocol === 'https:' && !url.username && !url.password
? url.href
: null;
} catch {
return null;
}
}
/** A clip a browser plays itself (an https MP4 or WebM file), or null. */
export function playableVideo(value) {
const url = webLink(value);
return url && /\.(?:mp4|m4v|webm)$/i.test(new URL(url).pathname) ? url : null;
}
const finiteNumber = (value) => {
const number = typeof value === 'string' ? Number(value.trim()) : value;
return typeof number === 'number' && Number.isFinite(number) ? number : null;
};
/** Why an upstream could not be read; never carries its URL or body. */
export class UpstreamError extends Error {
constructor(
message,
{
status = null,
rejected = false,
quota = false,
unreadable = false,
unreached = false,
} = {},
) {
super(message);
this.name = 'UpstreamError';
this.status = status;
// The key was refused (401 or 403): the operator's to fix.
this.rejected = rejected;
// The key's allowance is spent (402, or the day's reads): it answers
// again later. A 429 is a per-second limit, not this.
this.quota = quota;
// The answer itself cannot be read (corrupt, or over a size cap): asking
// again gets the same.
this.unreadable = unreadable;
// The request got no answer at all (offline, timed out).
this.unreached = unreached;
}
}
async function getText(
fetchImpl,
url,
{ headers = {}, maxBytes, signal, what },
) {
let response;
try {
response = await fetchImpl(url, {
headers: { Accept: 'application/json', ...headers },
redirect: 'error',
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (cause) {
// A request that timed out may have been read; one refused was not.
const timedOut = cause?.name === 'TimeoutError';
throw new UpstreamError(
timedOut ? `${what} timed out` : `${what} unreachable`,
{ unreached: !timedOut },
);
}
if (!response.ok) {
await response.body?.cancel?.().catch?.(() => {});
throw new UpstreamError(`${what} answered ${response.status}`, {
status: response.status,
rejected: response.status === 401 || response.status === 403,
quota: response.status === 402,
});
}
try {
return await readResponseTextCapped(response, maxBytes, signal);
} catch {
throw new UpstreamError(`${what} answer unreadable`);
}
}
function parseJson(text, what) {
try {
return JSON.parse(text);
} catch {
// Not the parser's message: an error answer may echo the request.
throw new UpstreamError(`${what} answer unreadable`);
}
}
/**
* Liveuamap's items with a headline, a point and a page:
* `{id, title, lat, lon, place, publishedAt, url, image, video}`.
*/
export function parseLiveuamap(payload) {
if (!Array.isArray(payload?.venues))
throw new UpstreamError('Liveuamap answer unreadable');
const items = [];
for (const venue of payload.venues) {
const id = finiteNumber(venue?.id);
const title = unescapeHtml(venue?.name);
const lat = finiteNumber(venue?.lat);
const lon = finiteNumber(venue?.lng);
const seconds = finiteNumber(venue?.timestamp);
const url = webLink(venue?.link);
if (
id === null ||
!title ||
lat === null ||
lon === null ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180 ||
(lat === 0 && lon === 0) ||
seconds === null ||
!url
)
continue;
items.push({
id: Math.trunc(id),
title,
lat,
lon,
place: unescapeHtml(venue.location) || null,
publishedAt: new Date(seconds * 1000).toISOString(),
url,
image: webLink(venue.img_share),
video: playableVideo(venue.video),
});
}
return items;
}
/** The worldwide feed of the last day, as of `now`. */
export async function fetchLiveuamap({ key, fetchImpl, now, signal }) {
const url = new URL(LIVEUAMAP_URL);
url.search = new URLSearchParams({
a: 'mpts',
resid: '1',
time: String(Math.floor((now - DAY_MS) / 1000)),
count: String(LIVEUAMAP_COUNT),
key,
}).toString();
const text = await getText(fetchImpl, url, {
maxBytes: LIVEUAMAP_MAX_BYTES,
signal,
what: 'Liveuamap',
});
return parseLiveuamap(parseJson(text, 'Liveuamap'));
}
// World News API writes times as "2026-10-30 01:31:00", in UTC.
const worldNewsTime = (ms) =>
new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
function worldNewsDate(value) {
const match = /^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d)$/.exec(
String(value ?? ''),
);
const ms = match ? Date.parse(`${match[1]}T${match[2]}Z`) : NaN;
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
}
/**
* A search's articles with a title, a link and a time, one per title (the
* same story from several outlets is one), sport and entertainment left
* out, newest first:
* `{id, title, url, publisher, language, category, publishedAt, image, video}`.
*/
export function parseWorldNews(payload) {
if (!Array.isArray(payload?.news))
throw new UpstreamError('World News API answer unreadable');
const seen = new Set();
const articles = [];
for (const article of payload.news) {
const id = finiteNumber(article?.id);
const title = unescapeHtml(article?.title);
const url = webLink(article?.url);
const publishedAt = worldNewsDate(article?.publish_date);
const category =
typeof article?.category === 'string'
? article.category.toLowerCase()
: null;
if (id === null || !title || !url || !publishedAt) continue;
if (WORLD_NEWS_LEFT_OUT.has(category)) continue;
const same = title.toLowerCase();
if (seen.has(same)) continue;
seen.add(same);
articles.push({
id: Math.trunc(id),
title,
url,
publisher: new URL(url).hostname.replace(/^www\./, ''),
language:
typeof article.language === 'string' &&
/^[a-z]{2}$/i.test(article.language)
? article.language.toLowerCase()
: null,
category,
publishedAt,
image: webLink(article.image),
video: playableVideo(article.video),
});
}
return articles.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
}
/** A country's everyday news of the last day, as of `now`. */
export async function fetchWorldNews({ key, country, fetchImpl, now, signal }) {
const url = new URL(WORLD_NEWS_URL);
url.search = new URLSearchParams({
'source-country': country.toLowerCase(),
// Both ends: sorted by time with neither, a search is cut to 31 days.
'earliest-publish-date': worldNewsTime(now - DAY_MS),
'latest-publish-date': worldNewsTime(now),
sort: 'publish-time',
'sort-direction': 'DESC',
number: String(WORLD_NEWS_ARTICLES),
}).toString();
const text = await getText(fetchImpl, url, {
headers: { 'x-api-key': key },
maxBytes: WORLD_NEWS_MAX_BYTES,
signal,
what: 'World News API',
});
return parseWorldNews(parseJson(text, 'World News API'));
}
+1 -1
View File
@@ -69,7 +69,7 @@ function validMessages(messages) {
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
* as `resolve_area`'s area ids do. Every argument is still checked against
* the action's own contract before the tool runs, so the model loses a hint,
* not a rule. Realtime voice accepts these patterns and keeps them.
* not a rule. Realtime voice refuses them too now (#1618) and uses this as well.
*/
export function chatSchema(schema) {
if (Array.isArray(schema)) return schema.map(chatSchema);
+16 -1
View File
@@ -14,6 +14,7 @@ import {
} from './constants.js';
import { realtimeInstructions } from './instructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
import { chatSchema } from './chat.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
import {
preferCodexOAuth,
@@ -128,6 +129,19 @@ function createRealtimeOAuthLoginHandler({
};
}
/**
* The tools one Realtime session is offered, as OpenAI accepts them: it now
* refuses a whole session whose tool schema uses `\p{…}` patterns (#1618), as
* Chat Completions did (#1616), so voice drops the same pattern hints chat does.
*/
function realtimeSessionTools(tools, { overpass = false } = {}) {
return offeredTools(tools, { overpass }).map((tool) =>
tool.parameters
? { ...tool, parameters: chatSchema(tool.parameters) }
: tool,
);
}
function createRealtimeTokenHandler({
annotationGuidance,
endpoint = 'https://api.openai.com/v1/realtime/client_secrets',
@@ -303,7 +317,7 @@ function createRealtimeTokenHandler({
output: { voice },
},
instructions: realtimeInstructions(annotationGuidance, { overpass }),
tools: offeredTools(tools, { overpass }),
tools: realtimeSessionTools(tools, { overpass }),
tool_choice: 'auto',
},
};
@@ -370,4 +384,5 @@ export {
createRealtimeOAuthLoginHandler,
createRealtimeOAuthStatusHandler,
createRealtimeTokenHandler,
realtimeSessionTools,
};
+5
View File
@@ -227,6 +227,11 @@ export function installRouteMiddleware(
// only shape that asks the upstream for them. A response WITHOUT steps is
// byte for byte what this endpoint has always returned.
middlewares.use('/api/route', async (req, res) => {
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' });
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
const fail = (msg) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: msg }));
+6 -1
View File
@@ -3,6 +3,7 @@ import { fetchRegionalPlace } from './place.js';
import { fetchRegionalWeather } from './weather.js';
import { fetchRegionalNews } from './news.js';
import { validRegionalPoint } from './query.js';
import { REGIONAL_NEWS_WINDOW_HOURS } from '../../../src/data/regionalModel.js';
import { coalesceProxyRequest } from '../common/http.js';
// ---------------------------------------------------------------------------
@@ -50,10 +51,11 @@ function regionalBriefProxy({ placeProvider = fetchRegionalPlace } = {}) {
if (!regionalBriefHasAnySource({ place, weather, news })) {
throw new Error('All regional briefing sources unavailable');
}
const retrievedAt = new Date().toISOString();
const payload = {
status:
place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
retrievedAt: new Date().toISOString(),
retrievedAt,
coordinates: point,
place,
placeStatus: place ? 'ready' : 'unavailable',
@@ -62,6 +64,9 @@ function regionalBriefProxy({ placeProvider = fetchRegionalPlace } = {}) {
newsStatus: news.status,
newsQuery: news.query,
newsSource: news.source,
newsWindowHours: REGIONAL_NEWS_WINDOW_HOURS,
// A cached or stale answer keeps this, so the client can show its age.
newsFetchedAt: retrievedAt,
articles: news.articles,
};
_regionalBriefCache.set(key, { payload, cachedAt: Date.now() });
+35 -8
View File
@@ -1,5 +1,10 @@
import { fetchRegionalText, fetchRegionalJson } from './http.js';
import { normalizeRegionalArticles } from '../../../src/data/regionalModel.js';
import {
REGIONAL_NEWS_WINDOW_HOURS,
googleNewsWindowOperator,
normalizeRegionalArticles,
recentRegionalArticles,
} from '../../../src/data/regionalModel.js';
function decodeRssText(value) {
return String(value || '')
@@ -22,7 +27,10 @@ function rssTag(block, tag) {
);
}
function normalizeRssArticles(xml, limit = 5) {
// Feed items read before the window filter; Google returns about 100.
const RSS_ITEM_CAP = 100;
function normalizeRssArticles(xml, limit = RSS_ITEM_CAP) {
const seen = new Set();
const articles = [];
for (const match of String(xml || '').matchAll(
@@ -57,12 +65,27 @@ function normalizeRssArticles(xml, limit = 5) {
return articles;
}
async function fetchRegionalNews(place) {
/**
* Headlines for a place from the last REGIONAL_NEWS_WINDOW_HOURS, newest
* first: Google News RSS, then GDELT when RSS fails or has nothing recent.
* `empty` means a source answered with nothing in the window; `unavailable`
* means there was no place or no source answered.
* @param {{locality?: string, region?: string, country?: string}|null} place
* @param {{now?: () => number}} [options]
*/
async function fetchRegionalNews(place, { now = Date.now } = {}) {
const query = place?.locality || place?.region || place?.country;
if (!query)
return { status: 'unavailable', query: null, articles: [], source: null };
const recent = (articles) =>
recentRegionalArticles(articles, {
now: now(),
windowHours: REGIONAL_NEWS_WINDOW_HOURS,
limit: 5,
});
let answered = false;
const rssParams = new URLSearchParams({
q: String(query).replace(/["\\]/g, ' ').trim(),
q: `${String(query).replace(/["\\]/g, ' ').trim()} ${googleNewsWindowOperator()}`,
hl: 'en-US',
gl: 'US',
ceid: 'US:en',
@@ -75,7 +98,8 @@ async function fetchRegionalNews(place) {
timeoutMs: 12_000,
},
);
const articles = normalizeRssArticles(xml, 5);
answered = true;
const articles = recent(normalizeRssArticles(xml));
if (articles.length)
return { status: 'ready', query, articles, source: 'Google News RSS' };
} catch {
@@ -87,7 +111,7 @@ async function fetchRegionalNews(place) {
format: 'json',
maxrecords: '5',
sort: 'datedesc',
timespan: '48h',
timespan: `${REGIONAL_NEWS_WINDOW_HOURS}h`,
});
try {
const payload = await fetchRegionalJson(
@@ -97,7 +121,7 @@ async function fetchRegionalNews(place) {
timeoutMs: 12_000,
},
);
const articles = normalizeRegionalArticles(payload, 5);
const articles = recent(normalizeRegionalArticles(payload, 5));
return {
status: articles.length ? 'ready' : 'empty',
query,
@@ -105,7 +129,10 @@ async function fetchRegionalNews(place) {
source: 'GDELT fallback',
};
} catch {
return { status: 'unavailable', query, articles: [], source: null };
// An RSS answer with nothing recent is still a true empty result.
return answered
? { status: 'empty', query, articles: [], source: 'Google News RSS' }
: { status: 'unavailable', query, articles: [], source: null };
}
}
+130 -74
View File
@@ -1,6 +1,9 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
import {
celestrakSatcatUrl,
celestrakTleUrl,
} from '../../../src/data/spaceProviderRequests.js';
import { providerCooldownMs } from './cooldown.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { readResponseTextCapped } from '../common/http.js';
@@ -22,84 +25,131 @@ import { readResponseTextCapped } from '../common/http.js';
* 6 h; on upstream failure the freshest stale copy is served (a stale TLE
* beats an empty satellites layer). Pattern mirrors openSkyProxy's
* cache+serve-stale. Adapted from skylight's TleStore (MIT).
*
* The same plugin serves CelesTrak SATCAT records for the satellites layer's
* groups at `/api/celestrak-satcat/<group>` (object type, operational status
* and owner, which file each object under a class). The catalog changes
* slowly, so SATCAT is cached for a day. CelesTrak throttles the client, not
* the endpoint, so a 403 or 429 holds both routes off; any other failure
* holds off only the route that failed, so a bad SATCAT answer never stops
* TLE refreshes.
*/
export function celestrakProxy() {
const TLE_TTL_MS = 6 * 3600_000;
const SATCAT_TTL_MS = 24 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const groups = new Set([
/** The satellites layer's core groups (src/layers/satellites/policy.js). */
const coreGroups = [
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
// Space Missions (`getActiveTle`) reads the full active catalog.
'active',
]);
let cooldownUntil = 0;
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`);
async function readDisk(group) {
try {
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
return parsed;
} catch {
/* no disk cache yet */
}
return null;
}
async function writeDisk(group, entry) {
try {
await fsp.mkdir(CACHE_DIR, { recursive: true });
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
} catch (err) {
console.warn('[celestrak-proxy] cache write failed');
}
}
'resource',
'weather',
'planet',
'sar',
'science',
];
const feeds = [
{
route: '/api/celestrak',
ttlMs: TLE_TTL_MS,
cacheHeader: 'x-tle-cache',
cachedAtHeader: 'X-TLE-Cached-At',
diskName: (group) => `celestrak-${group}.json`,
upstreamUrl: celestrakTleUrl,
groups: new Set([
...coreGroups,
'starlink',
// Space Missions (`getActiveTle`) reads the full active catalog.
'active',
]),
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
validate: (body) => /^1 /m.test(body),
invalid: 'no TLE lines in response',
},
{
route: '/api/celestrak-satcat',
ttlMs: SATCAT_TTL_MS,
cacheHeader: 'x-satcat-cache',
cachedAtHeader: 'X-SATCAT-Cached-At',
diskName: (group) => `celestrak-satcat-${group}.json`,
upstreamUrl: celestrakSatcatUrl,
groups: new Set(coreGroups),
// A real answer, even for an empty group, starts with the CSV header.
validate: (body) => /^OBJECT_NAME,/.test(body),
invalid: 'no SATCAT header in response',
},
];
// CelesTrak throttling (403/429) is per client, so it holds every feed
// off; any other failure holds off only the feed that failed, so a bad
// SATCAT answer never blocks the TLEs the layer cannot do without.
let hostCooldownUntil = 0;
/** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */
const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
async function fetchUpstream(group) {
const url = celestrakTleUrl(group);
const signal = AbortSignal.timeout(20000);
const res = await fetch(url.toString(), {
signal,
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
// descriptive User-Agent with a contact point.
headers: {
'User-Agent':
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
},
});
if (!res.ok) {
// Group refreshes run in parallel: a later failure without Retry-After
// must never shorten a longer deadline an earlier one already set.
cooldownUntil = Math.max(
cooldownUntil,
Date.now() + providerCooldownMs(res),
);
void res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const body = await readResponseTextCapped(
res,
CELESTRAK_MAX_RESPONSE_BYTES,
signal,
);
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
return { at: Date.now(), body };
}
function createFeedHandler(feed) {
let feedCooldownUntil = 0;
const cooldownUntil = () => Math.max(hostCooldownUntil, feedCooldownUntil);
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
const diskPath = (group) => path.join(CACHE_DIR, feed.diskName(group));
const installMiddleware = (server) => {
server.middlewares.use('/api/celestrak', async (req, res) => {
async function readDisk(group) {
try {
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
return parsed;
} catch {
/* no disk cache yet */
}
return null;
}
async function writeDisk(group, entry) {
try {
await fsp.mkdir(CACHE_DIR, { recursive: true });
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
} catch (err) {
console.warn('[celestrak-proxy] cache write failed');
}
}
async function fetchUpstream(group) {
const url = feed.upstreamUrl(group);
const signal = AbortSignal.timeout(20000);
const res = await fetch(url.toString(), {
signal,
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
// descriptive User-Agent with a contact point.
headers: {
'User-Agent':
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
},
});
if (!res.ok) {
// Group refreshes run in parallel: a later failure without Retry-After
// must never shorten a longer deadline an earlier one already set.
const until = Date.now() + providerCooldownMs(res);
if (res.status === 403 || res.status === 429)
hostCooldownUntil = Math.max(hostCooldownUntil, until);
else feedCooldownUntil = Math.max(feedCooldownUntil, until);
void res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const body = await readResponseTextCapped(
res,
CELESTRAK_MAX_RESPONSE_BYTES,
signal,
);
if (!feed.validate(body)) throw new Error(feed.invalid);
return { at: Date.now(), body };
}
return async (req, res) => {
const group = String(req.url || '')
.replace(/^\//, '')
.split('?')[0];
@@ -111,7 +161,7 @@ export function celestrakProxy() {
res.end();
return;
}
if (!groups.has(group)) {
if (!feed.groups.has(group)) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('invalid group');
return;
@@ -123,15 +173,15 @@ export function celestrakProxy() {
if (res.headersSent) return;
res.writeHead(status, {
'Content-Type': 'text/plain',
'x-tle-cache': cacheStatus,
[feed.cacheHeader]: cacheStatus,
'Cache-Control': 'no-store',
...(entry
? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() }
? { [feed.cachedAtHeader]: new Date(entry.at).toISOString() }
: {}),
...(cooldownUntil > 0 && Date.now() < cooldownUntil
...(cooldownUntil() > 0 && Date.now() < cooldownUntil()
? {
'Retry-After': String(
Math.ceil((cooldownUntil - Date.now()) / 1000),
Math.ceil((cooldownUntil() - Date.now()) / 1000),
),
}
: {}),
@@ -145,11 +195,11 @@ export function celestrakProxy() {
entry = await readDisk(group);
if (entry) mem.set(group, entry);
}
if (entry && now - entry.at < TLE_TTL_MS) {
if (entry && now - entry.at < feed.ttlMs) {
send(200, entry.body, 'HIT', entry);
return;
}
if (Date.now() < cooldownUntil) {
if (Date.now() < cooldownUntil()) {
send(
entry ? 200 : 502,
entry?.body || 'celestrak upstream cooling down',
@@ -169,8 +219,8 @@ export function celestrakProxy() {
return fresh;
})
.catch((err) => {
if (Date.now() >= cooldownUntil)
cooldownUntil = Date.now() + 60000;
if (Date.now() >= feedCooldownUntil)
feedCooldownUntil = Date.now() + 60000;
console.warn(
'[celestrak-proxy] refresh failed — serving cache if any',
);
@@ -191,7 +241,13 @@ export function celestrakProxy() {
console.error('[celestrak-proxy] request failed');
send(500, 'celestrak proxy error', 'ERROR');
}
});
};
}
const handlers = feeds.map((feed) => [feed.route, createFeedHandler(feed)]);
const installMiddleware = (server) => {
for (const [route, handler] of handlers)
server.middlewares.use(route, handler);
};
return {
name: 'celestrak-proxy',
+144 -1
View File
@@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) {
: shape;
}
/** The kind of shape a row action is offered for: `area` or `line`. */
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
const lonLat = (point) =>
Array.isArray(point)
? [Number(point[0]), Number(point[1])]
: [Number(point?.lon), Number(point?.lat)];
const finitePairs = (points) =>
(Array.isArray(points) ? points : [])
.map(lonLat)
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
const wrapLon = (lon) =>
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
/** Whether consecutive positions step across the antimeridian once wrapped. */
const crossesAntimeridian = (points) =>
points.some(
(point, index) =>
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
);
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
const inRange = (points) => {
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
return crossesAntimeridian(wrapped) ? null : wrapped;
};
const closed = (ring) => {
if (ring.length < 3) return ring;
const [first, last] = [ring[0], ring[ring.length - 1]];
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
};
/**
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
* handle has several parts), a line a `LineString`. A pin, a shape too small
* to be one, or one that crosses the antimeridian has none, so it is offered
* no action rather than a shape that wraps the wrong way round the world.
*/
export function drawMarkGeometry(mark) {
if (mark?.type === 'route') {
const path = inRange(finitePairs(mark.path));
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
}
if (mark?.type !== 'area') return null;
const ringOf = (points) => {
const ring = inRange(closed(finitePairs(points)));
return ring?.length >= 4 ? ring : null;
};
if (Array.isArray(mark.polygons) && mark.polygons.length) {
const polygons = [];
for (const rings of mark.polygons) {
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
// A part that cannot be given whole is no part: never a hole dropped.
if (!parts.length || parts.some((ring) => !ring)) return null;
polygons.push(parts);
}
return polygons.length > 1
? { type: 'MultiPolygon', coordinates: polygons }
: { type: 'Polygon', coordinates: polygons[0] };
}
const ring = ringOf(mark.ring);
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
}
/**
* The actions an application adds to Draw's rows, checked once: each has an
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
* as the row shows it). Malformed or repeated ids are dropped.
*/
export function drawMarkActions(actions = []) {
const kept = new Map();
for (const action of Array.isArray(actions) ? actions : []) {
const wellFormed =
typeof action?.id === 'string' &&
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
typeof action.icon === 'string' &&
typeof action.label === 'function' &&
typeof action.run === 'function' &&
Array.isArray(action.shapes);
if (!wellFormed || kept.has(action.id)) continue;
kept.set(
action.id,
Object.freeze({
id: action.id,
icon: action.icon,
label: action.label,
shapes: Object.freeze(
action.shapes.filter((shape) =>
Object.values(ACTION_SHAPES).includes(shape),
),
),
accepts:
typeof action.accepts === 'function' ? action.accepts : () => true,
run: action.run,
}),
);
}
return Object.freeze([...kept.values()]);
}
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
export function drawMarkActionsFor(mark, actions = []) {
const shape = ACTION_SHAPES[mark?.type];
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
const geometry = drawMarkGeometry(mark);
if (!geometry) return { shape, geometry, actions: [] };
return {
shape,
geometry,
actions: actions.filter((action) => {
if (!action.shapes.includes(shape)) return false;
try {
return action.accepts(geometry, shape) === true;
} catch {
return false;
}
}),
};
}
/** An action's words for a row; an application's label that throws shows its id. */
function actionLabel(action, title) {
try {
const text = action.label(title);
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
} catch {
return action.id;
}
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
*/
export function presentDrawMarks(marks = []) {
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
const offered = drawMarkActionsFor(mark, actions).actions;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
busy: notes.get(mark.id)?.busy === true,
note: offered.length ? notes.get(mark.id)?.text || '' : '',
actions: offered.map((action) => ({
id: action.id,
icon: action.icon,
label: actionLabel(action, title),
})),
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
+219
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
drawMarkGeometry,
presentDrawMarks,
} from './drawMarks.js';
@@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () =>
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => {
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
}),
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
}).coordinates[0].at(-1),
[0, 0],
'an open ring is closed',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
],
}),
null,
);
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
{ lon: 1, lat: 2, height: 9 },
{ lon: 3, lat: 4, height: 0 },
],
}),
{
type: 'LineString',
coordinates: [
[1, 2],
[3, 4],
],
},
);
assert.equal(
drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }),
null,
);
const square = (x) => [
[x, 0],
[x + 1, 0],
[x + 1, 1],
[x, 0],
];
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: square(0),
polygons: [[square(0)], [square(5)]],
}),
{ type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] },
'an area handle with several parts keeps them all',
);
assert.equal(
drawMarkGeometry({
type: 'area',
polygons: [
[
square(0),
[
[0.2, 0.2],
[0.3, 0.2],
],
],
],
}),
null,
'a hole that cannot be given whole is never dropped silently',
);
});
test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => {
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
[370, 1],
[371, 1],
],
}).coordinates,
[
[10, 1],
[11, 1],
],
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[180, 0],
[179, 0],
[179, 1],
],
}).coordinates[0][0],
[180, 0],
'the antimeridian itself stays put',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[179.9, 0],
[-179.9, 0],
[-179.9, 1],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[179.5, 0],
[180.5, 0],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[0, 91],
[1, 0],
],
}),
null,
);
});
test('row actions are checked once: malformed ones and repeated ids are dropped', () => {
const run = () => {};
const label = () => 'x';
const actions = drawMarkActions([
{ id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] },
{ id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] },
{
id: 'tag',
icon: 'sell',
label,
run,
shapes: ['area', 'line', 'pin'],
},
{ id: 'tag', icon: 'other', label, run, shapes: ['area'] },
null,
]);
assert.deepEqual(
actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })),
[{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }],
);
assert.deepEqual(drawMarkActions('nope'), []);
const area = {
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
};
const offer = drawMarkActionsFor(area, actions);
assert.equal(offer.shape, 'area');
assert.deepEqual(
offer.actions.map((a) => a.id),
['tag'],
);
const refusing = drawMarkActions([
{
id: 'measure',
icon: 'a',
label,
run,
shapes: ['area'],
accepts: () => {
throw new Error('no');
},
},
]);
assert.deepEqual(
drawMarkActionsFor(area, refusing).actions,
[],
'an accepts that throws offers nothing',
);
assert.deepEqual(
presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0]
.actions,
[],
);
});
+90 -5
View File
@@ -50,9 +50,28 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The longest note a row action leaves under its row, in characters. */
const NOTE_MAX = 200;
/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */
function noteText(text) {
if (typeof text !== 'string') return '';
const trimmed = text.trim();
if (trimmed.length <= NOTE_MAX) return trimmed;
const cut = trimmed.slice(0, NOTE_MAX - 1);
const space = cut.lastIndexOf(' ');
return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
@@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS;
* through the actions it gives `connect`, which returns their disconnect.
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* `markActions` are the application's own buttons on an area's or a line's
* row (drawMarks.js `drawMarkActions`); a press runs the action with the
* shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
@@ -76,7 +98,12 @@ export function initDrawTool({
navigation = null,
publish = () => {},
connect = () => () => {},
markActions = [],
}) {
const rowActions = drawMarkActions(markActions);
// What a row action said last on each drawn shape: `{ run, busy, text }`.
const notes = new Map();
let noteRun = 0;
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
@@ -115,14 +142,19 @@ export function initDrawTool({
() => null,
);
const present = () =>
const present = () => {
const marks = annotations.list?.() || [];
// A note goes with its shape.
for (const id of notes.keys())
if (!marks.some((mark) => mark.id === id)) notes.delete(id);
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
marks: presentDrawMarks(marks, rowActions, notes),
});
};
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
@@ -450,6 +482,56 @@ export function initDrawTool({
return annotations.frame?.(id) ?? false;
}
/**
* Run the application's row action `actionId` on drawn shape `id`, if the
* shape is still on the board and still offered it. Returns whether it ran.
* What the action returns, or resolves to, is the row's note (a measurement,
* "saved"): the row is busy until it settles, a newer press or the shape's
* removal makes a late answer moot, and a failure clears the note, so an
* action words its own refusals and resolves with them.
*/
function runMarkAction(id, actionId) {
const mark = (annotations.list?.() || []).find(
(entry) => entry.id === id && entry.manual === true,
);
const offer = drawMarkActionsFor(mark, rowActions);
const action = offer.actions.find((entry) => entry.id === actionId);
if (!action || notes.get(mark.id)?.busy) return false;
const run = ++noteRun;
const settle = (text) => {
if (destroyed || notes.get(mark.id)?.run !== run) return;
notes.set(mark.id, {
run,
busy: false,
text: noteText(text),
});
present();
};
const fail = (error) => {
console.warn(`[draw] ${actionId} failed`, error);
settle('');
};
notes.set(mark.id, { run, busy: true, text: '' });
let answer;
try {
answer = action.run({
id: mark.id,
shape: offer.shape,
name: mark.name || '',
title: mark.name || drawMarkFallbackName(mark),
geometry: offer.geometry,
});
} catch (error) {
fail(error);
return false;
}
if (typeof answer?.then === 'function') {
present();
Promise.resolve(answer).then(settle, fail);
} else settle(answer);
return true;
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -478,6 +560,7 @@ export function initDrawTool({
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
runDrawMarkAction: runMarkAction,
});
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
@@ -500,13 +583,15 @@ export function initDrawTool({
setActive,
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
marks: () =>
presentDrawMarks(annotations.list?.() || [], rowActions, notes),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
runMarkAction,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
+129 -1
View File
@@ -223,7 +223,7 @@ function fakeAnnotations() {
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness({ navigation = null } = {}) {
function harness({ navigation = null, markActions = [] } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) {
viewer,
annotations,
navigation,
markActions,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
@@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
}
});
test('an application\'s row actions show on the shapes they are for and run with the shape', async () => {
const ran = [];
const h = harness({
markActions: [
{
id: 'tag',
icon: 'sell',
label: (name) => `Tag ${name}`,
shapes: ['area', 'line'],
run: (shape) => ran.push(['tag', shape]),
},
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
accepts: (geometry) => geometry.type === 'Polygon',
run: (shape) => ran.push(['measure', shape]),
},
],
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] },
{ id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] },
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
[
['r1', [
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
]],
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
['p1', []],
],
);
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action');
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual(ran, [
['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }],
['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }],
]);
} finally {
h.restore();
}
});
test('an action that throws is reported and leaves the row alone', async () => {
const h = harness({
markActions: [
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
],
});
const warn = console.warn;
const warned = [];
console.warn = (...args) => warned.push(args[0]);
try {
h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
h.annotations.changed();
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
assert.deepEqual(warned, ['[draw] boom failed']);
} finally {
console.warn = warn;
h.restore();
}
});
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
const pending = [];
const h = harness({
markActions: [
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
},
{ id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' },
{ id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` },
],
});
const warn = console.warn;
console.warn = () => {};
try {
const board = h.annotations;
const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
board.marks.push(area('r1'), area('r2'));
board.changed();
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
h.act('runDrawMarkAction', 'r1', 'long');
assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word');
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [true, '']);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press');
pending[0].resolve('412 km², 38 km of edge');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
h.act('runDrawMarkAction', 'r1', 'measure');
pending[1].reject(new Error('refused'));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
h.act('runDrawMarkAction', 'r2', 'measure');
board.marks.splice(1, 1);
board.changed();
pending[2].resolve('late');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(row('r2'), undefined);
board.marks.push(area('r2'));
board.changed();
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
} finally {
console.warn = warn;
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
+4 -1
View File
@@ -1,5 +1,6 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
@@ -31,6 +32,7 @@ import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationShipwrecks } from './layers/shipwrecks.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationNews } from './layers/news.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
@@ -79,7 +81,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const weatherClock = createWeatherClock({ world: worldClock });
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
@@ -140,6 +142,7 @@ export function createApplicationCatalog({
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationNews({ source: sources.news }),
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 39);
assert.equal(first.layers.length, 40);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'osm-places'),
@@ -68,6 +68,7 @@ test('catalogs construct distinct layers and classification from their supplied
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('drone-airspace'));
assert.ok(first.get('news'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -161,7 +162,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 39);
assert.equal(catalog.layers.length, 40);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+5 -1
View File
@@ -1,6 +1,10 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
import { worldTimeFor } from '../../time/worldClock.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
return createGnssIntegrityLayer({
worldNow: worldTimeFor('gnss-integrity'),
...options,
});
}
+6
View File
@@ -0,0 +1,6 @@
import { createNewsLayer } from '../../layers/news/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire the news layer to the application overlay host. */
export function createApplicationNews(options) {
return createNewsLayer({ overlayHost, ...options });
}
+4
View File
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { worldTimeFor } from '../../time/worldClock.js';
const imageryTime = worldTimeFor('recent-imagery');
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
now: () => new Date(imageryTime()),
});
}
+8
View File
@@ -15,6 +15,8 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
@@ -59,6 +61,12 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(
followWorldTime(viewer, worldTimeFor('sky'), {
subscribe: worldClock.subscribe,
requestRender: () => governorRequestRender('world-time'),
}),
);
defer(installFreeLookHeightLimit(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
+1
View File
@@ -51,6 +51,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
shipwrecks: contract(['shipwrecks'], ['getSnapshot']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
news: contract(['news'], ['getHotspots', 'getFeed', 'getOutline']),
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
+30 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 39);
assert.equal(actual.layers.length, 40);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
@@ -150,6 +150,35 @@ test('GhostMaps obeys source omission, replacement and explicit removal', async
assert.equal(fetches, 0);
});
test('News is unavailable without a source and the standalone default reads only same-origin /api/news', async (t) => {
const missing = catalog({}, t);
assert.equal(missing.getSourceAvailability('news')?.available, false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: missing.getSourceAvailability },
);
manager.register(missing.get('news'));
assert.equal(await manager.setEnabled('news', true), false);
assert.equal(manager.layers.get('news').initialized, false);
assert.equal(createStandaloneLayerSources({ news: null }).news, null);
const urls = [];
t.mock.method(globalThis, 'fetch', async (url) => {
urls.push(url);
return new Response(
JSON.stringify({
error: 'news_not_configured',
unavailable: true,
retryable: false,
}),
{ status: 503 },
);
});
const standalone = createStandaloneLayerSources().news;
await assert.rejects(standalone.getHotspots(), { unavailable: true });
assert.deepEqual(urls, ['/api/news/hotspots']);
});
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
const actual = catalog({}, t);
const manager = new LayerLifecycle(
+22 -1
View File
@@ -1,6 +1,11 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import {
claimPointer,
isPointerFree,
releasePointer,
} from '../data/inputOwnership.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
@@ -35,6 +40,7 @@ export function createApplicationTools({
startChrome,
onSceneDirector,
sceneDataPacks,
drawMarkActions = [],
signal,
defer,
}) {
@@ -80,12 +86,15 @@ export function createApplicationTools({
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button. A row's fly-to
// goes through the shell's navigation policy, as Import's does.
// `drawMarkActions` are an application's own buttons on a drawn area's or
// line's row (annotations/drawMarks.js); the standalone shell adds none.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
markActions: drawMarkActions,
});
defer(() => drawTool?.destroy());
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
@@ -241,5 +250,17 @@ export function createApplicationTools({
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
// An application's own tool takes the pointer as Draw does, and its own
// click handlers yield while a tool holds it, as the layers' do
// (data/inputOwnership.js).
return {
sceneDirector,
annotations,
voiceCommands,
pointer: {
claim: claimPointer,
release: releasePointer,
isFree: isPointerFree,
},
};
}
Loaded 100 of 466 files, more files were not shown because too many files have changed in this diff. Show more