mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
Merge branch 'main' into upstream/firms-age-rounding
This commit is contained in:
commit
1c08482fa8
406 files changed
+53649
-1838
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -30,6 +30,7 @@ How to read this:
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo that names one, linked to its Planespotters page; "Planespotters.net" in Data attribution |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **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) |
|
||||
@@ -39,6 +40,12 @@ How to read this:
|
||||
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
|
||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
|
||||
| **News server** (`GEV_NEWS_ORIGIN`, optional; none by default) | News layer, both sections: the last 24 h of reports from many publishers. Set, the local server forwards the layer's `/api/news/hotspots`, `/feed` and `/outline` GET requests to it and Core reads none of the three sources below. Which providers stand behind its feed is the news server's to state | Each report stays its publisher's: headlines and summaries link to the original article, and pictures are hotlinked from the publisher's site (no referrer), never stored | Per report: publisher name and article link on every card; picture cards name the picture's publisher. The Data attribution popover states that places, categories, severity and English headlines are derived automatically and can be wrong |
|
||||
| **GDELT 2.0 events and GKG** (`data.gdeltproject.org/gdeltv2/`; no key; used only without a news server) | News layer, Conflicts: the last 24 h of GDELT's 15-minute event exports (`*.export.CSV.zip`, about 100 KB each), read server-side as they appear. Physical violence only (CAMEO roots 18–20); one report per story, copies of one agency story merged; placed at GDELT's location for it (a town the article's words name beats a bare country). Titled by the article's page title from the GKG export of the same quarter hour (`*.gkg.csv.zip`, about 6 MB each) for the newest 3 h, else by the words of the article's address. GDELT's actor coding is never shown. Categories, relevance and severity by bundled rules | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic, commercial and governmental use, with citation and a link to the GDELT Project. Coded events, page titles and article links only; linked articles keep their publishers' terms | "GDELT Project" with a link to gdeltproject.org in the Data attribution popover; "via GDELT Project", the publisher's domain and the article link on every card |
|
||||
| **Liveuamap** (`a.liveuamap.com/api`, `LIVEUAMAP_API_KEY`, optional, paid; used only without a news server) | News layer, Conflicts, beside GDELT: Liveuamap's worldwide mapped items of the last 24 h (`a=mpts`), read server-side every 5 minutes; categories and severity are read from the headline by bundled rules | Liveuamap API terms of the operator's own subscription. Headlines, places and links only; item pictures and clips are hotlinked from Liveuamap, never stored | "Liveuamap" with a link in the Data attribution popover once its items show; "Liveuamap" and the item's Liveuamap link on every card |
|
||||
| **World News API** (`api.worldnewsapi.com/search-news`, `WORLDNEWSAPI_KEY`, optional; used only without a news server) | News layer, Local: the last 24 h of news by the source country at the view centre (country from the bundled Natural Earth admin-0 pack; none in a disputed area), read server-side once per country every 30 minutes, about 1.5 points a read, kept in memory for at most 1 hour and then deleted (a read that fails is served from that copy only within the hour); at most `WORLDNEWSAPI_DAILY_READS` reads a day (default 30), after which Local says the daily limit is reached. Each story keeps World News API's own category (politics, business, else other) with no severity of its own; sport, entertainment, lifestyle and travel left out | [World News API terms](https://worldnewsapi.com/terms/) of the operator's own plan. The terms allow caching only with World News API's prior written permission, and then for at most 1 hour: getting that permission before setting the key is the operator's responsibility. Titles and links only; article text and summaries are not kept or shown; pictures are hotlinked from the publisher | The "World News API" link to worldnewsapi.com, always in the Data attribution popover (a credit; the terms require a backlink only of invited free plans); "via World News API", the publisher's domain and the article link on every card |
|
||||
| **EMODnet Human Activities — Ship wrecks** (`ows.emodnet-humanactivities.eu/wfs`, layer `emodnet:heritageshipwrecks`) | Shipwrecks layer: about 7,000 wrecks in European waters (name, sinking year and text, vessel type, least depth and its quality, position accuracy, period, cargo, protected status). Keyless WFS GeoJSON, fetched by `/api/shipwrecks` once a day; rows sourced from wrecksite.eu are skipped, and free text that refers to a person by a title or role is dropped by a keyword filter (a bare name can still pass) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) (EMODnet catalogue record `e965088b-a265-4517-84df-c49b156af8a7`; data originator AND-International, compiled from SHOM, National Monuments Service, Historic England and the Oxford Roman Economy Project); fetched at runtime, not stored in this repository | "Shipwrecks (Europe): EMODnet Human Activities (data originator AND-International, …), CC BY 4.0" — shown in-app |
|
||||
| **Wikidata — shipwrecks** (`query.wikidata.org/sparql`) | Shipwrecks layer: shipwreck items (Q852190) with a coordinate and at least one Wikipedia article, plus sinking events (Q906512, where the death toll lives): name, ship type, sinking date with its precision, deaths and survivors (counts only), description and article link. Keyless, fetched by `/api/shipwrecks` once a day | [CC0](https://creativecommons.org/publicdomain/zero/1.0/); fetched at runtime | "Shipwrecks (worldwide): Wikidata (CC0)" — shown in-app |
|
||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
|
||||
| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" |
|
||||
@@ -200,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`, and whose page is an `https://www.planespotters.net` URL is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
|
||||
- **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.
|
||||
@@ -268,6 +276,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **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) |
|
||||
@@ -538,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.
|
||||
@@ -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
|
||||
@@ -384,7 +384,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
|
||||
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
|
||||
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients.
|
||||
|
||||
Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
Thirty-five tools (thirty-four unless an Overpass server is set), four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
|
||||
**🎥 Direct it** — drone-operator camera verbs:
|
||||
|
||||
@@ -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 |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
@@ -438,6 +438,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 |
|
||||
| 🌍 **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 | 🟢 |
|
||||
@@ -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 |
|
||||
|
||||

|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
+2
-1
@@ -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.
|
||||
@@ -132,6 +132,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`
|
||||
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
|
||||
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
|
||||
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
|
||||
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; freshness is judged only by the result's `issued_at`, so a result older than five minutes only arms, whether the panel was mounted again (reopening a conversation) or got it later on the same mount, while one re-sent or remounted within five minutes of issue opens the photo again; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it, or when the screen centre cannot be picked at all (no ground picking, or a globe with no size yet); with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
|
||||
|
||||
## Scope & expectations
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
- "Show me X on Street View" (or on Mapillary, KartaView or Panoramax): a
|
||||
`show_street_level` voice and typed action flies to the place, switches to
|
||||
Google 3D when the source needs it, lights that source in Street Level,
|
||||
turns the layer on and opens the nearest photo, saying which step failed.
|
||||
With no place named it looks under the screen centre, as OPEN does. Views
|
||||
and share links carry the same request (`street_level`, `sl=`). A plain
|
||||
`sl=google` link never loads a billed panorama by itself: it arms Street
|
||||
View and leaves the load to OPEN GOOGLE STREET VIEW; embedded views open it,
|
||||
and so does the God's Eye View panel for a result issued in the last five
|
||||
minutes; a link whose source has no working key says so. A request stops
|
||||
when the user navigates, or enters cockpit, clean view, recording or scene
|
||||
playback, before its photo loads (collapsing the panel does not stop it: the
|
||||
photo reveals it); a new photo replaces the open one in place, so EXPAND
|
||||
stays. When nothing is found and a 360°, flat or SINCE filter is on, the
|
||||
answer names the filter. Failure cards are in the interface language. An
|
||||
unavailable map stack now reports the
|
||||
controller's own reason instead of always asking for a Cesium ion token
|
||||
(Said Turksever, @asturksever, #1113).
|
||||
@@ -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).
|
||||
@@ -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,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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- New Shipwrecks layer (Events): about 8,000 wrecks from EMODnet (CC BY 4.0)
|
||||
and Wikidata (CC0). Each has a card with deaths, date, vessel, cause, depth,
|
||||
position quality and a source link. Markers grow with the death toll, and
|
||||
a start and end year picker filters by period (IMBrandao, #1583).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Without `OPENAI_API_KEY`, the mic now says the key is missing and to add it in
|
||||
Provider Settings, instead of showing the raw server error with a hint to
|
||||
check microphone permissions. `/api/realtime/token` returns
|
||||
`code: "API_KEY_MISSING"` with its 503 (asturksever, #1599).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Add Swedish (sv) to the language picker: the whole interface in Swedish,
|
||||
with Swedish number and date formats and a style guide and glossary in
|
||||
docs/translations/sv.md (Kaj Obst, @Kajjan7610, #1630).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,3 @@
|
||||
- A tracked aircraft's Planespotters photo is shown even when the answer names
|
||||
no photographer, linked to its Planespotters page with no credit line,
|
||||
instead of no photo at all (Sameh Khamis, @samehkhamis, #1657).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Hide street-vehicle brackets and callouts behind buildings and terrain. A
|
||||
Street Traffic contact behind a building, a 3D tile, a hill or a tunnel roof
|
||||
now loses its bracket and "VEH-nnnn" callout along with its dot, instead of
|
||||
having them painted over the facade; aircraft, vessels and satellites keep
|
||||
their labels over the scene as before (Sameh Khamis, @samehkhamis, #1659).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keyless Gemini voice no longer blames the microphone. Without
|
||||
`GEMINI_API_KEY`, the voice control shows only "Add a Gemini API key in
|
||||
Provider Settings", without the microphone-permission hint, as OpenAI voice
|
||||
already does (Sameh Khamis, @samehkhamis, #1660).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Gemini voice messages follow a language change. A Gemini Live status or
|
||||
error, such as "Gemini quota is unavailable", is shown in the new interface
|
||||
language after you switch it, instead of staying in the old one, as OpenAI
|
||||
voice messages already do (Sameh Khamis, @samehkhamis, #1661).
|
||||
@@ -0,0 +1,36 @@
|
||||
- Flights, Military Flights and AIS Vessels follow the world clock when their
|
||||
source declares a timeline, that is, can answer a snapshot at a given time
|
||||
(`source.timeline`, read by `readSnapshotTimeline` from
|
||||
`gods-eye-view/sources/live`). Away from live they show the snapshot at the
|
||||
clock's time and move on with it as it plays; back at live they poll as
|
||||
before. A source's `timeline.subscribe` may also tell of a better answer
|
||||
for a time it has answered; paused, the layer loads the time shown again.
|
||||
A source with no timeline keeps them live, unchanged (Sameh Khamis,
|
||||
@samehkhamis, #1662).
|
||||
- AIS Vessels are keyed by the stable id their live source gives (`id` on a
|
||||
vessel record), not by MMSI: vessels can share an MMSI, through spoofing,
|
||||
reused numbers or defaults such as 0, and a number can move to another
|
||||
hull. Two vessels sharing an MMSI are now two vessels, each selected,
|
||||
followed and carded on its own. The MMSI is an attribute (`mmsi`), shown
|
||||
when known and `--` otherwise, up to ten digits; looking a vessel up or
|
||||
selecting it by MMSI (an older link, say) still finds a vessel with it, and
|
||||
a vessel with no name is no longer named by its id. `vesselSnapshot` takes
|
||||
`idFor(row)` for a source whose rows carry an id of their own. Tool and voice results carry the vessel's id beside its MMSI
|
||||
(`id`, or `vesselId` where `id` is a display label), and `get_vessel_track`
|
||||
also takes an `id`. AISStream has only MMSIs, so its vessels are keyed by
|
||||
MMSI and nothing changes there (Sameh Khamis, @samehkhamis, #1662).
|
||||
- A share link made while a vessel is selected (and the camera follows
|
||||
nothing) now opens that vessel: the link names it, opens with the camera
|
||||
where it was, looking at the vessel, and selects the vessel once the feed
|
||||
has it, at the link's world time when it has one. A vessel id that is a
|
||||
source's stable id, such as a UUID, travels as its 16 bytes in a new thing
|
||||
field (13), so the link stays short (62 characters where a followed
|
||||
aircraft's is 44). MMSI and other ids, and links already shared, are
|
||||
unchanged; an app that predates the field opens such a link at the same
|
||||
camera, without selecting the vessel. A camera that no follow offset can
|
||||
carry (below the vessel's horizon, say) still makes a place view.
|
||||
A shared flight, military flight, vessel or satellite that is not reporting
|
||||
now says so in a sentence ("The shared vessel isn't reporting now — showing
|
||||
where it was at 14:05"), with its id on a second line under it (ICAO,
|
||||
NORAD, MMSI or vessel UUID) instead of at the head of the sentence (Sameh
|
||||
Khamis, @samehkhamis, #1662).
|
||||
@@ -0,0 +1,4 @@
|
||||
- A weather feed can supply decoded frames to the weather layers. With an
|
||||
optional `getFrameImage` or `getFrameTile`, Core draws the images and tiles
|
||||
the feed returns instead of reading the image and tile routes; a feed
|
||||
without them is unchanged (samehkhamis, #1665).
|
||||
@@ -0,0 +1,7 @@
|
||||
- The voice debug log now says why OpenAI Realtime did or did not get a
|
||||
viewport screenshot or pointer crop. When an entity-context answer reaches
|
||||
the image step, the log records a reason-coded decision and, when an image
|
||||
was due, one outcome under the same id: sent, or why it was dropped (a stale
|
||||
pointer, a camera move, a newer turn). What is captured, sent and kept is
|
||||
unchanged, and no image data or user text is logged (Kevin Rajan, @kvnloo,
|
||||
Manjunath, @manjunath22466, #835).
|
||||
@@ -188,6 +188,13 @@ state restoration, and release them after the consumers stop. The catalog
|
||||
constructor (`application/layers`) also requires `wind`, `weather` and
|
||||
`cyclones` sources with `getSnapshot` for the Weather layers, and owns the
|
||||
observed-history clock those layers share for the catalog's lifetime.
|
||||
A `weather` source may also supply decoded frames: an optional
|
||||
`getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or
|
||||
detail image and an optional `getFrameTile({ product, time, z, x, y, size,
|
||||
signal })` returns one tile or null for a transparent tile, each as an
|
||||
`ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested
|
||||
size, which the layer draws once and closes. Without them the layers read the
|
||||
image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`).
|
||||
|
||||
`application/services` accepts boundary, terrain, regional-context, weather and
|
||||
summary services; its weather service supplies cockpit local conditions, not the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -631,8 +634,9 @@ source-time fallback and model-owned ground policy. The renderer supplies that
|
||||
ownership fact and handles stale-ground lifting, Cesium history and primitives;
|
||||
record reconciliation does not import the engine or application.
|
||||
|
||||
`layers/vessels/records` owns plain AIS metadata, stable MMSI identity and bounded
|
||||
retention. `layers/vessels/ingestion` owns source requests and feed state through
|
||||
`layers/vessels/records` owns plain AIS metadata, identity by the stable id the
|
||||
source gives (an MMSI only for a feed that has nothing else; the MMSI itself is
|
||||
an attribute) and bounded retention. `layers/vessels/ingestion` owns source requests and feed state through
|
||||
explicit operations. Both exports exclude Cesium, DOM and application assembly.
|
||||
The snapshot renderer applies record changes; rendering owns a weak map of
|
||||
geometry and billboard resources used by cards, picking and trails.
|
||||
|
||||
+411
-57
@@ -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
|
||||
@@ -151,6 +167,49 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
|
||||
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
||||
implementations.
|
||||
|
||||
## Shipwrecks — EMODnet and Wikidata
|
||||
|
||||
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
|
||||
the Oxford Roman Economy Project; CC BY 4.0) and a Wikidata SPARQL query for
|
||||
shipwreck items with a coordinate and at least one Wikipedia article (about
|
||||
1,450 worldwide; CC0). A source that answered well is asked again after 24 hours;
|
||||
one that failed is asked again after 15 minutes. Each source keeps its own last
|
||||
good records: a source that fails, answers with nothing usable, or answers with
|
||||
less than half of its last good set keeps serving its earlier records as stale
|
||||
for up to seven days, and never removes the other source's wrecks. When the
|
||||
page cannot refresh, the wrecks it shows are marked stale, and it removes them
|
||||
once even the newest source is past seven days, counted from the ages the
|
||||
proxy reported (so the page and server clocks are never compared; a wreck
|
||||
both sources list is one record, so nothing is removed per source). EMODnet rows the portal took from
|
||||
wrecksite.eu are skipped (that site is not openly licensed). A Wikidata wreck
|
||||
with the same name within 3 km of an EMODnet wreck is merged into one record
|
||||
that keeps the article link and takes EMODnet's year, sinking text, depth and
|
||||
position accuracy. Only ship columns are read: EMODnet's long histories
|
||||
(`ship_char`, `obj_desc`) are not. Sinking, cargo and description text that
|
||||
refers to a person by a title or role (Mr, captain, master, owner, crew,
|
||||
"reported by", …) is dropped whole by a keyword filter; a bare name with no
|
||||
such word can still pass. Deaths appear only as a Wikidata count.
|
||||
|
||||
The globe draws a round marker with a hull and mast per wreck, all in one
|
||||
billboard collection, ground-clamped with the depth test off and the horizon
|
||||
cull GhostMaps uses, sized by deaths (a bigger toll draws on top) and coloured
|
||||
by era (a legend under the row): before 1900 (ancient wrecks dated only by
|
||||
period count here), 1900–1945, after 1945, and date unknown. A period filter
|
||||
under the row picks a start year and an end year side by side (two pickers
|
||||
over the years that have a wreck; a reversed pair is stored swapped); undated
|
||||
wrecks show only when every year is selected. The filter is session-only (not
|
||||
in the share link). Clicking a marker opens a `stateless` card: deaths and
|
||||
survivors first, then the sinking date or year (or the archaeological dating)
|
||||
and waters, vessel type, what happened, least depth and its quality, how well
|
||||
the position is known, period notes or the Wikidata description, cargo or
|
||||
finds, protected status and source.
|
||||
Clicking the card opens the Wikipedia article (else the Wikidata item), the
|
||||
wreck's own web page when EMODnet lists one, else EMODnet's portal. Voice does
|
||||
not toggle this layer yet (prompt budget, as for the airspace layer).
|
||||
|
||||
## Wildlife Sightings — iNaturalist and Acartia
|
||||
|
||||
**Wildlife Sightings** (`wildlife-sightings`, share-link token `9`) is off by
|
||||
@@ -751,7 +810,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
|
||||
@@ -1076,9 +1135,9 @@ Source factories have dedicated `layers/<family>/source` exports. ALPR and earth
|
||||
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
||||
|
||||
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 35 tools using separate description-only metadata; a session is offered 34 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
|
||||
|
||||
@@ -1347,6 +1406,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
|
||||
@@ -1404,6 +1487,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
|
||||
@@ -1460,6 +1589,47 @@ messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||
Sources receive cancellation signals and check them after body parsing. The
|
||||
layer's existing lifecycle and selection guards continue to reject late work.
|
||||
|
||||
### Following the world clock
|
||||
|
||||
A source may declare a timeline: it can answer a snapshot at a given time.
|
||||
`source.timeline` is `{ coverage(), stepMs, subscribe? }`, validated by
|
||||
`readSnapshotTimeline` (`src/sources/live/contract.js`): `coverage()` returns
|
||||
the span `{ start, end }` in epoch ms it can answer, or null while it does not
|
||||
know yet; `stepMs` is the spacing of its snapshots; the optional
|
||||
`subscribe(listener)` tells of a change in coverage. Flights, Military Flights
|
||||
and AIS Vessels follow the world clock only while their source declares one
|
||||
(`src/time/snapshotTimeline.js`); with none they never follow it, and every
|
||||
request, timing and display stays as it is live.
|
||||
|
||||
While such a layer is on it follows the clock and registers the source's
|
||||
coverage on the timeline. At the live edge it polls as it always has. Away
|
||||
from it the layer stops polling and asks for `getSnapshot({ ...query, atMs })`
|
||||
at the step at or before the clock's time; a request at the live edge carries
|
||||
no `atMs`. A seek, the time going backwards or leaving live is a jump (the
|
||||
clock's `getState().jumps` counts seeks and returns to live, not play): what
|
||||
loads is dropped, play holds while the snapshot loads, and the snapshot then
|
||||
stands in for every contact. A contact it lacks is gone at once; a tracked or
|
||||
selected contact it lacks is released, and one it has starts its trail afresh,
|
||||
whose track request carries `atMs` and draws only points at or before it.
|
||||
Playing, the layer stays a snapshot ahead of the time, and play does not pass a
|
||||
snapshot still loading for more than 8 seconds. Those snapshots apply as polls
|
||||
do, so contacts move on smoothly: aircraft are drawn at the clock's time
|
||||
rather than behind it, and vessels between two fixes move along the great
|
||||
circle, never past the newer one. Records, absences and the Layers panel's age
|
||||
are judged at the clock's time. Outside the coverage the layer shows no
|
||||
contacts and loads nothing; while the coverage is unknown it loads nothing and
|
||||
clears nothing. A snapshot that fails clears the time and is asked again after
|
||||
5 seconds; one the source drops for a newer request (an `AbortError`) is no
|
||||
failure, and nothing is cleared. A layer has one snapshot loading at a time:
|
||||
the older is aborted before a newer starts. Moving the view while vessels follow the clock asks for the new
|
||||
area at the same time. Back at live the contacts are cleared and the layer
|
||||
polls at once.
|
||||
|
||||
A snapshot at `atMs` describes the world as it stood then: `observedAtMs` is
|
||||
at or before `atMs`, no record is observed later than `atMs`, freshness and
|
||||
staleness are judged against `atMs`, `atMs` is echoed back, and a vessel
|
||||
snapshot reports `transportStatus: 'live'`.
|
||||
|
||||
## State and action outcomes
|
||||
|
||||
Share preferences, place lookups and Scene playback expose immutable snapshots
|
||||
@@ -1722,7 +1892,7 @@ time. TRACK ACQUIRED stays first. Then:
|
||||
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
|
||||
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
|
||||
@@ -1934,6 +2104,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
|
||||
@@ -2407,8 +2578,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,
|
||||
@@ -2788,6 +2960,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
|
||||
@@ -2862,6 +3045,13 @@ reporting are unchanged.
|
||||
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.
|
||||
FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line.
|
||||
The `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
|
||||
@@ -3149,7 +3339,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
|
||||
@@ -3457,6 +3647,42 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
|
||||
> eye level before detection sees it, so ground-backed labels only reappear
|
||||
> once the camera clears the ellipsoid. Rendered proof:
|
||||
> `scripts/qa-cockpit-plates.mjs`.
|
||||
> - **Street vehicles behind geometry lose their bracket and callout
|
||||
> (2026-11-09):** a contact a layer marks `depthTested: true` (only Street
|
||||
> Traffic does) is tested against the depth the scene drew, and is left out
|
||||
> of the overlay while a building, 3D tile, terrain or any other drawn
|
||||
> surface stands in front of it by more than a margin: 3 m, or what a 2 m
|
||||
> rise above the vehicle on its line of sight takes when the camera looks
|
||||
> down on it, and above 3 m never more than 2% of its range (2% applies
|
||||
> outright at grazing angles or when the camera looks up). A flat
|
||||
> 2% let walls, awnings and trees 10 to 20 m in front of a street 1 km away
|
||||
> pass as the road. That includes vehicles the road data routes through
|
||||
> tunnels. Every other layer
|
||||
> is untouched: aircraft, vessels and satellites draw their markers over the
|
||||
> scene on purpose and keep their labels over it. The test runs on the GPU
|
||||
> (`src/data/sceneOcclusionProbe.js`): one occlusion query per contact,
|
||||
> repeated every 100 to 200 ms, answered a frame or two later, with nothing
|
||||
> read back from the GPU and so no stall. A contact hides as soon as a
|
||||
> "hidden" answer arrives and returns 600 ms after its last one, which keeps
|
||||
> a contact on a silhouette edge from blinking; a callout whose contact goes
|
||||
> behind something is dropped at once rather than faded out on the facade.
|
||||
> A contact new to the view, or back in it after longer than a retest
|
||||
> interval, is held back until its first answer (at most 400 ms, longer
|
||||
> when a crowded view stretches the retest pace), so orbiting a city does
|
||||
> not flash a bracket for each vehicle that swings into frame behind a
|
||||
> building. A callout placed beside a visible vehicle can still overlap a
|
||||
> facade; that is where the callout was placed, not the vehicle being
|
||||
> behind it. These times run on painted frames,
|
||||
> so a hidden tab or an idle scene does not expire them.
|
||||
> Do not swap this for `scene.pickPosition` per contact or for a depth
|
||||
> read-back: both wait on the GPU process (measured 0.2 to 1.4 ms per pick,
|
||||
> and 1.5 to 25 ms per `getBufferSubData` even behind a signalled sync
|
||||
> object). With no pick depth (2D or Columbus view, depth picking off) or
|
||||
> after a failed self-check, nothing is hidden and
|
||||
> `getDetectionDiagnostics().surfaceDepthMode` reads `off`; a WebGL 1
|
||||
> context reads depth back synchronously about 8 times a second and reads
|
||||
> `sync`. The renderer internals this relies on are listed, with the engine
|
||||
> version they were checked against, in `readSceneDepthSource`.
|
||||
> - **Required attribution has two named keep-out rules (2026-08-20):** the
|
||||
> Google/Cesium credit line must stay visible in every state, and below 900px
|
||||
> two surfaces used to paint over it — the command dock's popover tray (any
|
||||
@@ -4200,8 +4426,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.
|
||||
@@ -4590,7 +4819,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**, **sv**, **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).
|
||||
|
||||
@@ -4604,19 +4835,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
|
||||
@@ -4665,35 +4898,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 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) |
|
||||
| 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;
|
||||
@@ -5023,6 +5258,111 @@ core's lookups alone. The MCP globe panel allows the meta-catalogue and the
|
||||
three biggest instances; a picture on another instance shows only in the
|
||||
app.
|
||||
|
||||
**"Show me X on Street View."** `show_street_level` (voice and typed;
|
||||
`src/voice/gevActions.js`) is one call whose stages fail separately
|
||||
(`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`):
|
||||
|
||||
- **Key first.** A keyless source explains itself and changes nothing, as a
|
||||
chip does.
|
||||
- **Place.** A name, coordinates, a pointed spot or a numbered item: it flies
|
||||
there at street framing and waits for arrival. With no place it looks at the
|
||||
ground under the screen centre, as OPEN does (`aim: 'screen-centre'`,
|
||||
`armed: false`: sky refuses, and a link's armed point is not used and is kept
|
||||
until a photo opens). Coordinates off the globe are refused.
|
||||
- **Map and source.** It switches to the map the source needs (Google Street
|
||||
View: Google 3D), lights the source through `setLayerParams`, one at a time
|
||||
and kept like a chip click (origin `voice`; a link runs the action with
|
||||
origin `programmatic`, so it never writes the recipient's saved layer state),
|
||||
turns the layer on and calls `openNearest`. `provider` is `google` for
|
||||
"Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit
|
||||
source.
|
||||
- **Hidden panel.** Cockpit, clean view, recording or scene playback end it at
|
||||
`view`, before the flight and after every await; embed mode's own clean view
|
||||
is exempt, since the photo is lifted over the globe.
|
||||
- **Superseded.** After every await (flight, map switch, layer on, lookup) a
|
||||
newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where
|
||||
its own flight lands) or a press or wheel on the globe stops it
|
||||
(`superseded: true`). The same checks run before a found photo loads
|
||||
(`proceed`), so nothing billed loads for a request that was given up. A
|
||||
lookup given up leaves the panel as it was when the request kept the photo's
|
||||
source; one that had switched the source off closes that source's photo with
|
||||
it. A photo that opened after either is closed again.
|
||||
- **Replacing a photo.** An open photo stays up while the new request looks
|
||||
(`openNearest` `replace: true`; the viewer is replaced, not closed, so EXPAND
|
||||
stays, also across sources through `holdViewer`, which holds only the action's
|
||||
own switch-off of the photo's source: a source switched by hand meanwhile
|
||||
still stops the load) and closes if nothing opens, so a place with no imagery
|
||||
shows its own error; a link that only arms closes it at once. A replacement
|
||||
from another source drops the old photo's details, and the request's error or
|
||||
armed OPEN outlives the old viewer. A replacement stops a playing Mapillary
|
||||
sequence.
|
||||
- **Outcome.** `noImagery` marks an empty result, `imageryError` a source or
|
||||
viewer failure and `aim: 'sky'` a sky centre (from `street.nearestOutcome`);
|
||||
an empty result names a narrowing 360°/flat or SINCE filter the source
|
||||
honours, as `filter`. Every photo the action opens reveals the panel, also
|
||||
one the user had collapsed.
|
||||
- **Search.** A source without a coverage layer searches up to 250 m
|
||||
(`NEAREST_RADIUS_MAX_M`; Google honours it); clicks keep 50 m. Mapillary
|
||||
asks for 100 photos within 50 m, its API's most (the API returns them
|
||||
unordered and cuts at the limit), and gives up after 12 s, body included
|
||||
(`NEAREST_TIMEOUT_MS`).
|
||||
- **Phone.** The panel sits in the Cameras tab sheet: a photo that opens, or a
|
||||
link that arms, opens the sheet and scrolls the panel into view
|
||||
(`revealPanel`, which does nothing above 720px).
|
||||
|
||||
The layer also exposes `requiredMapStack(id)` and `providerStatus(id)` for
|
||||
callers that decide before the panel lists a source, and `holdViewer()`. A
|
||||
caller's abort is honoured through the viewer's mount and
|
||||
load, and only the lookup that still owns the panel changes its spinner or
|
||||
error.
|
||||
|
||||
Views carry the request as `street_level` (`src/view/index.js`): a source and
|
||||
a point, which defaults to where the camera looks (below 20 km, at a pitch
|
||||
steeper than -5° and within 10 km ahead; else the camera's own point); half a
|
||||
point is not mixed with the camera's: both coordinates come from that default. A `google` source brings `map: photoreal` and refuses
|
||||
another map; `street_level` does not combine with `follow`.
|
||||
`show_in_gods_eye_view`, and the embedded app for a posted view with no
|
||||
point, measure the view's altitude from the terrain under the camera
|
||||
(`groundedLookAt` with the terrain heights service) before taking the look-at
|
||||
point, and keep the view's own estimate without a terrain answer (the embedded
|
||||
app waits 2 s for one). Links carry
|
||||
`sl=<source>,<lat>,<lon>`; the globe panel applies views through the action,
|
||||
and a plain link runs it once restored (one shot: the rewritten hash drops
|
||||
`sl`). A link that follows an entity ignores `sl`.
|
||||
|
||||
- **Billing gate.** A plain link never loads a photo from a source that bills
|
||||
every load (`billedPerImage`; Google): the action runs with
|
||||
`autoOpen: false` and arms the layer (`armNearest`: the link's point and
|
||||
radius, for the next OPEN press only, and only while the ground under the
|
||||
screen centre is within 1 km of it (`ARMED_REACH_M`) or cannot be picked; a
|
||||
sky centre refuses; any other lookup, open, close, disable, the source
|
||||
switched off or its map leaving drops it), opens the panel and shows a toast
|
||||
pointing at OPEN GOOGLE STREET VIEW. A link whose source has no working
|
||||
key here changes nothing and says so in a toast (not in a frame).
|
||||
- **Who may open.** Posted views, and the panel page's own link (inline embed)
|
||||
for a tool result issued in the last 5 minutes, open the photo:
|
||||
`show_in_gods_eye_view` stamps a street-level result `issued_at`, every
|
||||
`gev:view` the panel posts carries `fresh`, and the inline app applies a
|
||||
view without it with `autoOpen: false` (a framing page's posted views still
|
||||
open). `?embed=1` on a published link only arms, without the panel nudge or
|
||||
toast.
|
||||
- **Who owns the camera.** A link's photo step is skipped when a newer link
|
||||
superseded its restore, when its restore flight never landed
|
||||
(`share.camera !== 'applied'`: the visitor dragged or searched), or after
|
||||
any navigation since the generation that flight owned (`navigationToken` on
|
||||
the settled result), also while its layers still load. A link pasted into
|
||||
the open tab runs its step after its restore settles
|
||||
(`gev:share-navigation-restore-settled`).
|
||||
- **Embed.** The rail is hidden, so an open photo moves to an overlay on the
|
||||
globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in
|
||||
full screen, Esc leaves full screen first). EXPAND stays under embed mode's
|
||||
clean view; cockpit, recording and scene playback hide the overlay. Loading
|
||||
errors render inside the overlay, and inside the viewer while EXPAND is on.
|
||||
A posted view whose photo step fails before the imagery stage closes the
|
||||
earlier photo before its camera flies; one the user overtook (a click on the
|
||||
map, a newer request) leaves their photo and camera alone; one that found a
|
||||
photo it could not load keeps that error.
|
||||
|
||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||
@@ -5201,8 +5541,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
|
||||
@@ -5679,7 +6022,7 @@ 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**: 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.
|
||||
@@ -5816,7 +6159,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.
|
||||
|
||||
@@ -6073,9 +6416,9 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
|
||||
`GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC:
|
||||
|
||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token.
|
||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
|
||||
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
|
||||
- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
|
||||
- **Thirty-five tools** (thirty-four in a session without `OVERPASS_UPSTREAMS`, which adds `osm_query`; argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `resolve_area`, `find_imagery`, `osm_query`, `next_iss_pass`, and `next_satellite_pass`.
|
||||
|
||||
> **Reading `npm test` totals:** the count depends on the Node major. The two
|
||||
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
|
||||
@@ -6113,6 +6456,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
- **Reliability**: tool-call dedupe (2.5s window across call/item/args keys); response-create queueing that respects active responses and defers follow-ups when the user starts speaking; per-tool follow-up instructions so the agent confirms only what actually happened (zoom confirms only on `ok=true`).
|
||||
- **Aircraft identity honesty:** “What is this aircraft?” reads callsign, operator, registration, type, and route only from the selected contact context. Missing operator, route, or type enrichment is named explicitly rather than silently omitted or inferred from the callsign.
|
||||
- **Diagnostics**: every client/server event is posted to `/api/realtime/debug-log` and appended to `.gev-logs/realtime-conversations.jsonl` (gitignored) with secret/image redaction; last 30 errors persist in `localStorage` (`gev-realtime-errors`); `window.__gevVoiceCommands.getDiagnostics()` in the console.
|
||||
- **Visual-context escalation** (OpenAI Realtime): when a response's last tool result is a `get_entity_context` answer, a tool output from that response was sent and the data channel is open, it logs one `viewport_context.decision` with `contextId`, `send` and `reason`, plus `viewScale` and, when `send` is true, `kind` (`viewport` or `crop`). Decision reasons: `structured-identity-sufficient` (structured context made an image unnecessary), `view-scale-not-local`, `structured-identity-missing` (requests a viewport), `pointer-crop-eligible` (a pointer answer with no identity; requests a crop, and the crop checks below decide whether one is sent), `pointer-identity-present` and `pointer-missing`. A send decision is followed by exactly one outcome with the same `contextId` and `kind`: `viewport_context.result` (`sent`, `data-channel-send-failed`), logged as soon as the send returns, or `viewport_context.skipped`. Pointer-crop skips are `pointer-not-fresh` (no snapshot, or a stale one), `pointer-not-ground`, `pointer-off-screen`, `pointer-scale-not-useful` (zoomed out past `local` or `city`), `pointer-already-requested` (the most recently requested snapshot again: already sent, still being captured, or an earlier attempt for it threw), `pointer-expired`, `pointer-pose-unknown` and `pose-changed`; dropped captures are `capture-unavailable`, `session-reset`, `superseded` (a newer request or turn), `channel-lost` and `frame-stale`; a step that throws before an outcome closes the pair with `error`, and the caller then logs `viewport_context.failed`. Direct calls to the crop or image senders log their skips with a null `contextId`. Decision and outcome are separate records, so the debug-log rate limit can drop one half of a pair. Payloads are static codes only, never image data, tool payloads or user text, and other tool results log nothing here. Gemini Live uses a different gate in `geminiSession.js` (no pointer crop, a 1 s throttle), which is not instrumented.
|
||||
- **Counting semantics ("near")** — a CONTRACT; new count-bearing tool work inherits it. Three honest numbers exist for one question: the Contacts cohort (250 km around the subject, what the panel shows), `analyst_query`'s count of _currently-loaded_ records for the requested scope, and the layer-wide loaded total in `coverage.layersQueried[].total`. They diverge legitimately — the flights layer loads by viewport, so after a camera dive the loaded set can hold a fraction of the cohort (field case: panel 42, analyst 8). The contract:
|
||||
1. **Contacts ACTIVE** → "near / nearby / how many aircraft" means the **Contacts window** — the panel's numbers, spoken verbatim. Mechanism: `contactsWindow` (`{centeredOn, radiusKm, flights, military, vessels, complete}`), carried by both `analyst_query` and `get_current_view_state`, derived by `contactsWindowFromSnapshot()` from the same snapshot the panel renders so the two cannot drift. Voice filtering and follow-up memory use that immutable snapshot too. A cohort whose feed cannot answer reports `'unknown'`, never a confident zero; a cohort at the 20,000-row retention bound reports a lower bound.
|
||||
2. **Contacts OFF** → "nearby" means **in view**; "near \<place\>" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera.
|
||||
@@ -6328,6 +6672,7 @@ 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, 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.
|
||||
@@ -6344,6 +6689,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.
|
||||
@@ -6601,11 +6955,11 @@ apply to `public/` assets or built-preview serving.
|
||||
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. 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, leaving bothLine truncated
|
||||
- **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 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 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, 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.
|
||||
|
||||
+44
-1
@@ -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
|
||||
|
||||
+51
-14
@@ -28,7 +28,11 @@ Following an aircraft is 42–50: 42 when the link is made from the app's own
|
||||
follow camera with the fix taken as the link is made, 44 with a fix four
|
||||
seconds older, and 50 after orbiting to another heading and pitch. A view in
|
||||
any other style that keeps the style's own look adds only the style (two
|
||||
bytes): the 42-character followed aircraft in Noir is 44.
|
||||
bytes): the 42-character followed aircraft in Noir is 44. A selected vessel
|
||||
named by its MMSI costs what an aircraft does (46 characters for the
|
||||
44-character aircraft above); one named by a UUID costs 13 bytes more, as its
|
||||
16 bytes travel raw (62 characters where the aircraft is 44, rather than about
|
||||
88 with the UUID as text).
|
||||
|
||||
## Opening a short link
|
||||
|
||||
@@ -47,9 +51,32 @@ best effort: until the entity is found, and if it is not found in time, the
|
||||
camera is where the link was made, looking at the entity's last-known fix,
|
||||
and the app says that the entity isn't reporting and when the fix was taken.
|
||||
The share button makes a thing view while the camera follows an aircraft,
|
||||
military aircraft or satellite. A selected vessel is not followed (the camera
|
||||
stays where the user puts it), so a link made then is a place view; vessel
|
||||
thing views open, but the app does not make them yet.
|
||||
military aircraft or satellite. While the camera follows nothing and a vessel
|
||||
is selected, it makes a vessel thing view: the fix is the vessel's position
|
||||
as drawn (as of the world time while the vessel layer follows the world
|
||||
clock, else as of its latest position report), and the offset is the one whose derived camera is
|
||||
where the user's camera is (`followOffsetFor` in `src/share/follow.js`, the
|
||||
inverse of `followCameraFor`, taken from the fix at the format's precision).
|
||||
The opened camera is then within the offset's whole metres and degrees of
|
||||
where the link was made, looking at the vessel. When no offset can carry the
|
||||
camera, because it is not above the vessel's local horizontal (it would look
|
||||
up at the vessel, or through the earth) or its range is outside 1 m to
|
||||
10⁸ m, the link is a place view as before. A vessel is selected, not
|
||||
followed, so the camera stays where the user puts it. Following an entity
|
||||
takes priority over a selected vessel, and a followed entity the format
|
||||
cannot name still makes a place view. Opening a vessel thing view with a
|
||||
world time waits for the vessel layer's snapshot at that time and selects the
|
||||
vessel once it has it.
|
||||
|
||||
## Street-level photos (`sl`)
|
||||
|
||||
Views built by `show_in_gods_eye_view` or `src/view/index.js` can add
|
||||
`sl=<source>,<lat>,<lon>` to the long form. Once the link has restored, the
|
||||
app opens the nearest photo there, or, for a source that bills every load,
|
||||
arms the panel so OPEN loads it (see `docs/TOOLS.md`). The app's own links do
|
||||
not carry it: the address bar's long form drops `sl` when the camera next
|
||||
moves, and the short form has no field for it. A reload or the app's Copy
|
||||
Link keeps the lit source, the layer and the map, but not the photo request.
|
||||
|
||||
## Payload
|
||||
|
||||
@@ -274,14 +301,23 @@ sub-message:
|
||||
| 10 | fix alt | sint metres | 0 |
|
||||
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
|
||||
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
|
||||
| 13 | vessel id | UUID, 16 bytes (LEN) | |
|
||||
|
||||
The entity kind is the field that carries the id (its code in
|
||||
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
|
||||
present (two is malformed). Entity ids are the ones the layers look up: an
|
||||
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
|
||||
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
|
||||
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
|
||||
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
|
||||
`SHARE_VIEW_ENTITY_KINDS`, or field 13 for a vessel), so it costs nothing;
|
||||
exactly one of fields 1–4 and 13 is present (two is malformed). Entity ids are
|
||||
the ones the layers look up: an aircraft's ICAO 24-bit address (six hex
|
||||
digits, stored as a number; other forms such as TIS-B `~abc123` as text), a
|
||||
vessel's id (its MMSI, or a source's stable id, such as a UUID) and a
|
||||
satellite's NORAD catalog number (decimal numbers; an MMSI with a leading
|
||||
zero stays text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`, except a
|
||||
vessel id that is a UUID (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, lowercase
|
||||
hex): it is written as its 16 bytes in field 13 rather than as 36 characters
|
||||
of text in field 3, after the other thing fields, and decodes as the
|
||||
lowercase dashed UUID. Field 13 of any other length is malformed. A decoder
|
||||
that predates field 13 skips it, finds no entity and opens the view as a
|
||||
place at its derived camera (`unknown-entity-kind`), as for a new entity kind,
|
||||
so the field needed no new version.
|
||||
|
||||
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
|
||||
the entity from `range` metres away, heading clockwise from north, in the
|
||||
@@ -319,8 +355,9 @@ bytes, a check byte that does not match (`checksum`), an unknown version byte,
|
||||
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
|
||||
overlong varint, a LEN longer than what remains (at any level, including
|
||||
inside the thing sub-message), a reserved wire type, field number 0, a known
|
||||
field with the wrong wire type or repeated, two entity ids in one thing, a
|
||||
duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||
field with the wrong wire type or repeated, two entity ids in one thing
|
||||
(field 13 counts as one), a UUID vessel id that is not 16 bytes
|
||||
(`bad-length`), a duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||
longitude (camera or fix) beyond ±90/±180.
|
||||
|
||||
Tolerated and reported in `unknownFields`: unknown fields at any level
|
||||
@@ -358,8 +395,8 @@ the app that can open it.
|
||||
boolean takes the next flag bit instead, which costs nothing when it is at
|
||||
its default.
|
||||
6. **New view kinds and entity kinds** get the next code. A new entity kind's
|
||||
id takes the next free thing field (13 onwards), so older apps skip it and
|
||||
fall back to the camera. A thing that needs more than an id (for example a
|
||||
id takes the next free thing field (14 onwards; 13 is a vessel id that is
|
||||
a UUID), so older apps skip it and fall back to the camera. A thing that needs more than an id (for example a
|
||||
CCTV camera, a radio station or a transit vehicle) adds further thing
|
||||
fields the same way.
|
||||
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
|
||||
|
||||
+24
-6
@@ -102,11 +102,29 @@ action draws (pins, highlights, areas, arrows, routes and labels at a place
|
||||
name or coordinates). Links carry them in the `an` parameter, and the app
|
||||
draws them once the link has been restored.
|
||||
|
||||
A view can open street-level photos too: `street_level` names a source
|
||||
(`google` for Street View, `mapillary`, `kartaview`, `panoramax`, or none for
|
||||
the lit one) and a point, by default the spot the camera looks at. Street View
|
||||
shows only on the Google 3D map, so a `google` source sets `map` to
|
||||
`photoreal` and refuses another; a view cannot both follow an entity and open
|
||||
photos. Links carry it as `sl=<source>,<lat>,<lon>`: once restored, the app
|
||||
runs `show_street_level`, which flies there and opens the nearest photo. A
|
||||
plain link with a `google` source only arms the panel for OPEN GOOGLE STREET
|
||||
VIEW; a view posted into the embedded app opens it, and so does the panel
|
||||
page's own opening link, but only for a tool result issued in the last five
|
||||
minutes (an older one, such as a host remounting the panel, only arms);
|
||||
`?embed=1` on a published link does not. Mapillary,
|
||||
KartaView and Panoramax, which do not bill per image, open at once. A link
|
||||
that follows an entity ignores `sl`.
|
||||
|
||||
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
|
||||
`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
|
||||
tilted camera over an area looks at its center from behind; camera fields
|
||||
given with an area override its framing. `resolveViewArguments` turns them
|
||||
into a view.
|
||||
`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and
|
||||
`street_level`. A tilted camera over an area looks at its center from behind;
|
||||
camera fields given with an area override its framing. A passed-through
|
||||
view's `street_level` point moves with a new `area` or camera position (a
|
||||
turned camera keeps it). `resolveViewArguments` turns them into a view. The
|
||||
app's own links (the address bar, Copy Link, `/share/<code>`) do not carry
|
||||
`sl`; see `docs/SHARE-LINK-FORMAT.md`.
|
||||
|
||||
### The God's Eye View panel
|
||||
|
||||
@@ -270,7 +288,7 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship by vessel id or MMSI, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
|
||||
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
||||
@@ -299,5 +317,5 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
|
||||
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
|
||||
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
|
||||
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
|
||||
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks, something to follow, or street-level photos (Street View or Mapillary) at a place |
|
||||
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
|
||||
+5
-4
@@ -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`), **Swedish** (`sv`), **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), [Swedish](./translations/sv.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.
|
||||
@@ -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, בלי שינוי |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Swedish (`sv`)
|
||||
|
||||
Catalog: `src/i18n/locales/sv.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||
|
||||
Translation source: [God's Eye View Sverige](https://github.com/Kajjan7610/Gods-view-sweden) by Kaj Obst, November 2026.
|
||||
|
||||
## Style
|
||||
|
||||
- Standard Swedish as in Swedish software and on Swedish authority sites (SMHI, Trafikverket, Polisen).
|
||||
- Actions and hints use the imperative without a pronoun: «Försök igen», «Klicka på en kamera för att aktivera den», «Välj en tagning först», «Tillåt mikrofonen och försök igen». Avoid «du»/«ni»; "your" becomes «egen» or is left out: «Egen RTL-SDR-mottagare», «Stationen ser din IP-adress» only where a person must be addressed.
|
||||
- Sentence case, not Title Case: "Live Flights" → «Liveflyg», "Provider Settings" → «Leverantörsinställningar». UPPERCASE English stays UPPERCASE: «LIVEDATA AV», NOT AN ALL-CLEAR → «INTE FARA ÖVER».
|
||||
- Compounds are written as one word, with a hyphen after a name, code or abbreviation: «datalager», «väderrutor», «TomTom-nyckel», «AIS-fartyg», «Street View-reserv». Never split a compound with a space.
|
||||
- Short labels, chips and HUD readouts stay short; use a standard abbreviation with a period over a wrapped line: RÄCKV., HAST., NEDERB., ÖVR., MEDD., FÖRST., KAL. «Jämför» is JFR, without a period.
|
||||
- The avionics codes HDG, BRG, CRS, DEST, SPD, ALT, GS, TRK, V/S, FOV and SQUAWK stay in English, as in the cockpit. Words in a sentence are translated: heading → «kurs», bearing → «bäring», range → «räckvidd».
|
||||
- A dash between clauses is a spaced en dash « – ». Quotes are ”…” (Swedish closing quotes on both sides).
|
||||
- Status and waiting messages: «Väntar på uppdatering», «Laddar …». Reasons after a dash stay lower case: «Scenen sparades inte – projektet är större än 5 MiB».
|
||||
- A layer named in a sentence takes its panel name in quotes: «Lagret ”Liveflyg” är inte aktiverat».
|
||||
- Rolling windows say their length: LAST YEAR → «SENASTE 12 MÅN.», LAST MONTH → «SENASTE 30 DAGARNA».
|
||||
- Keyboard keys: Mellanslag, Backsteg, Skift; Esc, Enter, Tab and letter keys stay.
|
||||
- Decimal comma in fixed text: «M4,4», «28,2710° N». Thousands separator is a space: «1 000 km». Formatted numbers come from `Intl` and are already Swedish.
|
||||
- Units: km, m, km/h, m/s, kn (knop), ft (fot), hPa, MW, h, min, s, d (dygn, in short readouts); in UPPERCASE text KM, KM/H, KN, FT, MIN, H, D. A space between number and unit, also before %: «{value} m», «{count} min», «50 %». `°` sticks to the number.
|
||||
- "ago": «för {count} min sedan», short «{count} min sedan». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «sedan» again.
|
||||
- Compass points use O for öst and V for väst, as SMHI does: N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSV, SV, VSV, V, VNV, NV, NNV; hemisphere letters N, S, O, V.
|
||||
- Routing: «Sväng {direction} in på {road}», «Fortsätt {direction}», «Ta {exit} avfarten» with ordinals «2:a», «3:e».
|
||||
|
||||
## Plurals
|
||||
|
||||
A `tn()` entry needs two forms. `Intl.PluralRules('sv')` picks:
|
||||
|
||||
| Form | Counts | Example |
|
||||
| ------- | --------------------------- | ----------- |
|
||||
| `one` | 1 | 1 kamera |
|
||||
| `other` | everything else: 0, 2, 1,5… | 3 kameror |
|
||||
|
||||
Abbreviated units and codes do not change: «{count} MEDD.».
|
||||
|
||||
## Glossary
|
||||
|
||||
| English | Svenska |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------- |
|
||||
| layer / data layer | lager / datalager |
|
||||
| feed / source / data source | flöde / källa / datakälla |
|
||||
| LIVE (status) / live data | LIVE / livedata |
|
||||
| SIMULATED | SIMULERAT |
|
||||
| stale / degraded / unavailable | inaktuell / begränsad / otillgänglig |
|
||||
| retry / retrying / retrying slowly | nytt försök / försöker igen / försöker igen med längre intervall |
|
||||
| fallback | reserv |
|
||||
| key (API key) / POWER UP / POWERED UP | nyckel / KOPPLA PÅ / PÅKOPPLAD |
|
||||
| Provider Settings | Leverantörsinställningar |
|
||||
| ON / OFF (state chips) | PÅ / AV |
|
||||
| enable / disable | aktivera / inaktivera |
|
||||
| globe / map / map source / basemap | glob / karta / kartkälla / bakgrundskarta |
|
||||
| aircraft / flight / military flights | luftfartyg (short labels: flygplan) / flygning / militärflyg |
|
||||
| vessel, ship | fartyg |
|
||||
| satellite / shell (Starlink) | satellit / skal |
|
||||
| launch (rocket) / launch site / space mission | uppskjutning / uppskjutningsplats / rymduppdrag |
|
||||
| contact / tracking | kontakt / följning |
|
||||
| installation / site | anläggning / plats |
|
||||
| camera / CCTV camera | kamera / CCTV-kamera |
|
||||
| earthquake / active fire / fire perimeter | jordbävning / aktiv brand / brandgräns |
|
||||
| cyclone / advisory (NHC) / cone | cyklon / bulletin / kon |
|
||||
| radio station / broadcast FM | radiostation / FM-radio |
|
||||
| road / traffic flow | väg / trafikflöde |
|
||||
| transit / bike share | kollektivtrafik / hyrcyklar |
|
||||
| scene / shot / capture shot / camera move | scen / tagning / spara tagning / kameraåkning |
|
||||
| anchor / data pack / bundle | ankare / datapaket / paket |
|
||||
| briefing / panel / dock | genomgång / panel / docka |
|
||||
| preset / visual preset | förval / visuellt förval |
|
||||
| Scope (circular viewport mask) / keyhole | Scope / nyckelhål |
|
||||
| overlay / detection overlay | överlägg / detekteringsöverlägg |
|
||||
| Clean UI | Rent gränssnitt |
|
||||
| Night vision / Thermal / Snow (styles) | Mörkerseende / Värmekamera / Snö |
|
||||
| heading / altitude / ground speed | kurs / höjd / markfart |
|
||||
| RANGE / PITCH / HGT (CCTV calibration chips) | RÄCKV. / LUTN. / HÖJD |
|
||||
| PREV / NEXT / RESUME | FÖRRA / NÄSTA / FORTSÄTT |
|
||||
| nearest (spatial) | närmaste |
|
||||
| expand / collapse | fäll ut / fäll ihop |
|
||||
| Street Level / Street View (Google) | Gatunivå / Street View |
|
||||
| Recent Imagery | Senaste bilder |
|
||||
| voice (control) / voice session | röststyrning / röstsession |
|
||||
| click / tap / press a key | klicka / tryck / tryck på tangenten |
|
||||
| card / label (on the map) / clear (layers) | kort / etikett / stäng av |
|
||||
| frame (weather, video) / image | bildruta / bild |
|
||||
| incident | händelse |
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<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
|
||||
|
||||
@@ -99,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"
|
||||
},
|
||||
@@ -137,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"
|
||||
},
|
||||
@@ -260,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",
|
||||
|
||||
@@ -181,6 +181,18 @@ export function answerMapillaryRequest({ method, url, images }) {
|
||||
: [];
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('lat') && q.has('lng')) {
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
||||
const limit = Number(q.get('limit')) || 10;
|
||||
// In no particular order, as the real API answers.
|
||||
const data = photos
|
||||
.filter((image) => metresApart(at, image) <= radius)
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((image) => withFields(imageRecord(image), fields));
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||
return {
|
||||
...json(400, { error: 'unsupported images query' }),
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/voice/realtimeViewportPolicy.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"src/app/sources.js",
|
||||
@@ -1077,6 +1078,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",
|
||||
@@ -1229,6 +1231,7 @@
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/sources/phaseTiming.test.mjs",
|
||||
"scripts/build-census-places.mjs",
|
||||
@@ -1283,6 +1286,13 @@
|
||||
"src/voice/panelLayout.js",
|
||||
"src/voice/textChat.js",
|
||||
"src/voice/textProtocol.js",
|
||||
"src/layers/shipwrecks/records.test.mjs",
|
||||
"src/layers/shipwrecks/cards.test.mjs",
|
||||
"src/layers/shipwrecks/eras.test.mjs",
|
||||
"src/layers/shipwrecks/period.test.mjs",
|
||||
"src/layers/shipwrecks/index.test.mjs",
|
||||
"src/layers/shipwrecks/source.test.mjs",
|
||||
"src/data/shipwrecksProxy.test.mjs",
|
||||
"src/app/sourceComposition.js",
|
||||
"src/app/sourceComposition.test.mjs",
|
||||
"src/data/dataCredits.js",
|
||||
@@ -1293,10 +1303,12 @@
|
||||
"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",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/sv.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 });
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -46,6 +46,15 @@ const VIEWPORT = { width: 1440, height: 900 };
|
||||
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
||||
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
|
||||
const DAY_MS = 86_400_000;
|
||||
/** A Sacramento street with Street View, for the sl=google link step. */
|
||||
const STREET_VIEW_STREET = Object.freeze({ lon: -121.49372, lat: 38.57986 });
|
||||
/**
|
||||
* What a Street View lookup and load cost: the Maps JS API's
|
||||
* SingleImageSearch (the nearest lookup), GetMetadata and the panorama tiles.
|
||||
* Script loads are not counted.
|
||||
*/
|
||||
const STREET_VIEW_REQUEST =
|
||||
/MapsJsInternalService\/(SingleImageSearch|GetMetadata)|GeoPhotoService|streetviewpixels-pa\.googleapis\.com/;
|
||||
|
||||
/**
|
||||
* Coverage for any street-zoom tile: a grid of 360°/flat, recent/old
|
||||
@@ -234,6 +243,28 @@ async function boot(page, url) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a share link as a fresh document. Changing only the hash of a live
|
||||
* page and reloading races the app's share-link writer, which can put its own
|
||||
* state back in the hash before the reload reads it. The page's fixtures (a
|
||||
* CDP session on the page) carry over.
|
||||
*/
|
||||
async function bootLink(page, link) {
|
||||
await page.goto('about:blank');
|
||||
await boot(page, link);
|
||||
}
|
||||
|
||||
/** The camera's position in degrees and metres. */
|
||||
const cameraAt = (page) =>
|
||||
page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return {
|
||||
lon: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
height: c.height,
|
||||
};
|
||||
});
|
||||
|
||||
/** Run `test(uiState, arg)` in the page until it is true. */
|
||||
const uiUntil = (page, test, arg = null, timeout = 30_000) =>
|
||||
page.waitForFunction(
|
||||
@@ -780,6 +811,219 @@ async function main() {
|
||||
await uiUntil(page, litAlone, 'mapillary');
|
||||
await park(page);
|
||||
|
||||
const base = url.split('#')[0];
|
||||
await fixtureStep(
|
||||
'a link to street-level photos (sl=) opens the photo at its point once the link restores',
|
||||
async () => {
|
||||
const photo = fixture.images[0];
|
||||
// The camera sits ~330 m north of the photo: the lookup must use
|
||||
// the link's point, not the view centre.
|
||||
const cameraLat = Number((photo.lat + 0.003).toFixed(6));
|
||||
await bootLink(
|
||||
page,
|
||||
`${base}#v=2&lat=${cameraLat}&lon=${photo.lon}&alt=600&heading=0&pitch=-60&roll=0&sl=mapillary,${photo.lat},${photo.lon}`,
|
||||
);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const { street } = await getUI(page);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
assert.equal(street.providerId, 'mapillary');
|
||||
assert.equal(
|
||||
street.imageId,
|
||||
photo.id,
|
||||
`the photo at the link's point, not the one nearest the camera: ${street.imageId}`,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => location.hash.includes('sl=')),
|
||||
false,
|
||||
'the rewritten link no longer carries the one-shot request',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await fixtureStep(
|
||||
'the show_street_level action opens the nearest photo at a point without a pointer',
|
||||
async () => {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.closeViewer(),
|
||||
);
|
||||
const photo = fixture.images[0];
|
||||
const result = await page.evaluate(
|
||||
(at) =>
|
||||
window.__godsEyeView.voiceCommands.runner('show_street_level', {
|
||||
latitude: at.lat,
|
||||
longitude: at.lon,
|
||||
provider: 'mapillary',
|
||||
fly: false,
|
||||
}),
|
||||
{ lat: photo.lat, lon: photo.lon },
|
||||
);
|
||||
assert.equal(
|
||||
result.ok,
|
||||
true,
|
||||
`action failed: ${result.stage} ${result.error}`,
|
||||
);
|
||||
assert.equal(result.provider, 'mapillary');
|
||||
assert.ok(
|
||||
fixture.images.some((image) => image.id === result.imageId),
|
||||
`a fixture photo opened: ${result.imageId}`,
|
||||
);
|
||||
assert.match(result.say || '', /^Mapillary at /);
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'an sl=google link never loads a photo by itself: keyless it stops at the key, keyed it arms and waits for OPEN',
|
||||
async ({ skipped }) => {
|
||||
const point = STREET_VIEW_STREET;
|
||||
// ~330 m north of the street: a flight would move the camera, and a
|
||||
// press that ignored the armed point would open something else.
|
||||
const cameraLat = Number((point.lat + 0.003).toFixed(6));
|
||||
const googleRequests = [];
|
||||
const onRequest = (request) => {
|
||||
if (STREET_VIEW_REQUEST.test(request.url()))
|
||||
googleRequests.push(request.url());
|
||||
};
|
||||
page.on('request', onRequest);
|
||||
try {
|
||||
await bootLink(
|
||||
page,
|
||||
`${base}#v=2&lat=${cameraLat}&lon=${point.lon}&alt=600&heading=0&pitch=-60&roll=0&map=osm&sl=google,${point.lat},${point.lon}`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(want) => {
|
||||
const c =
|
||||
window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return Math.abs((c.latitude * 180) / Math.PI - want) < 0.0005;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
cameraLat,
|
||||
);
|
||||
const googleKey = await page.evaluate(() =>
|
||||
Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||
);
|
||||
if (!googleKey) {
|
||||
// Without a key the action stops at the key, before any flight,
|
||||
// map switch or source change, and the direct call says why.
|
||||
const direct = await page.evaluate(
|
||||
(at) =>
|
||||
window.__godsEyeView.voiceCommands.runner(
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: at.lat,
|
||||
longitude: at.lon,
|
||||
provider: 'google',
|
||||
fly: false,
|
||||
autoOpen: false,
|
||||
},
|
||||
),
|
||||
point,
|
||||
);
|
||||
assert.equal(direct.ok, false);
|
||||
assert.equal(
|
||||
direct.stage,
|
||||
'key',
|
||||
`${direct.stage} ${direct.error}`,
|
||||
);
|
||||
const after = await page.evaluate(() => {
|
||||
const m =
|
||||
window.__godsEyeView.dataManager.layers.get(
|
||||
'street-level',
|
||||
).module;
|
||||
const u = m.getUIState();
|
||||
return {
|
||||
stack: window.__godsEyeView.mapStackController.getActiveId(),
|
||||
google: m.getParams().google === true,
|
||||
armed: u.street.armed,
|
||||
open: u.street.open,
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
after,
|
||||
{ stack: 'osm', google: false, armed: null, open: false },
|
||||
'a keyless Street View link changes nothing',
|
||||
);
|
||||
const still = await cameraAt(page);
|
||||
assert.ok(
|
||||
Math.abs(still.lat - cameraLat) < 0.0005,
|
||||
`the camera did not fly: ${still.lat}`,
|
||||
);
|
||||
assert.deepEqual(googleRequests, [], 'no Street View request');
|
||||
return;
|
||||
}
|
||||
const photoreal = await page.evaluate(() =>
|
||||
window.__godsEyeView.mapStackController.isStackAvailable(
|
||||
'photoreal',
|
||||
),
|
||||
);
|
||||
if (!photoreal) return skipped('no Google 3D');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) =>
|
||||
u.street.armed !== null &&
|
||||
u.providers.some((p) => p.id === 'google' && p.on),
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const beforePress = googleRequests.length;
|
||||
const waiting = await getUI(page);
|
||||
assert.equal(
|
||||
waiting.street.open,
|
||||
false,
|
||||
'nothing open without a click',
|
||||
);
|
||||
assert.equal(waiting.street.imageId, null);
|
||||
assert.deepEqual(
|
||||
waiting.street.armed.point,
|
||||
{ lat: point.lat, lon: point.lon },
|
||||
'armed at the link point',
|
||||
);
|
||||
assert.equal(
|
||||
beforePress,
|
||||
0,
|
||||
'no Street View request before the press',
|
||||
);
|
||||
await expandPanel(page);
|
||||
await press(page, '#sl-nearest-btn');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const { street } = await getUI(page);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
assert.equal(street.providerId, 'google');
|
||||
assert.ok(
|
||||
googleRequests.length >= 1,
|
||||
'the press made the Street View request',
|
||||
);
|
||||
const metres = Math.hypot(
|
||||
(street.position.lat - point.lat) * 111_320,
|
||||
(street.position.lon - point.lon) *
|
||||
111_320 *
|
||||
Math.cos((point.lat * Math.PI) / 180),
|
||||
);
|
||||
assert.ok(
|
||||
metres <= 250,
|
||||
`within 250 m of the link point: ${Math.round(metres)} m`,
|
||||
);
|
||||
// Back to Mapillary: the keyless page restores the saved layer
|
||||
// and expects its KEY REQUIRED.
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await uiUntil(page, litAlone, 'mapillary');
|
||||
} finally {
|
||||
page.off('request', onRequest);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await fixtureStep(
|
||||
// The fixture bundle carries a token, so this covers a keyless
|
||||
// server behind a keyed bundle; the keyless default is checked above.
|
||||
|
||||
@@ -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);
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -904,6 +904,7 @@ const SLOW_TOOLS = new Set([
|
||||
'annotate_map',
|
||||
'fly_to_location',
|
||||
'select_nearest_aircraft',
|
||||
'show_street_level',
|
||||
'analyst_query',
|
||||
'fly_route',
|
||||
'frame_overhead',
|
||||
|
||||
+156
-21
@@ -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)
|
||||
@@ -1495,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.
|
||||
@@ -1741,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
|
||||
@@ -1762,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}`);
|
||||
|
||||
@@ -35,6 +35,14 @@ export const CORE_PHRASES = Object.freeze([
|
||||
{ phrase: 'Show me the whole earth', expect: 'zoom_to_globe' },
|
||||
{ phrase: 'Frame the aircraft near us from overhead', expect: 'frame_overhead', typedArgs: [where('frame_overhead', (a) => a.target === 'flights')] },
|
||||
|
||||
// — street-level photos: one composite action, never fly + basemap —
|
||||
{ phrase: 'Show me the Eiffel Tower on Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && /eiffel/i.test(a.query || ''))] },
|
||||
{ phrase: 'Street view of 10th Street in Sacramento', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google')] },
|
||||
{ phrase: 'Show me Times Square on Mapillary', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'mapillary')] },
|
||||
{ phrase: 'Switch to Google Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && !a.query)] },
|
||||
{ phrase: 'Show me the Eiffel Tower', expect: 'fly_to_location' },
|
||||
{ phrase: 'Turn on street level', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'street-level' && a.enabled === true)] },
|
||||
|
||||
// — the satellites trap: data layer, never basemap —
|
||||
{ phrase: 'Show me the satellites', expect: { oneOf: ['set_layer_visibility', 'frame_overhead'] } },
|
||||
{ phrase: 'Turn off the satellites', expect: 'set_layer_visibility', args: { layerId: 'satellites' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'satellites' && a.enabled === false)] },
|
||||
|
||||
@@ -150,6 +150,11 @@ 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() {
|
||||
|
||||
@@ -63,7 +63,10 @@ export function createGeminiTokenHandler({
|
||||
return reply(403, 'Gemini voice requires a same-origin app request');
|
||||
apiKey = String(resolveApiKey() || '').trim();
|
||||
if (!apiKey)
|
||||
return reply(503, 'Add a Gemini API key in Provider Settings');
|
||||
return reply(503, {
|
||||
error: 'Add a Gemini API key in Provider Settings',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
model = normalizeGeminiModel(resolveModel());
|
||||
if (!model)
|
||||
return reply(503, 'The Gemini Live model configuration is invalid');
|
||||
|
||||
@@ -28,7 +28,9 @@ import { weatherProxy } from './weather.js';
|
||||
import { firePerimetersProxy } from './firePerimeters.js';
|
||||
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';
|
||||
@@ -82,7 +84,9 @@ function localProviderPlugins({
|
||||
firePerimetersProxy(),
|
||||
gnssIntegrityProxy(),
|
||||
ghostMapsProxy(),
|
||||
shipwrecksProxy(),
|
||||
droneAirspaceProxy(),
|
||||
newsProxy(),
|
||||
inaturalistProxy(),
|
||||
acartiaProxy(),
|
||||
keySetupEndpoint(),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'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);
|
||||
});
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
// 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();
|
||||
// Exports are read in parallel, so reports of one time arrive in any
|
||||
// order: the most mentioned comes first, then by id.
|
||||
const reports = [...gdelt.reports.values()]
|
||||
.filter((r) => r.publishedAt >= dayAgo)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.publishedAt.localeCompare(a.publishedAt) ||
|
||||
b.mentions - a.mentions ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
.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
@@ -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'));
|
||||
}
|
||||
@@ -38,7 +38,7 @@ function realtimeDirectives(
|
||||
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
|
||||
'Do not mention disabled layers or stale selections.',
|
||||
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, show_street_level, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.',
|
||||
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers, approximate outlines and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
|
||||
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
|
||||
@@ -65,6 +65,7 @@ function realtimeDirectives(
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
|
||||
'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
|
||||
'STREET LEVEL: "X on Street View/Mapillary/KartaView/Panoramax", "street view of X" → show_street_level once, not fly_to_location; no place = view centre. Street View is never a basemap.',
|
||||
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
|
||||
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
|
||||
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
|
||||
|
||||
@@ -238,7 +238,12 @@ function createRealtimeTokenHandler({
|
||||
if (!bearer) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,28 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
show_street_level: {
|
||||
description:
|
||||
'Fly to a place and open its nearest street-level photo; Street View switches to Google 3D (billed). No query: view centre.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
query: {
|
||||
description: 'Place or address; "pointer" = under the cursor.',
|
||||
$position: 1,
|
||||
},
|
||||
provider: {
|
||||
description:
|
||||
'google = Street View; others by name; omit: the lit source.',
|
||||
$position: 1,
|
||||
},
|
||||
referent: {
|
||||
description: 'Numbered item n from the last result list.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
adjust_camera_zoom: {
|
||||
description:
|
||||
'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
EMODNET_WFS_URL,
|
||||
WIKIDATA_QUERY,
|
||||
mergeShipwreckRecords,
|
||||
normalizeEmodnetFeatures,
|
||||
normalizeWikidataResults,
|
||||
} from '../../src/layers/shipwrecks/records.js';
|
||||
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
|
||||
import { clientKey, makeRateLimiter } from './common/rate-limit.js';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
/** EMODnet republishes the wreck layer about once a year; Wikidata daily at most. */
|
||||
const REFRESH_MS = 24 * 60 * 60 * 1000;
|
||||
/** A source's last good records keep being served this long, marked stale. */
|
||||
const STALE_MAX_MS = 7 * REFRESH_MS;
|
||||
/** After a source fails, it is not asked again for this long. */
|
||||
const RETRY_MS = 15 * 60 * 1000;
|
||||
/** A refresh with fewer than this share of the last good records is refused. */
|
||||
const MIN_KEEP_RATIO = 0.5;
|
||||
// Only the columns the card uses; ship_char/obj_desc histories can name people
|
||||
// and are not read beyond a short vessel type.
|
||||
const EMODNET_PROPERTIES = [
|
||||
'source_id',
|
||||
'country',
|
||||
'name',
|
||||
'least_depth',
|
||||
'depth_prec',
|
||||
'loc_prec',
|
||||
'sink_context',
|
||||
'sink_yr',
|
||||
'dating',
|
||||
'period',
|
||||
'statutory',
|
||||
'ship_char',
|
||||
'obj_type',
|
||||
'artefacts',
|
||||
'source_inf',
|
||||
'website1',
|
||||
'point_info',
|
||||
'the_geom',
|
||||
].join(',');
|
||||
const EMODNET_URL = `${EMODNET_WFS_URL}&propertyName=${EMODNET_PROPERTIES}`;
|
||||
const WIKIDATA_URL =
|
||||
'https://query.wikidata.org/sparql?format=json&query=' +
|
||||
encodeURIComponent(WIKIDATA_QUERY);
|
||||
// Wikimedia asks automated clients to name themselves.
|
||||
const USER_AGENT =
|
||||
"God's Eye View shipwrecks layer (https://github.com/bilawalsidhu/gods-eye-view)";
|
||||
|
||||
/** Fixed-origin, cached EMODnet + Wikidata shipwreck snapshot for dev and preview. */
|
||||
export function shipwrecksProxy({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
// Each source keeps its last good records on its own, so one failing (or
|
||||
// answering with nothing usable) never removes the other's wrecks or its
|
||||
// own earlier ones. `retryAt` holds a failed source back for RETRY_MS.
|
||||
const sources = {
|
||||
emodnet: { load: fetchEmodnet, good: null, retryAt: 0 },
|
||||
wikidata: { load: fetchWikidata, good: null, retryAt: 0 },
|
||||
};
|
||||
let snapshot = null;
|
||||
const inFlight = new Map();
|
||||
const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 });
|
||||
|
||||
async function upstreamJson(url, maxBytes) {
|
||||
const signal = AbortSignal.timeout(90_000);
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
redirect: 'error',
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('upstream_unavailable');
|
||||
}
|
||||
return readResponseJsonCapped(response, maxBytes, signal);
|
||||
}
|
||||
|
||||
async function fetchEmodnet() {
|
||||
const records = normalizeEmodnetFeatures(
|
||||
await upstreamJson(EMODNET_URL, 32 * MIB),
|
||||
);
|
||||
if (!records.length) throw new Error('invalid_emodnet_layer');
|
||||
return records;
|
||||
}
|
||||
|
||||
async function fetchWikidata() {
|
||||
const payload = await upstreamJson(WIKIDATA_URL, 16 * MIB);
|
||||
if (!Array.isArray(payload?.results?.bindings))
|
||||
throw new Error('invalid_wikidata_results');
|
||||
const records = normalizeWikidataResults(payload);
|
||||
// Wikidata lists over a thousand wrecks; none at all is a broken answer.
|
||||
if (!records.length) throw new Error('invalid_wikidata_results');
|
||||
return records;
|
||||
}
|
||||
|
||||
const fresh = (state, at) =>
|
||||
state.good !== null && at - state.good.fetchedAt < REFRESH_MS;
|
||||
const usable = (state, at) =>
|
||||
state.good !== null && at - state.good.fetchedAt <= STALE_MAX_MS;
|
||||
const due = (state, at) => !fresh(state, at) && at >= state.retryAt;
|
||||
|
||||
/** Refresh the sources that are due; failures keep their last good records. */
|
||||
async function refresh() {
|
||||
const at = now();
|
||||
const names = Object.keys(sources).filter((name) => due(sources[name], at));
|
||||
const results = await Promise.allSettled(
|
||||
names.map((name) => sources[name].load()),
|
||||
);
|
||||
const done = now();
|
||||
results.forEach((result, i) => {
|
||||
const state = sources[names[i]];
|
||||
// A valid answer far smaller than the last good set (a truncated or
|
||||
// half-built upstream) is a failure too, until the old set expires.
|
||||
const shrunk =
|
||||
result.status === 'fulfilled' &&
|
||||
usable(state, done) &&
|
||||
result.value.length < state.good.records.length * MIN_KEEP_RATIO;
|
||||
if (result.status === 'fulfilled' && !shrunk)
|
||||
state.good = { records: result.value, fetchedAt: done };
|
||||
else state.retryAt = done + RETRY_MS;
|
||||
if (shrunk)
|
||||
console.warn(
|
||||
`[shipwrecks] ${names[i]} refresh refused: ${result.value.length} ` +
|
||||
`records against ${state.good.records.length} last good`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The served snapshot: every source with usable records, merged once. */
|
||||
function compose(at) {
|
||||
const sourceFlags = {};
|
||||
// How old each source's records are, on this clock, so the page can
|
||||
// expire them by its own clock without comparing the two.
|
||||
const ages = {};
|
||||
let stale = false;
|
||||
let fetchedAt = null;
|
||||
for (const [name, state] of Object.entries(sources)) {
|
||||
sourceFlags[name] = usable(state, at);
|
||||
ages[name] = sourceFlags[name] ? at - state.good.fetchedAt : null;
|
||||
if (!sourceFlags[name]) continue;
|
||||
if (!fresh(state, at)) stale = true;
|
||||
fetchedAt = Math.min(fetchedAt ?? Infinity, state.good.fetchedAt);
|
||||
}
|
||||
if (!sourceFlags.emodnet && !sourceFlags.wikidata) return null;
|
||||
const key = `${sourceFlags.emodnet && sources.emodnet.good.fetchedAt}:${
|
||||
sourceFlags.wikidata && sources.wikidata.good.fetchedAt
|
||||
}`;
|
||||
if (snapshot?.key !== key) {
|
||||
// The merge writes EMODnet facts into Wikidata records: work on copies
|
||||
// so a source's kept records stay as fetched.
|
||||
const copy = (name) =>
|
||||
sourceFlags[name]
|
||||
? sources[name].good.records.map((record) => ({ ...record }))
|
||||
: [];
|
||||
snapshot = {
|
||||
key,
|
||||
records: mergeShipwreckRecords(copy('emodnet'), copy('wikidata')),
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
fetchedAt,
|
||||
version: key,
|
||||
sources: sourceFlags,
|
||||
ages,
|
||||
maxAgeMs: STALE_MAX_MS,
|
||||
records: snapshot.records,
|
||||
},
|
||||
stale,
|
||||
};
|
||||
}
|
||||
|
||||
async function acquire() {
|
||||
const at = now();
|
||||
if (Object.values(sources).some((state) => due(state, at)))
|
||||
await coalesceProxyRequest(inFlight, 'refresh', refresh).promise;
|
||||
const composed = compose(now());
|
||||
if (!composed) throw new Error('shipwrecks_unavailable');
|
||||
return composed;
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const json = (status, value, stale = false) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
...(status === 429 ? { 'Retry-After': '60' } : {}),
|
||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
||||
const path = (req.url || '/').split('?')[0];
|
||||
if (path !== '/' && path !== '')
|
||||
return json(404, { error: 'unknown_route' });
|
||||
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
||||
try {
|
||||
const { value, stale } = await acquire();
|
||||
json(200, stale ? { ...value, stale: true } : value, stale);
|
||||
} catch {
|
||||
json(502, { error: 'shipwrecks_unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'shipwrecks',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/shipwrecks', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/shipwrecks', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,9 @@ import { createApplicationAirQuality } from './layers/airQuality.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
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';
|
||||
@@ -136,9 +138,11 @@ export function createApplicationCatalog({
|
||||
windowMs: sources['gnss-integrity'].windowMs,
|
||||
}),
|
||||
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||
createApplicationShipwrecks({ source: sources.shipwrecks }),
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
createApplicationNews({ source: sources.news }),
|
||||
createApplicationWildlifeSightings({
|
||||
source: sources['wildlife-sightings'],
|
||||
groundFloor: surface.groundFloor,
|
||||
|
||||
@@ -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, 38);
|
||||
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, 38);
|
||||
assert.equal(catalog.layers.length, 40);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
+272
-24
@@ -12,13 +12,25 @@
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
import {
|
||||
announceNavigationAuthority,
|
||||
watchCameraOwner,
|
||||
} from '../navigationPolicy.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js';
|
||||
import {
|
||||
annotationsFromParams,
|
||||
createView,
|
||||
followFromParams,
|
||||
groundedLookAt,
|
||||
streetLevelFromParams,
|
||||
} from '../view/index.js';
|
||||
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const STREET_LEVEL_LAYER = 'street-level';
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
military_aircraft: 'military',
|
||||
@@ -27,6 +39,8 @@ const FOLLOW_LAYERS = {
|
||||
const FOLLOW_ATTEMPTS = 20;
|
||||
const FOLLOW_RETRY_MS = 1000;
|
||||
const FLIGHT_SECONDS = 2;
|
||||
/** How long a posted view waits for the terrain under its camera. */
|
||||
const TERRAIN_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Whether this page shows the app embedded: `?embed=1` when another page
|
||||
@@ -45,6 +59,14 @@ export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the panel page started the app for a tool result issued just now,
|
||||
* not one its host handed back on mounting the panel again.
|
||||
*/
|
||||
function isFreshPanelResult() {
|
||||
return isEmbeddedInline() && globalThis.GEV_EMBED_FRESH === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
|
||||
* may report it hidden while it is on screen (see keepPanelRendering), so an
|
||||
@@ -165,6 +187,40 @@ function flyTo(viewer, camera) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `show_street_level` arguments for a view's street-level photos. A plain
|
||||
* link (`autoOpen: false`) only arms a source that bills every load; a view
|
||||
* posted into the embedded app is the client's own request and opens.
|
||||
*/
|
||||
function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
|
||||
return {
|
||||
latitude: streetLevel.lat,
|
||||
longitude: streetLevel.lon,
|
||||
...(streetLevel.provider ? { provider: streetLevel.provider } : {}),
|
||||
fly: true,
|
||||
...(autoOpen ? {} : { autoOpen: false }),
|
||||
};
|
||||
}
|
||||
|
||||
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
|
||||
/** Dispatched on the window when a link opened after start-up has restored. */
|
||||
const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled';
|
||||
|
||||
/** The armed link's toast, naming the OPEN button as the panel shows it. */
|
||||
function armedToast() {
|
||||
return t('Street View is ready: press {open} to load it', {
|
||||
open: t('OPEN {labels}', { labels: translateKnown(GOOGLE_LABEL) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Why a link's photo stays shut: its source has no working key here. */
|
||||
function keyToast(result) {
|
||||
return t(
|
||||
"{source} cannot open this link's street-level photo without a working key",
|
||||
{ source: translateKnown(result.providerName || 'Street Level') },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app show a view: style and map, exactly the view's layers,
|
||||
* annotations, the camera, and the followed entity. Each step runs through the
|
||||
@@ -173,13 +229,20 @@ function flyTo(viewer, camera) {
|
||||
*/
|
||||
export async function applyView(
|
||||
view,
|
||||
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
|
||||
{
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen = true,
|
||||
retryMs = FOLLOW_RETRY_MS,
|
||||
},
|
||||
) {
|
||||
const steps = [];
|
||||
const act = async (name, args) => {
|
||||
const act = async (name, args, options) => {
|
||||
let result = null;
|
||||
try {
|
||||
result = await run(name, args);
|
||||
result = await run(name, args, options);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error?.message || String(error) };
|
||||
}
|
||||
@@ -198,6 +261,8 @@ export async function applyView(
|
||||
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
|
||||
}
|
||||
for (const layerId of view.layers) {
|
||||
// The street-level action lights its source first, then the layer.
|
||||
if (view.street_level && layerId === STREET_LEVEL_LAYER) continue;
|
||||
if (!dataManager.isEnabled(layerId))
|
||||
await act('set_layer_visibility', { layerId, enabled: true });
|
||||
}
|
||||
@@ -207,6 +272,29 @@ export async function applyView(
|
||||
await act('clear_annotations', {});
|
||||
if (view.annotations.length)
|
||||
await act('annotate_map', { annotations: [...view.annotations] });
|
||||
if (view.street_level) {
|
||||
// The photos frame the camera; when the action fails before flying, the
|
||||
// view's own camera still applies.
|
||||
// A posted view replays someone else's view, as a link does: the source
|
||||
// it lights is not saved as the visitor's own choice.
|
||||
const result = await act(
|
||||
'show_street_level',
|
||||
streetLevelArgs(view.street_level, { autoOpen }),
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
if (result?.ok !== true) {
|
||||
// Overtaken (a click on the map, a newer request): hands off.
|
||||
if (result?.cancelled || result?.superseded) return steps;
|
||||
// An earlier view's photo must not stay over a place this view left.
|
||||
// From the imagery stage on the action has settled the panel itself
|
||||
// (a photo it could not load keeps its error).
|
||||
if (result?.stage !== 'imagery')
|
||||
dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.();
|
||||
if (!result?.location)
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
if (!view.follow) {
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
return steps;
|
||||
@@ -250,18 +338,115 @@ export function installViews({
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
terrain = null,
|
||||
location = globalThis.location,
|
||||
windowRef = globalThis.window,
|
||||
}) {
|
||||
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
|
||||
const linked = annotationsFromParams(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
const hashParams = new URLSearchParams(
|
||||
String(location?.hash || '').replace(/^#/, ''),
|
||||
);
|
||||
if (linked.length)
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
|
||||
// The link's own steps run first, one at a time, and posted views queue
|
||||
// behind them; one failing step does not take the next with it. A passive
|
||||
// link never flies over someone who took the camera: its steps are skipped
|
||||
// when a newer link superseded it, when its flight never landed (the
|
||||
// recipient dragged or searched), or after any navigation since the
|
||||
// generation its restore flight owned.
|
||||
const cameraLanded = (outcome) => outcome?.share?.camera === 'applied';
|
||||
const restoreGeneration = (outcome) =>
|
||||
Number.isInteger(outcome?.navigationToken)
|
||||
? outcome.navigationToken
|
||||
: shell._navigationGeneration;
|
||||
let generationAtRestore = null;
|
||||
let startSuperseded = false;
|
||||
let queue = ready.then((outcome) => {
|
||||
generationAtRestore = restoreGeneration(outcome);
|
||||
startSuperseded =
|
||||
outcome?.status === 'superseded' ||
|
||||
(Boolean(outcome?.share) && !cameraLanded(outcome));
|
||||
});
|
||||
const navigatedSince = (generation) =>
|
||||
Number.isFinite(generation) && shell._navigationGeneration !== generation;
|
||||
const linkStep = (work) =>
|
||||
queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
await work();
|
||||
} catch {
|
||||
/* reported by the action itself */
|
||||
}
|
||||
});
|
||||
if (!isEmbedded(location)) return () => {};
|
||||
const linked = annotationsFromParams(hashParams);
|
||||
if (linked.length)
|
||||
queue = linkStep(() => run('annotate_map', { annotations: linked }));
|
||||
// A link's street-level photos open once it has restored. A billed source
|
||||
// only arms, except for the panel page's own link to a result issued just
|
||||
// now. A link that follows an entity keeps the camera on it.
|
||||
const linkedStreetLevel = (params) =>
|
||||
followFromParams(params) ? null : streetLevelFromParams(params);
|
||||
const openLinkedStreetLevel = async (
|
||||
streetLevel,
|
||||
{ generation, autoOpen = false },
|
||||
) => {
|
||||
if (navigatedSince(generation)) return;
|
||||
// The link replays the sender's view: nothing it switches becomes the
|
||||
// recipient's own preference.
|
||||
const result = await run(
|
||||
'show_street_level',
|
||||
streetLevelArgs(streetLevel, { autoOpen }),
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
// A source without a working key changes nothing: say why, or the link
|
||||
// looks broken. In a frame the host reads the result instead.
|
||||
if (result?.stage === 'key' && !isEmbedded(location)) {
|
||||
shell._showToast?.(keyToast(result));
|
||||
return;
|
||||
}
|
||||
// OPEN must be in view, but embed mode hides the rail that holds it: in
|
||||
// a frame a posted view is the way to open a photo.
|
||||
if (result?.armed === true && !isEmbedded(location)) {
|
||||
// Opened for this link only: the recipient's saved layout stays theirs.
|
||||
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
|
||||
explicit: true,
|
||||
persist: false,
|
||||
});
|
||||
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
|
||||
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
|
||||
shell._showToast?.(armedToast());
|
||||
}
|
||||
};
|
||||
const startStreetLevel = linkedStreetLevel(hashParams);
|
||||
if (startStreetLevel)
|
||||
queue = linkStep(() =>
|
||||
startSuperseded
|
||||
? undefined
|
||||
: openLinkedStreetLevel(startStreetLevel, {
|
||||
generation: generationAtRestore,
|
||||
autoOpen: isFreshPanelResult(),
|
||||
}),
|
||||
);
|
||||
// A link pasted into the open tab: its photos follow once its restore
|
||||
// settles, under the same rules.
|
||||
const onNavigatedRestore = (event) => {
|
||||
if (event?.detail?.status !== 'settled' || !cameraLanded(event.detail))
|
||||
return;
|
||||
const streetLevel = linkedStreetLevel(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
);
|
||||
if (!streetLevel) return;
|
||||
const generation = restoreGeneration(event.detail);
|
||||
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
|
||||
};
|
||||
windowRef?.addEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
const stopNavigatedRestores = () =>
|
||||
windowRef?.removeEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
if (!isEmbedded(location)) return stopNavigatedRestores;
|
||||
|
||||
windowRef.document.body.classList.add('ui-embed');
|
||||
shell.setCleanView?.(true);
|
||||
@@ -278,7 +463,6 @@ export function installViews({
|
||||
peer.postMessage(message, origin);
|
||||
};
|
||||
// Views apply one at a time, in the order they arrive.
|
||||
let queue = ready;
|
||||
const onMessage = (event) => {
|
||||
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
|
||||
return;
|
||||
@@ -302,19 +486,82 @@ export function installViews({
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue = queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
},
|
||||
replyOrigin,
|
||||
// A panel's host can hand back old results on a remount: only one issued
|
||||
// just now may open a billed photo. A framing page's view is its own.
|
||||
const autoOpen = !isEmbeddedInline() || event.data.fresh === true;
|
||||
// Photos asked for without a point open where the camera looks, measured
|
||||
// from the ground under it, as show_in_gods_eye_view places them.
|
||||
const asked = event.data.view?.street_level;
|
||||
const unplaced =
|
||||
asked &&
|
||||
typeof asked === 'object' &&
|
||||
asked.lat == null &&
|
||||
asked.lon == null;
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
if (unplaced && view.street_level) {
|
||||
// A terrain service that does not answer soon leaves the view's
|
||||
// own estimate.
|
||||
const wait = AbortSignal.timeout(TERRAIN_WAIT_MS);
|
||||
const owner = watchCameraOwner(viewer, shell);
|
||||
const point = await groundedLookAt(
|
||||
view.camera,
|
||||
terrain,
|
||||
signal ? AbortSignal.any([signal, wait]) : wait,
|
||||
)
|
||||
.catch(() => null)
|
||||
.finally(() => owner.stop());
|
||||
if (signal?.aborted) return;
|
||||
// Whoever takes the camera while the terrain answers (a drag, a
|
||||
// search, the assistant) keeps it: the view stands down.
|
||||
if (owner.taken()) {
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
steps: [{ step: 'street_level', ok: false, superseded: true }],
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (point)
|
||||
view = createView({
|
||||
...event.data.view,
|
||||
street_level: { ...asked, ...point },
|
||||
});
|
||||
}
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen,
|
||||
});
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
})
|
||||
// A view that fails is answered, and the views behind it still apply.
|
||||
.catch((error) =>
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
error: error?.message || String(error),
|
||||
},
|
||||
replyOrigin,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
windowRef.addEventListener('message', onMessage);
|
||||
void ready.then(() => {
|
||||
@@ -322,6 +569,7 @@ export function installViews({
|
||||
});
|
||||
return () => {
|
||||
stopRendering();
|
||||
stopNavigatedRestores();
|
||||
windowRef.removeEventListener('message', onMessage);
|
||||
};
|
||||
}
|
||||
+901
-3
@@ -8,7 +8,7 @@ import {
|
||||
panelAwareVisibility,
|
||||
} from './embed.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { createView } from '../view/index.js';
|
||||
import { createView, viewToParams } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
|
||||
});
|
||||
const recorder = (answers = {}) => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
const options = [];
|
||||
const run = async (name, args, runOptions) => {
|
||||
calls.push([name, args]);
|
||||
options.push([name, runOptions]);
|
||||
const answer = answers[name];
|
||||
return typeof answer === 'function'
|
||||
? answer(calls)
|
||||
: (answer ?? { ok: true });
|
||||
};
|
||||
return { calls, run };
|
||||
return { calls, run, options };
|
||||
};
|
||||
|
||||
test('embed mode is opt-in by query parameter', () => {
|
||||
@@ -83,6 +85,19 @@ test('a view is applied through the app actions in order', async () => {
|
||||
assert.ok(steps.some((step) => step.step === 'camera'));
|
||||
});
|
||||
|
||||
test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => {
|
||||
const { options, run } = recorder();
|
||||
await applyView(
|
||||
createView({
|
||||
camera: { lat: 48.86, lon: 2.29 },
|
||||
street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 },
|
||||
}),
|
||||
{ viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run },
|
||||
);
|
||||
const [, runOptions] = options.find(([name]) => name === 'show_street_level');
|
||||
assert.equal(runOptions?.origin, 'programmatic');
|
||||
});
|
||||
|
||||
test('a followed entity is retried until its layer has it', async () => {
|
||||
let tries = 0;
|
||||
const { run } = recorder({
|
||||
@@ -102,6 +117,65 @@ test('a followed entity is retried until its layer has it', async () => {
|
||||
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
|
||||
});
|
||||
|
||||
test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => {
|
||||
const parent = { postMessage() {} };
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = parent;
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} };
|
||||
const { calls, run } = recorder();
|
||||
const asked = [];
|
||||
// Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m.
|
||||
const terrain = {
|
||||
getHeights: async (points) => {
|
||||
asked.push(points);
|
||||
return points.map(() => ({ ellipsoid: 1590 }));
|
||||
},
|
||||
};
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
terrain,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: parent,
|
||||
origin: 'https://host.example',
|
||||
data: {
|
||||
type: 'gev:view',
|
||||
id: 1,
|
||||
view: {
|
||||
camera: {
|
||||
lat: 39.74,
|
||||
lon: -104.99,
|
||||
altitude_m: 1909,
|
||||
pitch_deg: -30,
|
||||
heading_deg: 0,
|
||||
},
|
||||
street_level: { provider: 'mapillary' },
|
||||
},
|
||||
},
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
remove();
|
||||
const [, args] = calls.find(([name]) => name === 'show_street_level');
|
||||
// 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the
|
||||
// ellipsoid altitude would give.
|
||||
assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]);
|
||||
assert.ok(
|
||||
args.latitude > 39.744 && args.latitude < 39.746,
|
||||
String(args.latitude),
|
||||
);
|
||||
assert.equal(args.longitude, -104.99);
|
||||
});
|
||||
|
||||
test('an embedded page takes views only from its parent and answers it', async () => {
|
||||
const posted = [];
|
||||
const targets = [];
|
||||
@@ -186,6 +260,59 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
remove();
|
||||
});
|
||||
|
||||
test('an inline panel opens a billed photo only for a view whose result is fresh', async () => {
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = windowRef;
|
||||
windowRef.postMessage = () => {};
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
Object.assign(windowRef, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const { calls, run } = recorder();
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
const remove = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
const view = {
|
||||
camera: { lat: 48.8584, lon: 2.2945 },
|
||||
map: 'photoreal',
|
||||
street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 },
|
||||
};
|
||||
for (const [id, fresh] of [
|
||||
[1, true],
|
||||
[2, false],
|
||||
[3, undefined],
|
||||
]) {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: windowRef,
|
||||
data: { type: 'gev:view', id, view, fresh },
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.deepEqual(
|
||||
calls
|
||||
.filter(([name]) => name === 'show_street_level')
|
||||
.map(([, args]) => args.autoOpen ?? true),
|
||||
[true, false, false],
|
||||
);
|
||||
remove();
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
}
|
||||
});
|
||||
|
||||
test('an app loaded inline into a panel page talks through its own window', async () => {
|
||||
const posted = [];
|
||||
const windowRef = new EventTarget();
|
||||
@@ -393,3 +520,774 @@ test('a followed aircraft owns the camera, and cockpit view is entered after it'
|
||||
/no cockpit view/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a view with street-level photos lets the action fly and open them', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder();
|
||||
const view = createView({
|
||||
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.map(([name]) => name),
|
||||
[
|
||||
'set_map_stack',
|
||||
'stop_tracking',
|
||||
'clear_annotations',
|
||||
'show_street_level',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(calls.at(-1)[1], {
|
||||
latitude: 38.5799,
|
||||
longitude: -121.4937,
|
||||
provider: 'google',
|
||||
fly: true,
|
||||
});
|
||||
assert.equal(viewer.flights.length, 0, 'the action frames the camera');
|
||||
assert.ok(steps.every((step) => step.ok));
|
||||
assert.equal(steps.at(-1).step, 'show_street_level');
|
||||
});
|
||||
|
||||
test('a link to street-level photos opens them after it restores', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const toasts = [];
|
||||
const panels = [];
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (...args) => panels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: 38.58,
|
||||
longitude: -121.49,
|
||||
provider: 'mapillary',
|
||||
fly: true,
|
||||
autoOpen: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[toasts, panels],
|
||||
[[], []],
|
||||
'an opened photo needs no toast or panel nudge',
|
||||
);
|
||||
// A failure (no key) is not an armed state either: it says why instead.
|
||||
const failed = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (...args) => panels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: failed.run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
[toasts, panels],
|
||||
[
|
||||
[
|
||||
"Street Level cannot open this link's street-level photo without a working key",
|
||||
],
|
||||
[],
|
||||
],
|
||||
'it says why, and nothing to press opens',
|
||||
);
|
||||
});
|
||||
|
||||
test('a link that follows an entity keeps the camera: its photos are not opened', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const params = viewToParams(
|
||||
createView({
|
||||
camera: { lat: 1, lon: 2 },
|
||||
follow: { kind: 'satellite', id: '25544' },
|
||||
}),
|
||||
);
|
||||
params.set('sl', 'mapillary,1.00000,2.00000');
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: `#${params}` },
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a failing link step does not take the next one with it', async () => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
calls.push(name);
|
||||
if (name === 'annotate_map') throw new Error('marks failed');
|
||||
return { ok: true, armed: false, ...args };
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['annotate_map', 'show_street_level']);
|
||||
});
|
||||
|
||||
test('a link whose source has no working key says why instead of doing nothing; a frame stays quiet', async () => {
|
||||
for (const embed of [false, true]) {
|
||||
const toasts = [];
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
provider: 'google',
|
||||
providerName: 'Google Street View',
|
||||
error: 'Google Street View needs its key',
|
||||
}),
|
||||
});
|
||||
const windowRef = new EventTarget();
|
||||
if (embed) {
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
windowRef.parent = { postMessage() {} };
|
||||
}
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed() {
|
||||
throw new Error('nothing to arm');
|
||||
},
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: embed ? '?embed=1' : '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls[0][0], 'show_street_level');
|
||||
assert.deepEqual(
|
||||
toasts,
|
||||
embed
|
||||
? []
|
||||
: [
|
||||
"Google Street View cannot open this link's street-level photo without a working key",
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => {
|
||||
const toasts = [];
|
||||
const panels = [];
|
||||
const revealed = [];
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({ ok: true, armed: true }),
|
||||
});
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (id, collapsed, options) =>
|
||||
panels.push([id, collapsed, options]),
|
||||
revealPanel: (id) => revealed.push(id),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls[0][1].autoOpen, false);
|
||||
assert.deepEqual(toasts, [
|
||||
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
panels,
|
||||
[['street-level-panel', false, { explicit: true, persist: false }]],
|
||||
"the panel opens so OPEN is in view, without saving the layout as the recipient's",
|
||||
);
|
||||
assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too');
|
||||
// Adding ?embed=1 to a published link does not lift the gate: the page is
|
||||
// still a plain top-level load.
|
||||
const framedToasts = [];
|
||||
const framedPanels = [];
|
||||
const framed = recorder({
|
||||
show_street_level: () => ({ ok: true, armed: true }),
|
||||
});
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
windowRef.parent = { postMessage() {} };
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
_showToast: (text) => framedToasts.push(text),
|
||||
setPanelCollapsed: (...args) => framedPanels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: framed.run,
|
||||
location: {
|
||||
search: '?embed=1',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(framed.calls[0][1].autoOpen, false);
|
||||
// In a frame the rail that holds OPEN is hidden: no nudge, no toast.
|
||||
assert.deepEqual([framedToasts, framedPanels], [[], []]);
|
||||
// Only the panel page's own link for a result issued just now is the
|
||||
// client's request: a host that mounts the panel again hands back an old
|
||||
// result, and that one only arms, like any link.
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
for (const fresh of [true, false]) {
|
||||
globalThis.GEV_EMBED_FRESH = fresh;
|
||||
const inline = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
const inlineWindow = new EventTarget();
|
||||
inlineWindow.document = { body: { classList: { add() {} } } };
|
||||
inlineWindow.postMessage = () => {};
|
||||
// The inline panel keeps rendering from a timer; give it inert clocks.
|
||||
Object.assign(inlineWindow, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const stop = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: inline.run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef: inlineWindow,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false);
|
||||
else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms');
|
||||
stop();
|
||||
}
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
delete globalThis.GEV_EMBED_FRESH;
|
||||
}
|
||||
// applyView (a posted view) opens too.
|
||||
const posted = recorder();
|
||||
await applyView(
|
||||
createView({
|
||||
camera: { lat: 1, lon: 2 },
|
||||
street_level: { provider: 'google' },
|
||||
}),
|
||||
{
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: posted.run,
|
||||
},
|
||||
);
|
||||
assert.equal('autoOpen' in posted.calls.at(-1)[1], false);
|
||||
});
|
||||
|
||||
test('a view whose photos fail before any flight still gets its camera', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
});
|
||||
assert.equal(viewer.flights.length, 1, 'the view flies itself');
|
||||
assert.deepEqual(
|
||||
steps.map((step) => [step.step, step.ok]),
|
||||
[
|
||||
['set_map_stack', true],
|
||||
['stop_tracking', true],
|
||||
['clear_annotations', true],
|
||||
['show_street_level', false],
|
||||
['camera', true],
|
||||
],
|
||||
);
|
||||
assert.ok(calls.some(([name]) => name === 'show_street_level'));
|
||||
// A flight that happened (the action reports where) is not repeated.
|
||||
const flown = fakeViewer();
|
||||
const { run: flownRun } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
location: 'Somewhere',
|
||||
error: 'no imagery',
|
||||
}),
|
||||
});
|
||||
await applyView(view, {
|
||||
viewer: flown,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: flownRun,
|
||||
});
|
||||
assert.equal(flown.flights.length, 0);
|
||||
});
|
||||
|
||||
test('a link runs its marks and photos in order, and posted views wait behind them', async () => {
|
||||
const log = [];
|
||||
let releaseStreetLevel = null;
|
||||
let releaseMarks = null;
|
||||
const run = async (name, args) => {
|
||||
log.push(`start:${name}`);
|
||||
if (name === 'annotate_map')
|
||||
await new Promise((resolve) => {
|
||||
releaseMarks = resolve;
|
||||
});
|
||||
if (name === 'show_street_level')
|
||||
await new Promise((resolve) => {
|
||||
releaseStreetLevel = resolve;
|
||||
});
|
||||
log.push(`end:${name}`);
|
||||
return {
|
||||
ok: true,
|
||||
...(name === 'show_street_level' ? { location: 'x' } : {}),
|
||||
};
|
||||
};
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
// A framing page: answers go to it, not to the app's own window.
|
||||
windowRef.parent = {
|
||||
postMessage: (message) => log.push(`post:${message.type}`),
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '?embed=1',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// A view arrives while the link's photos are still opening.
|
||||
windowRef.dispatchEvent(
|
||||
Object.assign(new Event('message'), {
|
||||
source: windowRef.parent,
|
||||
origin: 'null',
|
||||
data: {
|
||||
type: 'gev:view',
|
||||
id: 'v1',
|
||||
view: { camera: { lat: 3, lon: 4 } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const actions = () => log.filter((entry) => !entry.startsWith('post:'));
|
||||
assert.deepEqual(
|
||||
actions(),
|
||||
['start:annotate_map'],
|
||||
'the photos wait for the marks',
|
||||
);
|
||||
releaseMarks();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(actions().slice(0, 3), [
|
||||
'start:annotate_map',
|
||||
'end:annotate_map',
|
||||
'start:show_street_level',
|
||||
]);
|
||||
assert.ok(
|
||||
!log.includes('start:stop_tracking'),
|
||||
'the posted view waits for the link',
|
||||
);
|
||||
releaseStreetLevel();
|
||||
for (
|
||||
let tick = 0;
|
||||
tick < 20 && !log.includes('post:gev:view-applied');
|
||||
tick++
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'),
|
||||
true,
|
||||
);
|
||||
assert.ok(log.includes('post:gev:view-applied'), log.join(' '));
|
||||
});
|
||||
|
||||
test('an aborted install never runs the link photos', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
signal: controller.signal,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a passive link stands down when someone navigated while it waited behind its marks', async () => {
|
||||
const calls = [];
|
||||
let releaseMarks = null;
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 7,
|
||||
};
|
||||
const run = async (name) => {
|
||||
calls.push(name);
|
||||
if (name === 'annotate_map')
|
||||
await new Promise((resolve) => {
|
||||
releaseMarks = resolve;
|
||||
});
|
||||
return { ok: true };
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['annotate_map']);
|
||||
shell._navigationGeneration = 8; // the recipient searched, or the assistant flew
|
||||
releaseMarks();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
['annotate_map'],
|
||||
'the link did not fly over the newer navigation',
|
||||
);
|
||||
|
||||
// Unchanged navigation: the photos open as before.
|
||||
const quiet = [];
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 3,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: async (name) => {
|
||||
quiet.push(name);
|
||||
return { ok: true };
|
||||
},
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
|
||||
});
|
||||
|
||||
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(options, [
|
||||
['show_street_level', { origin: 'programmatic' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a start link whose restore a newer link superseded opens nothing', async () => {
|
||||
const { calls, run } = recorder();
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
|
||||
_navigationGeneration: 4,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
|
||||
});
|
||||
|
||||
test('a start link whose restore flight the recipient interrupted leaves the camera with them', async () => {
|
||||
for (const [camera, expected] of [
|
||||
['cancelled', 0],
|
||||
['skipped', 0],
|
||||
['applied', 1],
|
||||
]) {
|
||||
const { calls, run } = recorder();
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve({
|
||||
status: 'settled',
|
||||
share: { camera },
|
||||
}),
|
||||
_navigationGeneration: 4,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, expected, camera);
|
||||
}
|
||||
});
|
||||
|
||||
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
const windowRef = new EventTarget();
|
||||
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 1,
|
||||
};
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location,
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'the start link has no photos');
|
||||
const settled = (status, camera = 'applied') =>
|
||||
windowRef.dispatchEvent(
|
||||
Object.assign(new Event('gev:share-navigation-restore-settled'), {
|
||||
detail: { status, share: { camera } },
|
||||
}),
|
||||
);
|
||||
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
|
||||
shell._navigationGeneration = 2; // the restore's own flight
|
||||
settled('failed');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'a failed restore opens nothing');
|
||||
// The recipient dragged or searched during the restore flight: the camera
|
||||
// is theirs, and the link does not pull it back.
|
||||
shell._navigationGeneration = 3;
|
||||
for (const camera of ['cancelled', 'superseded']) {
|
||||
settled('settled', camera);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
[],
|
||||
`an interrupted restore (${camera}) opens nothing`,
|
||||
);
|
||||
}
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: 48.8874,
|
||||
longitude: 2.3495,
|
||||
provider: 'mapillary',
|
||||
fly: true,
|
||||
autoOpen: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(options.at(-1), [
|
||||
'show_street_level',
|
||||
{ origin: 'programmatic' },
|
||||
]);
|
||||
remove();
|
||||
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1, 'removed with the install');
|
||||
});
|
||||
|
||||
test('a view whose photo step the user overtook leaves their photo and camera alone', async () => {
|
||||
// Photo B, opened by a click while the posted view looked up its photo.
|
||||
const street = { open: false, imageId: null };
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
[
|
||||
'street-level',
|
||||
{
|
||||
module: {
|
||||
closeViewer: () =>
|
||||
Object.assign(street, { open: false, imageId: null }),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => {
|
||||
Object.assign(street, { open: true, imageId: 'b' });
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
cancelled: true,
|
||||
superseded: true,
|
||||
error: 'The camera moved on before the photo opened',
|
||||
};
|
||||
},
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'mapillary' },
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const steps = await applyView(view, { viewer, dataManager, run });
|
||||
assert.deepEqual(street, { open: true, imageId: 'b' }, 'photo B stays');
|
||||
assert.equal(viewer.flights.length, 0, 'the camera stays with the user');
|
||||
assert.equal(steps.at(-1).ok, false, 'reported as not applied');
|
||||
});
|
||||
|
||||
test('a view whose photo was found but could not load keeps its error in view', async () => {
|
||||
const closed = [];
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
imageryError: true,
|
||||
location: '1.00000, 2.00000',
|
||||
error: 'Image could not be opened',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'mapillary' },
|
||||
});
|
||||
await applyView(view, { viewer: fakeViewer(), dataManager, run });
|
||||
assert.equal(closed.length, 0, 'the viewer keeps the new photo’s error');
|
||||
});
|
||||
|
||||
test('a view whose photos fail closes the photo an earlier view opened', async () => {
|
||||
const closed = [];
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
await applyView(view, { viewer, dataManager, run });
|
||||
assert.equal(closed.length, 1, 'closed before the view flies away');
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const opened = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
await applyView(view, { viewer, dataManager, run: opened.run });
|
||||
assert.equal(closed.length, 1, 'an opened photo stays');
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installViews } from './embed.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { createAnnotationEngine } from '../annotations/annotationEngine.js';
|
||||
import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
// A camera taken while a posted view waits on terrain stays with whoever took
|
||||
// it: the stale view stands down whole, so it neither stops a newer follow nor
|
||||
// frames its own marks. Offline: real installViews, action runner and
|
||||
// annotation engine; the viewer, renderer, flights data and terrain are fixtures.
|
||||
globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); };
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function scenario(t, effect) {
|
||||
document.body.className = '';
|
||||
const entered = deferred();
|
||||
const terrain = deferred();
|
||||
const viewer = fakeCesiumViewer();
|
||||
viewer.clock = { onTick: new Cesium.Event() };
|
||||
viewer.canvas = viewer.scene.canvas;
|
||||
// A real perspective frustum looking down at 0,0; the old view's mark
|
||||
// at 48N,2E is outside it. No override of the engine's visibility logic.
|
||||
Object.assign(viewer.camera, {
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 1000),
|
||||
direction: new Cesium.Cartesian3(-1, 0, 0),
|
||||
up: new Cesium.Cartesian3(0, 0, 1),
|
||||
heading: 0,
|
||||
frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }),
|
||||
});
|
||||
const entity = { id: 'fixture-aircraft' };
|
||||
const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 };
|
||||
let stopCalls = 0;
|
||||
const flights = {
|
||||
findByQuery: () => aircraft,
|
||||
trackById: () => { viewer.trackedEntity = entity; return true; },
|
||||
getTrackedInfo: () => viewer.trackedEntity ? aircraft : null,
|
||||
stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; },
|
||||
};
|
||||
const dataManager = {
|
||||
layers: new Map([['flights', { module: flights }]]),
|
||||
getAll: () => [{ id: 'flights', enabled: true }],
|
||||
isEnabled: id => id === 'flights',
|
||||
setLayerParams: () => true,
|
||||
};
|
||||
const shell = {
|
||||
_navigationGeneration: 1,
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); },
|
||||
};
|
||||
const annotations = createAnnotationEngine({ viewer,
|
||||
renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} },
|
||||
});
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations });
|
||||
const calls = [];
|
||||
const run = async (name, args, options) => {
|
||||
calls.push(name);
|
||||
return runner(name, args, options);
|
||||
};
|
||||
const replies = [];
|
||||
const peer = { postMessage: message => replies.push(message) };
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = peer;
|
||||
windowRef.document = document;
|
||||
const remove = installViews({ shell, viewer, dataManager, run, windowRef,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } },
|
||||
});
|
||||
t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; });
|
||||
await tick();
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source: peer, origin: 'https://host.example', data: {
|
||||
type: 'gev:view', id: 1, view: {
|
||||
camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 },
|
||||
layers: ['flights'],
|
||||
street_level: { provider: 'mapillary' },
|
||||
annotations: effect === 'annotations'
|
||||
? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [],
|
||||
},
|
||||
} });
|
||||
windowRef.dispatchEvent(event);
|
||||
await entered.promise;
|
||||
if (effect === 'follow') {
|
||||
const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' });
|
||||
assert.equal(follow.ok, true, 'new follow starts through the real runner');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
} else {
|
||||
viewer.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
}
|
||||
terrain.resolve([{ ellipsoid: 200 }]);
|
||||
for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick();
|
||||
const reply = replies.find(reply => reply.id === 1);
|
||||
t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity,
|
||||
annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply }));
|
||||
assert.ok(reply, 'host receives completion');
|
||||
assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied');
|
||||
assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler');
|
||||
if (effect === 'follow') {
|
||||
assert.equal(stopCalls, 0, 'stale view must not stop the newer follow');
|
||||
assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected');
|
||||
} else {
|
||||
assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera');
|
||||
}
|
||||
}
|
||||
|
||||
for (const effect of ['follow', 'annotations']) {
|
||||
test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect));
|
||||
}
|
||||
@@ -462,8 +462,11 @@ export function panelRuntime(config) {
|
||||
* Load God's Eye View into this page from the app's panel build, in
|
||||
* inline embed mode, starting at the first view as its link would.
|
||||
*/
|
||||
async function startApp(url, view) {
|
||||
async function startApp(url, view, fresh = false) {
|
||||
window.GEV_EMBED_INLINE = true;
|
||||
// Whether this result was just issued, not handed back by a host that
|
||||
// mounted the panel again: only then may its link open a billed photo.
|
||||
window.GEV_EMBED_FRESH = fresh;
|
||||
window.GEV_APP_BASE_URL = config.appBaseUrl;
|
||||
try {
|
||||
history.replaceState(
|
||||
@@ -551,13 +554,21 @@ export function panelRuntime(config) {
|
||||
);
|
||||
}
|
||||
|
||||
// Tool results: the first view starts the app, later ones move it.
|
||||
// Tool results: the first view starts the app, later ones move it. A
|
||||
// result issued longer ago than this was handed back on a remount.
|
||||
const FRESH_RESULT_MS = 5 * 60 * 1000;
|
||||
let started = false;
|
||||
let ready = false;
|
||||
let queued = null;
|
||||
let queuedFresh = false;
|
||||
let currentUrl = null;
|
||||
const postView = (view) =>
|
||||
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
|
||||
// Every view carries whether its result was issued just now: only those may
|
||||
// open a billed photo, whichever result on this mount they came in.
|
||||
const postView = (view, fresh) =>
|
||||
window.postMessage(
|
||||
{ type: 'gev:view', id: nextId++, view, fresh: fresh === true },
|
||||
'*',
|
||||
);
|
||||
function show(result) {
|
||||
const data = result?.structuredContent;
|
||||
const view = data?.view;
|
||||
@@ -565,13 +576,18 @@ export function panelRuntime(config) {
|
||||
if (!view || !url) return;
|
||||
currentUrl = url;
|
||||
open.hidden = false;
|
||||
if (ready) return postView(view);
|
||||
const issuedAt = Number(data.issued_at);
|
||||
const fresh =
|
||||
Number.isFinite(issuedAt) &&
|
||||
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
|
||||
if (ready) return postView(view, fresh);
|
||||
queued = view;
|
||||
queuedFresh = fresh;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
startApp(url, view, fresh).catch((error) =>
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
@@ -592,7 +608,7 @@ export function panelRuntime(config) {
|
||||
if (message?.type === 'gev:ready') {
|
||||
ready = true;
|
||||
status.remove();
|
||||
if (queued) postView(queued);
|
||||
if (queued) postView(queued, queuedFresh);
|
||||
queued = null;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { panelRuntime } from './globePanelRuntime.js';
|
||||
|
||||
const element = () => ({
|
||||
isConnected: true,
|
||||
hidden: true,
|
||||
textContent: '',
|
||||
addEventListener() {},
|
||||
remove() {
|
||||
this.isConnected = false;
|
||||
},
|
||||
});
|
||||
|
||||
test('every view the panel posts says whether its result was issued just now', async () => {
|
||||
const savedWindow = globalThis.window;
|
||||
const savedDocument = globalThis.document;
|
||||
const views = [];
|
||||
const parent = { postMessage() {} };
|
||||
const windowRef = new EventTarget();
|
||||
Object.assign(windowRef, {
|
||||
parent,
|
||||
postMessage: (message) => {
|
||||
if (message?.type === 'gev:view') views.push(message);
|
||||
},
|
||||
});
|
||||
const elements = { status: element(), open: element(), expand: element() };
|
||||
globalThis.window = windowRef;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => elements[id] ?? null,
|
||||
addEventListener() {},
|
||||
baseURI: 'https://host.example/panel',
|
||||
body: { clientWidth: 600 },
|
||||
};
|
||||
const deliver = (source, data) => {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source, data });
|
||||
windowRef.dispatchEvent(event);
|
||||
};
|
||||
const result = (issuedAt) => ({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: {
|
||||
structuredContent: {
|
||||
url: 'http://localhost:4173/#sl=google,48.85840,2.29450',
|
||||
issued_at: issuedAt,
|
||||
view: { camera: { lat: 48.8584, lon: 2.2945 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
panelRuntime({
|
||||
appBaseUrl: 'http://localhost:4173/',
|
||||
panelBase: '/panel/',
|
||||
protocolVersion: '2025-06-18',
|
||||
panelHeight: 480,
|
||||
});
|
||||
deliver(windowRef, { type: 'gev:ready' });
|
||||
deliver(parent, result(Date.now()));
|
||||
// A host replaying an old result on the same mount.
|
||||
deliver(parent, result(Date.now() - 60 * 60 * 1000));
|
||||
deliver(parent, result(undefined));
|
||||
} finally {
|
||||
globalThis.window = savedWindow;
|
||||
globalThis.document = savedDocument;
|
||||
}
|
||||
assert.deepEqual(
|
||||
views.map((message) => message.fresh),
|
||||
[true, false, false],
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createShipwrecksLayer } from '../../layers/shipwrecks/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire the EMODnet + Wikidata shipwrecks layer into the application catalog. */
|
||||
export function createApplicationShipwrecks(options) {
|
||||
return createShipwrecksLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -49,7 +49,9 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
'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']),
|
||||
|
||||
@@ -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, 38);
|
||||
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(
|
||||
|
||||
+21
-2
@@ -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';
|
||||
@@ -241,9 +246,23 @@ export function createApplicationTools({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
run: (name, args, options = {}) =>
|
||||
voiceCommands.runner(name, args, { ...options, signal }),
|
||||
signal,
|
||||
terrain: operations.requests.terrain,
|
||||
}),
|
||||
);
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import { mapAnalystRecord } from './aisLiveVessels.js';
|
||||
|
||||
const FULL_RECORD = {
|
||||
id: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -20,6 +21,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
const r = mapAnalystRecord(FULL_RECORD);
|
||||
assert.deepEqual(r, {
|
||||
id: 'EVER GIVEN',
|
||||
vesselId: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -38,6 +40,16 @@ test('ais analyst record: nameless vessel falls back to mmsi id', () => {
|
||||
assert.equal(r.name, null);
|
||||
});
|
||||
|
||||
test('ais analyst record: the key is the source id; the MMSI rides along when known', () => {
|
||||
const shared = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-a' });
|
||||
assert.equal(shared.vesselId, 'provider-a');
|
||||
assert.equal(shared.mmsi, '353136000');
|
||||
const noMmsi = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-b', mmsi: '', name: '' });
|
||||
assert.equal(noMmsi.vesselId, 'provider-b');
|
||||
assert.equal(noMmsi.mmsi, null);
|
||||
assert.equal(noMmsi.id, 'provider-b', 'a nameless vessel with no MMSI is labelled by its id');
|
||||
});
|
||||
|
||||
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
|
||||
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
|
||||
assert.equal(r.mmsi, null);
|
||||
|
||||
@@ -287,7 +287,7 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 456,
|
||||
@@ -314,8 +314,8 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
lastMessageAt: 789,
|
||||
rawRowCount: 1,
|
||||
acceptedRowCount: 0,
|
||||
selectedMmsi: record.mmsi,
|
||||
trailMmsi: record.mmsi,
|
||||
selectedId: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositionCount: 1,
|
||||
sessionId: _getVesselFeedStateForTest().sessionId,
|
||||
firstConnectPhase: 'idle',
|
||||
@@ -565,7 +565,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
records: [replacement],
|
||||
selectedRecord: replacement,
|
||||
trail: replacementTrail,
|
||||
trailMmsi: replacement.mmsi,
|
||||
trailId: replacement.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 200,
|
||||
@@ -581,7 +581,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
let feed = _getVesselFeedStateForTest();
|
||||
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
|
||||
assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness');
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
|
||||
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
|
||||
await replacementLoad;
|
||||
@@ -590,8 +590,8 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
assert.equal(feed.lastUpdate, 200);
|
||||
assert.equal(feed.error, 'awaiting first AIS message…');
|
||||
assert.equal(feed.stale, true);
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.trailMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
assert.equal(feed.trailId, replacement.mmsi);
|
||||
assert.equal(replacementTrail.clearCalls, 0);
|
||||
} finally {
|
||||
globalThis.fetch = priorFetch;
|
||||
@@ -784,21 +784,21 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas
|
||||
|
||||
test('vessel selection: empty-space click requests deselection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: '353136000', gesture: 'click' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: no selection plus empty-space click is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: null, pickedId: null, gesture: 'click' }),
|
||||
{ action: 'none' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
|
||||
reduceVesselSelection({ selectedId: 353136000, gesture: 'escape' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -810,16 +810,16 @@ test('vessel selection: Escape deselects only when this layer owns a selection',
|
||||
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '367123450',
|
||||
selectedId: '353136000',
|
||||
pickedId: '367123450',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'select' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '353136000',
|
||||
selectedId: '353136000',
|
||||
pickedId: '353136000',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'none' },
|
||||
@@ -834,8 +834,9 @@ test('vessel selection: another vessel replaces selection; same vessel is a no-o
|
||||
|
||||
const POS = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3);
|
||||
|
||||
// Like the layer's records, a vessel with no id of its own is keyed by its MMSI.
|
||||
function makeRecord(overrides = {}) {
|
||||
return {
|
||||
const record = {
|
||||
name: 'EVER GIVEN',
|
||||
mmsi: '353136000',
|
||||
type: 'Container Ship',
|
||||
@@ -849,6 +850,8 @@ function makeRecord(overrides = {}) {
|
||||
billboard: null,
|
||||
...overrides,
|
||||
};
|
||||
if (!Object.hasOwn(overrides, 'id')) record.id = record.mmsi;
|
||||
return record;
|
||||
}
|
||||
|
||||
function makeTrailSpy() {
|
||||
@@ -948,7 +951,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory,
|
||||
interactionKeyTarget: keyTarget,
|
||||
@@ -983,7 +986,7 @@ test('vessel interaction wire: trail pick does not deselect', () => {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
|
||||
assert.equal(_getVesselStateForTest().trailId, harness.record.mmsi);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1213,7 +1216,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
|
||||
selectedRecord: harness.record,
|
||||
enabled: false,
|
||||
trail: harness.trail,
|
||||
trailMmsi: harness.record.mmsi,
|
||||
trailId: harness.record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory: harness.interactionHandlerFactory,
|
||||
interactionKeyTarget: harness.keyTarget,
|
||||
@@ -1287,7 +1290,7 @@ test('vessel trail lifecycle: deselect clears the selected-vessel trail', () =>
|
||||
aisLiveVesselsLayer.clearSelection();
|
||||
assert.equal(harness.trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 1,
|
||||
});
|
||||
@@ -1302,14 +1305,14 @@ test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail',
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
});
|
||||
try {
|
||||
_reconcileVesselsForTest({}, []);
|
||||
assert.equal(trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 0,
|
||||
});
|
||||
@@ -1566,6 +1569,28 @@ test('vessel interaction wire: selecting a vessel by click requests a camera tra
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: a vessel with an id and no MMSI is selected by click', () => {
|
||||
const idOnly = makeRecord({ id: 'provider-7', mmsi: '', name: 'NO MMSI' });
|
||||
const harness = installWireHarness(
|
||||
{ id: idOnly },
|
||||
{ records: [idOnly], selectedRecord: null, trailId: null },
|
||||
);
|
||||
const requests = [];
|
||||
harness.windowTarget.addEventListener(
|
||||
WORLD_FOCUS_REQUEST_EVENT,
|
||||
(event) => requests.push(event.detail),
|
||||
);
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.id, 'provider-7');
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, '');
|
||||
assert.equal(requests[0]?.id, 'provider-7');
|
||||
assert.match(harness.hud.readout.text, /MMSI: -- /);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Overlay-host recorder whose hitTest answers with one card id. The real host
|
||||
* only publishes rects for `interactive` entries, so a stub returning a hit is
|
||||
@@ -1689,11 +1714,27 @@ for (const card of ['live', 'stale', 'absent', 'covered']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
|
||||
test('vessel card policy: only keyed cards publish a hit rect', () => {
|
||||
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
|
||||
assert.equal(keyed.interactive, true);
|
||||
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
|
||||
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
|
||||
const idOnly = buildVesselCard(makeRecord({ id: 'provider-7', mmsi: '' }));
|
||||
assert.equal(applyVesselOverlayPolicy(idOnly).interactive, true, 'a source id is a key');
|
||||
assert.equal(idOnly.id, 'vessel:provider-7');
|
||||
});
|
||||
|
||||
test('vessel cards are keyed by id and show the MMSI only when known', () => {
|
||||
const shared = buildSelectedVesselCard(makeRecord({ id: 'hull-b' }));
|
||||
assert.equal(shared.id, 'vessel:hull-b');
|
||||
assert.equal(shared.actionable, true);
|
||||
assert.match(shared.details.at(-1), /^MMSI 353136000 · /);
|
||||
const unknown = buildSelectedVesselCard(
|
||||
makeRecord({ id: 'provider-7', mmsi: '', name: 'provider-7' }),
|
||||
);
|
||||
assert.equal(unknown.actionable, true);
|
||||
assert.match(unknown.details.at(-1), /^MMSI -- · /);
|
||||
assert.equal(unknown.title, 'VESSEL', 'a name that is only the id is no name');
|
||||
});
|
||||
|
||||
test('vessel interaction wire: deselecting never moves the camera', () => {
|
||||
@@ -1740,7 +1781,7 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
getSnapshot: async () => ({ records: [] }),
|
||||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
|
||||
});
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailId: null });
|
||||
try {
|
||||
harness.record.reference = 'opaque:test-reference';
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
@@ -1802,3 +1843,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
|
||||
test('detection objects name the vessel card their callout gives way to', () => {
|
||||
const vessel = (mmsi, name) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
const keyed = vessel('200', 'GOLDEN BEAR');
|
||||
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
|
||||
_setVesselStateForTest({
|
||||
viewer: {},
|
||||
records: [keyed, unkeyed],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
const objects = Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
|
||||
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
|
||||
assert.equal(
|
||||
objects['NO MMSI'].cardEntryId,
|
||||
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
|
||||
'the same identity the vessel card is published under',
|
||||
);
|
||||
} finally {
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
@@ -1518,3 +1518,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: {
|
||||
'surveillance:type': 'ALPR',
|
||||
operator: ' Şehir\n\u0000 PD ',
|
||||
manufacturer: '😀'.repeat(200),
|
||||
'camera:type': null,
|
||||
'surveillance:zone': '\t traffic zone ',
|
||||
},
|
||||
});
|
||||
assert.equal(record.operator, 'Şehir PD');
|
||||
assert.equal(record.manufacturer.length, 180);
|
||||
assert.equal(record.manufacturer, '😀'.repeat(90));
|
||||
assert.equal(record.cameraType, null);
|
||||
assert.equal(record.zone, 'traffic zone');
|
||||
});
|
||||
@@ -134,6 +134,13 @@ export const DATA_CREDITS = [
|
||||
'copied, published, or incorporated into other databases without the ' +
|
||||
'explicit permission of David J Taylor, Edinburgh',
|
||||
},
|
||||
{
|
||||
key: 'planespotters',
|
||||
html:
|
||||
'Aircraft photos: ' +
|
||||
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
|
||||
'· each photo is © its photographer, credited beside it',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
@@ -566,6 +573,38 @@ export const DATA_CREDITS = [
|
||||
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
|
||||
'Shown as published; reports are S2 assessments, not verified fact.',
|
||||
},
|
||||
{
|
||||
key: 'news',
|
||||
html:
|
||||
'News: every report is credited to its own publisher and links to the original article; ' +
|
||||
'pictures are the publishers’ own, shown from their sites with the publisher named. ' +
|
||||
'Places, categories, severity and English headlines are derived automatically, by ' +
|
||||
'bundled rules, the news provider or the configured news server, and can be wrong.',
|
||||
},
|
||||
{
|
||||
// GDELT's terms ask for a citation and a link.
|
||||
key: 'gdelt-news',
|
||||
html:
|
||||
'News, Conflicts without a news server: events and page titles from the ' +
|
||||
'<a href="https://www.gdeltproject.org/" target="_blank" rel="noopener">GDELT Project</a> ' +
|
||||
'(GDELT 2.0 events and GKG; places and categories are machine-coded)',
|
||||
},
|
||||
{
|
||||
// World News API's free plan asks for a visible "World News API" link to
|
||||
// its site; always listed, so a free-plan key is never shown without it.
|
||||
key: 'worldnewsapi',
|
||||
html:
|
||||
'News, Local without a news server: headlines from ' +
|
||||
'<a href="https://worldnewsapi.com/" target="_blank" rel="noopener">World News API</a>',
|
||||
},
|
||||
{
|
||||
key: 'shipwrecks',
|
||||
html:
|
||||
'Shipwrecks (Europe): <a href="https://emodnet.ec.europa.eu/en/human-activities" target="_blank" rel="noopener">EMODnet Human Activities</a> ' +
|
||||
'(data originator AND-International, compiled from SHOM, Ireland’s National Monuments Service, Historic England and the Oxford Roman Economy Project), ' +
|
||||
'licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>' +
|
||||
' · Shipwrecks (worldwide): <a href="https://www.wikidata.org/" target="_blank" rel="noopener">Wikidata</a> (CC0)',
|
||||
},
|
||||
{
|
||||
key: 'drivebc-cctv',
|
||||
html:
|
||||
@@ -632,6 +671,15 @@ export const TOMTOM_CREDIT = {
|
||||
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
|
||||
};
|
||||
|
||||
/** Registered when the News layer shows a Liveuamap report (only with
|
||||
* LIVEUAMAP_API_KEY set and no news server). */
|
||||
export const LIVEUAMAP_CREDIT = {
|
||||
key: 'liveuamap',
|
||||
html:
|
||||
'News, Conflicts: mapped reports from ' +
|
||||
'<a href="https://liveuamap.com/" target="_blank" rel="noopener">Liveuamap</a>',
|
||||
};
|
||||
|
||||
/** Registered when the first Natural Earth region or state/province outline
|
||||
* resolves (public domain — no attribution required; credited as a courtesy). */
|
||||
export const NATURAL_EARTH_CREDIT = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DATA_CREDITS,
|
||||
LIVEUAMAP_CREDIT,
|
||||
NYC_DOT_DISCLAIMER,
|
||||
transitFeedCredit,
|
||||
} from './dataCredits.js';
|
||||
@@ -43,6 +44,13 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('Planespotters is credited with a plain followable link', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'planespotters');
|
||||
assert.ok(credit, 'the tracked aircraft photos come from Planespotters');
|
||||
assert.match(credit.html, /href="https:\/\/www\.planespotters\.net"/);
|
||||
assert.doesNotMatch(credit.html, /nofollow/);
|
||||
});
|
||||
|
||||
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
|
||||
// UCS database manual, section 3: references name the version (the Excel
|
||||
// file's name) and link www.ucsusa.org/satellite_database.
|
||||
@@ -182,6 +190,26 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the News layer credits its built-in sources', () => {
|
||||
const credit = (key) => DATA_CREDITS.find((entry) => entry.key === key);
|
||||
// GDELT's terms ask for a citation and a link.
|
||||
assert.match(
|
||||
credit('gdelt-news').html,
|
||||
/href="https:\/\/www\.gdeltproject\.org\/"[^>]*>GDELT Project</,
|
||||
);
|
||||
// World News API's free plan asks for this exact link.
|
||||
assert.match(
|
||||
credit('worldnewsapi').html,
|
||||
/<a href="https:\/\/worldnewsapi\.com\/"[^>]*>World News API<\/a>/,
|
||||
);
|
||||
assert.match(LIVEUAMAP_CREDIT.html, />Liveuamap</);
|
||||
assert.ok(
|
||||
!DATA_CREDITS.some((entry) => entry.key === LIVEUAMAP_CREDIT.key),
|
||||
'Liveuamap is credited only once its key is in use',
|
||||
);
|
||||
assert.doesNotMatch(credit('news').html, /No single provider/);
|
||||
});
|
||||
|
||||
test('the PR #1506 camera packs are credited with their sources and terms', () => {
|
||||
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
|
||||
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
|
||||
|
||||
+109
-4
@@ -21,7 +21,10 @@ import {
|
||||
getKeyholeFadeTuning,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
getQueuedOverlayEntryAlpha,
|
||||
registerWorldOverlayPaintLane,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
DETECTION_STYLE,
|
||||
DETECTION_THEME_MAP,
|
||||
@@ -47,6 +50,8 @@ import {
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
||||
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
cyberSonarFrameDelay,
|
||||
@@ -107,6 +112,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
|
||||
const SPARSE_ZONE_FRACTION = 0.5;
|
||||
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
|
||||
const LABEL_SOLVE_INTERVAL_MS = 125;
|
||||
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
|
||||
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
|
||||
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
||||
const BRACKET_ALPHA_STEPS = 4;
|
||||
/** Finer bands for Global Context echo brackets, whose fade is animated. */
|
||||
@@ -263,6 +270,22 @@ let _allocationStrategy = ALLOCATION_ELASTIC;
|
||||
let _lastNonOffMode = MODE_BALANCED;
|
||||
/** @type {LabelArbiter} Shared stable callout selector. */
|
||||
const _labelArbiter = new LabelArbiter();
|
||||
/**
|
||||
* Hides the bracket and callout of a depth-tested contact (a street vehicle)
|
||||
* that the scene drew something in front of. `_occlusionProbe` asks the GPU
|
||||
* about each such contact without waiting for the answer; `_surfaceOcclusion`
|
||||
* remembers the answers and decides, every frame, who is drawn.
|
||||
*/
|
||||
const _occlusionProbe = new SceneOcclusionProbe();
|
||||
const _surfaceOcclusion = new SurfaceOcclusion(_occlusionProbe);
|
||||
/**
|
||||
* Keys of callouts the label arbiter still holds for contacts last seen
|
||||
* hidden. A key stays here until its contact is seen drawn again or the
|
||||
* arbiter lets the callout go, so the callout cannot come back at its old
|
||||
* place just because its contact has since left the view.
|
||||
*/
|
||||
const _surfaceSuppressedKeys = new Set();
|
||||
let _surfaceDepthFallbackReported = false;
|
||||
let _lastLabelSolveAt = 0;
|
||||
let _labelSolveDirty = true;
|
||||
let _lastDiagnostics = null;
|
||||
@@ -367,6 +390,9 @@ export function destroyDetection() {
|
||||
_suspended = false;
|
||||
_suspendReason = '';
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.destroy();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastDiagnostics = null;
|
||||
@@ -576,7 +602,8 @@ export function getDetectionDiagnostics() {
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
.filter((row) => row.paintAlpha > 0.001)
|
||||
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -691,6 +718,9 @@ function _applyModeState() {
|
||||
_syncSurfaceVisibility();
|
||||
_setLanesActive(false);
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.reset();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastSolveSnapshot = {
|
||||
@@ -1077,6 +1107,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
cardSourceId: '',
|
||||
cardEntryId: '',
|
||||
paintAlpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -1103,6 +1136,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
row.font = FONT;
|
||||
row.microFont = MICRO_FONT;
|
||||
row.alpha = alpha;
|
||||
row.cardSourceId = candidate.cardSourceId;
|
||||
row.cardEntryId = candidate.cardEntryId;
|
||||
_calloutCount++;
|
||||
}
|
||||
|
||||
@@ -1134,7 +1169,15 @@ function _paintCalloutLane(frame) {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
const row = _calloutPool[i];
|
||||
paintDetectionCallout(ctx, row, row.alpha);
|
||||
// The callout would show through its contact's own card: fade it by the
|
||||
// opacity that card paints with this frame.
|
||||
const cardAlpha =
|
||||
row.cardEntryId === ''
|
||||
? 0
|
||||
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
|
||||
row.paintAlpha =
|
||||
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
|
||||
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -1211,6 +1254,8 @@ function _materializeCandidate(
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
|
||||
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1316,6 +1361,9 @@ function _drawOverlay(frame) {
|
||||
// every host lane. Detection retains the manual scalar projection below.
|
||||
const occluder = frame.occluder;
|
||||
const camPos = frame.cameraPosition;
|
||||
// Answers to the occlusion tests started on earlier frames land here.
|
||||
_surfaceOcclusion.beginFrame(now);
|
||||
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
||||
const keyhole = frame.keyhole;
|
||||
// Read the OUTSIDE setting ONCE per paint, from the same module state the
|
||||
// host's own keyhole alpha comes from, so a bracket and its callout can never
|
||||
@@ -1426,6 +1474,29 @@ function _drawOverlay(frame) {
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
// Something the scene drew in front of a depth-tested contact hides its
|
||||
// bracket and callout too, on every frame, not only on solves.
|
||||
if (obj.depthTested === true) {
|
||||
const testedLayerId = obj._layerId || 'unknown';
|
||||
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
||||
const hidden = _surfaceOcclusion.isHidden(
|
||||
testedLayerId,
|
||||
testedSourceId,
|
||||
obj.position,
|
||||
);
|
||||
// If the arbiter holds a callout for this contact it would keep painting
|
||||
// it until the next solve, and then fade it out where it last stood;
|
||||
// see `_surfaceSuppressedKeys`. Only held contacts cost a key.
|
||||
if (
|
||||
(hidden || _surfaceSuppressedKeys.size > 0) &&
|
||||
renderIdentities.get(testedLayerId)?.has(testedSourceId)
|
||||
) {
|
||||
const key = _detectionKey(testedLayerId, testedSourceId);
|
||||
if (hidden) _surfaceSuppressedKeys.add(key);
|
||||
else _surfaceSuppressedKeys.delete(key);
|
||||
}
|
||||
if (hidden) continue;
|
||||
}
|
||||
const sonarFactor =
|
||||
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
|
||||
const sonarLabelFactor = sonarSampler.label(sonarFactor);
|
||||
@@ -1526,6 +1597,8 @@ function _drawOverlay(frame) {
|
||||
candidateMap.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
// Start this frame's occlusion tests; nothing here waits for them.
|
||||
_occlusionProbe.endFrame();
|
||||
|
||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||
@@ -1536,6 +1609,13 @@ function _drawOverlay(frame) {
|
||||
let didSolve = false;
|
||||
let solveMs = 0;
|
||||
if (shouldSolve) {
|
||||
_surfaceOcclusion.prune();
|
||||
if (_occlusionProbe.mode === 'sync' && !_surfaceDepthFallbackReported) {
|
||||
_surfaceDepthFallbackReported = true;
|
||||
console.info(
|
||||
'[detection] No occlusion queries here (WebGL 1); street-vehicle label occlusion reads the frame depth back synchronously, about 8 times a second.',
|
||||
);
|
||||
}
|
||||
const quotas = allocateLayerQuotas(
|
||||
demandByLayer,
|
||||
ambientBudget,
|
||||
@@ -1622,7 +1702,25 @@ function _drawOverlay(frame) {
|
||||
_ctx.lineWidth = bracketWidth;
|
||||
}
|
||||
|
||||
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
||||
let renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
||||
// A callout whose contact has gone behind something goes with it at once:
|
||||
// no carry-over to the next solve and no fade painted on the facade.
|
||||
if (_surfaceSuppressedKeys.size > 0) {
|
||||
const allEntries = renderEntries;
|
||||
renderEntries = allEntries.filter(
|
||||
(entry) => !_surfaceSuppressedKeys.has(entry.candidate.key),
|
||||
);
|
||||
// The arbiter has let some of them go: forget those.
|
||||
if (
|
||||
allEntries.length - renderEntries.length !==
|
||||
_surfaceSuppressedKeys.size
|
||||
) {
|
||||
const held = new Set();
|
||||
for (const entry of allEntries) held.add(entry.candidate.key);
|
||||
for (const key of _surfaceSuppressedKeys)
|
||||
if (!held.has(key)) _surfaceSuppressedKeys.delete(key);
|
||||
}
|
||||
}
|
||||
const fadingCount = countFadingRenderEntries(renderEntries);
|
||||
// Demand counts fades in BOTH directions; `fadingCount` above stays the
|
||||
// fade-OUT tail because that is what the published diagnostics have always
|
||||
@@ -1677,6 +1775,13 @@ function _drawOverlay(frame) {
|
||||
keyholeRadius: keyhole.radius,
|
||||
keyholeFeatherPx: keyhole.featherPx,
|
||||
calloutOcclusionCount: calloutOcclusionRects.length,
|
||||
surfaceTestedCount: _surfaceOcclusion.testedCount,
|
||||
surfaceHiddenCount: _surfaceOcclusion.hiddenCount,
|
||||
surfaceDepthMode: _occlusionProbe.mode,
|
||||
// Tests started on the last frame, and what collecting answers and
|
||||
// drawing them cost it.
|
||||
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
||||
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
||||
};
|
||||
} else {
|
||||
const selectedCount = _labelArbiter.selectedKeys.size;
|
||||
|
||||
@@ -20,15 +20,28 @@ import {
|
||||
} from './detection.js';
|
||||
import {
|
||||
destroyWorldOverlay,
|
||||
getOverlayPaintRect,
|
||||
initWorldOverlay,
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import {
|
||||
getKeyholeFadeTuning,
|
||||
getKeyholeGeometry,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
import {
|
||||
createFakeDepthScene,
|
||||
createFakeGl,
|
||||
depthImage,
|
||||
viewDistanceToDepth,
|
||||
} from './fixtures/fakeDepthScene.mjs';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(countFadingRenderEntries([
|
||||
@@ -579,6 +592,178 @@ test('every style paints its OWN plate token through the production callout path
|
||||
}
|
||||
});
|
||||
|
||||
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
|
||||
function shipWithCard(mmsi, position) {
|
||||
return {
|
||||
position,
|
||||
sourceId: mmsi,
|
||||
id: `SHIP-${mmsi}`,
|
||||
metric: '6 kn',
|
||||
type: 'SEA',
|
||||
cardSourceId: 'ais-live-vessels',
|
||||
cardEntryId: `vessel:${mmsi}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The production ambient vessel card (not protected: it goes through the arbiter). */
|
||||
function ambientVesselCard(mmsi, position) {
|
||||
return applyVesselOverlayPolicy({
|
||||
id: `vessel:${mmsi}`,
|
||||
position,
|
||||
title: `CARD-${mmsi}`,
|
||||
details: ['CARGO · 6KT · 80°'],
|
||||
gapPx: 10,
|
||||
});
|
||||
}
|
||||
|
||||
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
|
||||
// Default keyhole tuning: 7% feather, 1% outside opacity.
|
||||
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
|
||||
const width = 1600;
|
||||
const height = 900;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
const env = installEnvironment({ width, height, dpr: 1 });
|
||||
// Just outside the keyhole's left rim: the card sits above or below the
|
||||
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
|
||||
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
|
||||
const ship = shipWithCard('200', position);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
|
||||
assert.ok(rect, 'the host paints the card (at the outside opacity)');
|
||||
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
|
||||
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
|
||||
|
||||
const callouts = getDetectionDiagnostics().calloutRects;
|
||||
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
|
||||
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
|
||||
const ship = shipWithCard('200', at(0.1, 0.1));
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
const frame = (ms) => {
|
||||
env.ctx.calls.length = 0;
|
||||
env.advance(ms);
|
||||
env.postRender.raise();
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
for (let i = 0; i < 6; i++) frame(250);
|
||||
frame(16);
|
||||
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
|
||||
|
||||
// The vessel layer republishes its screen-grid cohort after a camera move:
|
||||
// this vessel's card is replaced by another one, the vessel stays detectable.
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
|
||||
frame(16);
|
||||
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
|
||||
assert.ok(painted('CARD-200'), 'and its text still paints');
|
||||
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
|
||||
|
||||
for (let i = 0; i < 4; i++) frame(250);
|
||||
assert.ok(!painted('CARD-200'), 'the card has faded out');
|
||||
assert.ok(painted('SHIP-200'), 'and the callout is back');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a vessel whose ambient card loses the collision keeps its callout', () => {
|
||||
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
|
||||
// Four vessels a few pixels apart: their cards collide (a card goes only
|
||||
// above or below its anchor), so the arbiter paints only some of them.
|
||||
const ships = [0, 1, 2, 3].map((i) =>
|
||||
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
|
||||
setOverlayEntries(
|
||||
'ais-live-vessels',
|
||||
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
|
||||
);
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
|
||||
assert.ok(
|
||||
winners.length >= 1 && winners.length < ships.length,
|
||||
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
|
||||
);
|
||||
for (const ship of ships) {
|
||||
const carded = winners.includes(ship);
|
||||
assert.equal(
|
||||
painted(ship.id),
|
||||
!carded,
|
||||
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
getDetectionDiagnostics().calloutRects.length,
|
||||
ships.length - winners.length,
|
||||
'only the callouts that painted are reported',
|
||||
);
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout fades out while its card fades in instead of vanishing at once', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
const alphas = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
|
||||
}
|
||||
assert.ok(
|
||||
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
|
||||
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
|
||||
);
|
||||
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
|
||||
// Satellites sit over the high-albedo lit Earth disc far more often than
|
||||
// aircraft do, so the space tier carries the heavier backing. Losing the
|
||||
@@ -1190,3 +1375,289 @@ test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
|
||||
});
|
||||
|
||||
/**
|
||||
* Give the host environment a scene that keeps frame depth: the camera 10,000
|
||||
* km up the +Z axis looking down it, and one depth range in which the whole
|
||||
* frame is a surface `surfaceDistance()` metres in front of the camera.
|
||||
*/
|
||||
function installDepthScene(env, surfaceDistance, { webgl2 = true } = {}) {
|
||||
const near = 1;
|
||||
const far = 100_000_000;
|
||||
const gl = createFakeGl({ webgl2 });
|
||||
const camera = env.viewer.camera;
|
||||
camera.viewMatrix = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 0, -10_000_000));
|
||||
camera.frustum.fovy = Math.PI / 2;
|
||||
camera.frustum.aspectRatio = 800 / 600;
|
||||
const paint = () =>
|
||||
depthImage(8, 6, () => viewDistanceToDepth(surfaceDistance(), near, far, true));
|
||||
const image = paint();
|
||||
const scene = createFakeDepthScene({ gl, ranges: [{ near, far, image }] });
|
||||
Object.assign(env.viewer.scene, scene, { camera });
|
||||
return {
|
||||
gl,
|
||||
/** Draw the next frame's depth. */
|
||||
redraw() {
|
||||
image.data.set(paint().data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Street contacts 3,643 km and 2,000 km below the camera, and a flight. */
|
||||
function occlusionLayers() {
|
||||
return [
|
||||
{
|
||||
id: 'traffic',
|
||||
getDetectableObjects: () => [
|
||||
{ position: new Cesium.Cartesian3(-0.4, 0, 6_356_752), id: 'VEH-0001', type: 'VEH', depthTested: true },
|
||||
{ position: new Cesium.Cartesian3(0.4, 0, 8_000_000), id: 'VEH-0002', type: 'VEH', depthTested: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'flights',
|
||||
// The same nearer surface is in front of it, but it is not depth-tested.
|
||||
getDetectableObjects: () => [
|
||||
{ position: new Cesium.Cartesian3(-0.4, 0.3, 6_356_752), sourceId: 'f1', id: 'AIR1', metric: 'FL300', type: 'AIR' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function paintedText(env) {
|
||||
return env.ctx.calls.filter(([name]) => name === 'fillText').map(([, text]) => text);
|
||||
}
|
||||
|
||||
/** One rendered frame 16 ms on; `settleFrame` is one 250 ms on (a solve). */
|
||||
function nextFrame(env) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
}
|
||||
|
||||
test('a depth-tested contact behind drawn geometry loses its bracket and callout, tested on the GPU without anything read back', () => {
|
||||
const env = installEnvironment();
|
||||
// A roof 3,000 km below the camera: in front of VEH-0001, behind VEH-0002.
|
||||
let surface = 3_000_000;
|
||||
const depth = installDepthScene(env, () => surface);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
let diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'async');
|
||||
assert.equal(diagnostics.surfaceTestedCount, 2, 'only the depth-tested contacts');
|
||||
assert.equal(diagnostics.surfaceProbeTests, 2);
|
||||
// New to the view, so held back for the frame or two the first answer
|
||||
// takes: neither is drawn yet. The flight is.
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 2, 'no answer has arrived yet');
|
||||
assert.equal(diagnostics.visibleCount, 1);
|
||||
assert.equal(depth.gl.counts.queries, 3, 'a control point and one query per contact');
|
||||
|
||||
// Frames before the GPU answers start nothing new and wait for nothing.
|
||||
nextFrame(env);
|
||||
nextFrame(env);
|
||||
assert.equal(depth.gl.counts.queries, 3);
|
||||
assert.equal(depth.gl.counts.resultReads, 0);
|
||||
|
||||
// The answers land. The contact above the roof is drawn from then on; the
|
||||
// one under it never is.
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'));
|
||||
assert.ok(paintedText(env).includes('VEH-0002'));
|
||||
|
||||
// It stays gone, frame after frame and solve after solve.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
|
||||
}
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 1);
|
||||
assert.equal(diagnostics.visibleCount, 2, 'the hidden contact has no bracket');
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0002'));
|
||||
assert.ok(!diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
assert.ok(diagnostics.labeledKeys.includes('flights:f1'));
|
||||
assert.ok(paintedText(env).includes('AIR1'));
|
||||
assert.equal(depth.gl.counts.readPixels, 0, 'depth never left the GPU');
|
||||
|
||||
// The roof goes away: the bracket returns once visible answers have kept
|
||||
// coming for the reappear time, and the callout once a solve re-admits it.
|
||||
surface = 5_000_000;
|
||||
depth.redraw();
|
||||
for (let i = 0; i < 80; i++) {
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
}
|
||||
diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a labelled contact that goes behind something loses its callout at once, and does not get it back by leaving the view', () => {
|
||||
const env = installEnvironment();
|
||||
// To begin with the drawn surface is below both vehicles.
|
||||
let surface = 5_000_000;
|
||||
const depth = installDepthScene(env, () => surface);
|
||||
const layers = occlusionLayers();
|
||||
const everyVehicle = layers[0].getDetectableObjects;
|
||||
const answerAndSolve = (count) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
}
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, layers, () => {});
|
||||
setMode('DENSE');
|
||||
answerAndSolve(6);
|
||||
assert.ok(getDetectionDiagnostics().labeledKeys.includes('traffic:VEH-0001'));
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(paintedText(env).includes('VEH-0001'));
|
||||
|
||||
// A roof is drawn over VEH-0001. Its callout goes on the frame the answer
|
||||
// lands, which is not a solve: no carry-over, no fade.
|
||||
surface = 3_000_000;
|
||||
depth.redraw();
|
||||
let gone = -1;
|
||||
for (let i = 0; i < 30 && gone < 0; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
if (!paintedText(env).includes('VEH-0001')) gone = i;
|
||||
}
|
||||
assert.ok(gone >= 0, 'the callout went');
|
||||
// The layer stops reporting it (it drove out of view). The arbiter still
|
||||
// remembers where its callout was; it must not paint it there again.
|
||||
layers[0].getDetectableObjects = () => everyVehicle().slice(1);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
|
||||
}
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('without scene depth every contact keeps its bracket and callout', () => {
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'off');
|
||||
assert.equal(diagnostics.surfaceTestedCount, 2);
|
||||
assert.equal(diagnostics.surfaceProbeTests, 0);
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scene depth that goes away puts hidden contacts back', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
|
||||
// The scene stops keeping pick depth (a 2D view, depth picking turned off).
|
||||
depth.gl.finishGpu();
|
||||
env.viewer.scene.useDepthPicking = false;
|
||||
// The hidden time runs on painted frames, so paint a second's worth.
|
||||
for (let i = 0; i < 70; i++) nextFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'off');
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning detection off drops every test in flight; tearing it down frees the GPU objects', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
assert.equal(depth.gl.liveQueries.size, 3);
|
||||
// Off keeps the GPU objects (the shader is not compiled again on the next
|
||||
// on) but the answers in flight are no longer wanted.
|
||||
setMode('OFF');
|
||||
assert.equal(depth.gl.counts.deleted, 0);
|
||||
depth.gl.finishGpu();
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
assert.equal(depth.gl.counts.queries, 6, 'both contacts are tested afresh');
|
||||
assert.equal(depth.gl.liveQueries.size, 3, 'on the same queries, reused');
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
settleFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
|
||||
assert.equal(getDetectionDiagnostics().visibleCount, 2);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
// Tearing detection down releases everything.
|
||||
assert.equal(depth.gl.liveQueries.size, 0);
|
||||
assert.equal(depth.gl.counts.deleted, 5);
|
||||
});
|
||||
|
||||
test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000, { webgl2: false });
|
||||
const originalInfo = console.info;
|
||||
const reported = [];
|
||||
console.info = (...args) => reported.push(args.join(' '));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 30; i++) nextFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'sync');
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 1);
|
||||
assert.equal(diagnostics.visibleCount, 2);
|
||||
// Half a second of frames: a read about every eighth of one, not one per
|
||||
// frame and not one per contact.
|
||||
assert.ok(depth.gl.counts.readPixels >= 3 && depth.gl.counts.readPixels <= 6, `${depth.gl.counts.readPixels} reads`);
|
||||
assert.equal(reported.length, 1);
|
||||
assert.match(reported[0], /synchronously/);
|
||||
} finally {
|
||||
console.info = originalInfo;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Scene-geometry occlusion for detection contacts.
|
||||
*
|
||||
* The overlay's own horizon test only knows about the Earth's curve. A contact
|
||||
* whose marker the scene depth-tests (a street vehicle's dot) can still sit
|
||||
* behind a building, a 3D tile or a hill, and painting its bracket and callout
|
||||
* there puts a label on the facade in front of it.
|
||||
*
|
||||
* The fix compares each such contact with what the renderer actually drew: if
|
||||
* the drawn surface along the contact's ray is nearer than the contact by more
|
||||
* than a small margin, something stands in front of it and its bracket and
|
||||
* callout are not drawn. `sceneOcclusionProbe.js` runs that comparison on the
|
||||
* GPU, against the frame's own depth, and hands back a yes or no per contact a
|
||||
* frame or two later without ever waiting on the GPU. This file holds the
|
||||
* arithmetic both sides share and the per-contact memory of the answers.
|
||||
*
|
||||
* Only contacts that opt in with `depthTested: true` are tested: the ones whose
|
||||
* marker the scene itself hides behind geometry. Everything else keeps its
|
||||
* label exactly as before, which covers contacts high above the ground
|
||||
* (aircraft, satellites): their markers are drawn on top of the scene by
|
||||
* design, their 3D models can be scaled up to a minimum pixel size and so fill
|
||||
* the very pixel being read, and a label that hid while its marker stayed on
|
||||
* screen would be the worse mismatch.
|
||||
*
|
||||
* This file is plain arithmetic; it never touches the renderer.
|
||||
*/
|
||||
|
||||
/** Never less than this: covers a dot drawn a little above or into its road. */
|
||||
export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3;
|
||||
/** The most the margin grows with range: 2% of it. */
|
||||
export const SURFACE_OCCLUSION_MARGIN_FRACTION = 0.02;
|
||||
/**
|
||||
* How far above a contact a drawn surface on its line of sight must stand to
|
||||
* count as in front of it, when the camera looks down on the contact.
|
||||
*/
|
||||
export const SURFACE_OCCLUSION_RISE_M = 2;
|
||||
|
||||
/**
|
||||
* How much nearer than the contact the drawn surface must be before it counts
|
||||
* as standing in front of it.
|
||||
*
|
||||
* The margin absorbs a contact placed a little above or below the drawn
|
||||
* ground: a vehicle's position and the scene's road surface are not the same
|
||||
* height to the metre. Looking down at the contact, a point on its line of
|
||||
* sight `s` metres in front of it stands `s * sinElevation` metres above it,
|
||||
* so a surface there only hides the contact once `s` passes
|
||||
* `SURFACE_OCCLUSION_RISE_M / sinElevation`. That keeps the margin to a few
|
||||
* metres from above, where a fixed share of the range would let a wall,
|
||||
* awning or tree 10 to 20 m in front of a street at 1 km pass as "the road".
|
||||
* At grazing angles, or looking up, the margin is 2% of the range at most
|
||||
* (never under 3 m); depth precision is far finer than that at every range
|
||||
* the overlay labels.
|
||||
*
|
||||
* @param {number} contactDistance Camera-to-contact distance in metres.
|
||||
* @param {number} [sinElevation] Sine of the camera's elevation seen from the
|
||||
* contact (1 straight overhead, 0 level, negative below); unknown when
|
||||
* omitted.
|
||||
* @returns {number} Margin in metres.
|
||||
*/
|
||||
export function surfaceOcclusionMargin(contactDistance, sinElevation = 0) {
|
||||
const distance = Number(contactDistance);
|
||||
if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M;
|
||||
let margin = distance * SURFACE_OCCLUSION_MARGIN_FRACTION;
|
||||
const rise = Number(sinElevation);
|
||||
if (rise > 0) margin = Math.min(margin, SURFACE_OCCLUSION_RISE_M / rise);
|
||||
return Math.max(SURFACE_OCCLUSION_MIN_MARGIN_M, margin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a drawn surface hides a contact.
|
||||
* @param {number} contactDistance Camera-to-contact distance in metres.
|
||||
* @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance
|
||||
* along the contact's ray; undefined or non-finite when nothing was drawn.
|
||||
* @param {number} [sinElevation] See `surfaceOcclusionMargin`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function surfaceHidesContact(
|
||||
contactDistance,
|
||||
surfaceDistance,
|
||||
sinElevation = 0,
|
||||
) {
|
||||
if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance))
|
||||
return false;
|
||||
return (
|
||||
surfaceDistance <
|
||||
contactDistance - surfaceOcclusionMargin(contactDistance, sinElevation)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack a depth the renderer stored across the four bytes of an RGBA8 texel
|
||||
* (most significant byte first, each byte a base-255 digit).
|
||||
* @param {ArrayLike<number>} bytes
|
||||
* @param {number} offset Index of the texel's red byte.
|
||||
* @returns {number} Window-space depth in [0, 1].
|
||||
*/
|
||||
export function unpackDepth(bytes, offset) {
|
||||
return (
|
||||
bytes[offset] / 255 +
|
||||
bytes[offset + 1] / 65025 +
|
||||
bytes[offset + 2] / 16581375 +
|
||||
bytes[offset + 3] / 4228250625
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a window-space depth to the distance in front of the camera, measured
|
||||
* along the view direction, for one depth range ("frustum") of the frame.
|
||||
*
|
||||
* Logarithmic depth stores `log2(z - near + 1) / log2(far - near + 1)`.
|
||||
* Standard perspective depth stores `far * (z - near) / (z * (far - near))`.
|
||||
*
|
||||
* @param {number} depth Window-space depth in [0, 1].
|
||||
* @param {number} near Near plane of the range, metres.
|
||||
* @param {number} far Far plane of the range, metres.
|
||||
* @param {boolean} logDepth Whether the frame was drawn with logarithmic depth.
|
||||
* @returns {number} Metres along the view direction, or NaN where the range
|
||||
* drew nothing (depth at or outside the ends of [0, 1]).
|
||||
*/
|
||||
export function depthToViewDistance(depth, near, far, logDepth) {
|
||||
if (!(depth > 0) || !(depth < 1)) return Number.NaN;
|
||||
if (!(near > 0) || !(far > near)) return Number.NaN;
|
||||
if (logDepth)
|
||||
return near + Math.pow(2, depth * Math.log2(far - near + 1)) - 1;
|
||||
return (near * far) / (far - depth * (far - near));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a world position falls in a frame, and how near a drawn surface has to
|
||||
* be there to hide it.
|
||||
*
|
||||
* A surface hides the contact when its distance along the contact's ray is
|
||||
* under the contact's own distance less the margin. Along one ray, distance
|
||||
* and view distance (depth in front of the camera) are in a fixed ratio, so
|
||||
* the same rule reads: the surface's view distance is under
|
||||
* `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`,
|
||||
* and comparing a decoded depth with it is all the GPU has to do.
|
||||
*
|
||||
* The margin depends on how steeply the camera looks down on the contact
|
||||
* (see `surfaceOcclusionMargin`). "Up" at the contact is taken as the
|
||||
* direction from the world origin to it, which is the Earth's centre in the
|
||||
* Earth-fixed frame the scene is drawn in; it differs from the true vertical
|
||||
* by a fraction of a degree at most.
|
||||
*
|
||||
* @param {ArrayLike<number>} view World-to-camera matrix, column-major, the
|
||||
* camera looking down its own -Z axis.
|
||||
* @param {number} tanHalfFovX
|
||||
* @param {number} tanHalfFovY
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @param {{u: number, v: number, threshold: number}} out `u`, `v` in [0, 1)
|
||||
* from the bottom-left corner of the frame.
|
||||
* @returns {boolean} False when the position is behind the camera or outside
|
||||
* the frame; `out` is then untouched.
|
||||
*/
|
||||
export function projectContact(view, tanHalfFovX, tanHalfFovY, position, out) {
|
||||
const { x, y, z } = position;
|
||||
const viewDistance = -(view[2] * x + view[6] * y + view[10] * z + view[14]);
|
||||
if (!(viewDistance > 0)) return false;
|
||||
const ex = view[0] * x + view[4] * y + view[8] * z + view[12];
|
||||
const ey = view[1] * x + view[5] * y + view[9] * z + view[13];
|
||||
const u = (ex / (viewDistance * tanHalfFovX)) * 0.5 + 0.5;
|
||||
const v = (ey / (viewDistance * tanHalfFovY)) * 0.5 + 0.5;
|
||||
if (!(u >= 0 && u < 1 && v >= 0 && v < 1)) return false;
|
||||
const contactDistance = Math.sqrt(
|
||||
ex * ex + ey * ey + viewDistance * viewDistance,
|
||||
);
|
||||
// Up at the contact, turned into camera axes, against the direction from
|
||||
// the contact to the camera (-ex, -ey, viewDistance in camera axes).
|
||||
const radius = Math.sqrt(x * x + y * y + z * z);
|
||||
let sinElevation = 0;
|
||||
if (radius > 0) {
|
||||
const ux = (view[0] * x + view[4] * y + view[8] * z) / radius;
|
||||
const uy = (view[1] * x + view[5] * y + view[9] * z) / radius;
|
||||
const uz = (view[2] * x + view[6] * y + view[10] * z) / radius;
|
||||
sinElevation = (-ux * ex - uy * ey + uz * viewDistance) / contactDistance;
|
||||
}
|
||||
out.u = u;
|
||||
out.v = v;
|
||||
out.threshold =
|
||||
viewDistance *
|
||||
(1 -
|
||||
surfaceOcclusionMargin(contactDistance, sinElevation) / contactDistance);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of depth read back to memory, with the camera it was drawn from.
|
||||
* Only the synchronous fallback uses this (see `sceneOcclusionProbe.js`); the
|
||||
* normal path never copies depth off the GPU.
|
||||
*
|
||||
* `bytes` holds `frustums.length` stacked images of `width` x `height` RGBA8
|
||||
* texels, nearest depth range first, rows bottom to top (the order the
|
||||
* graphics API reads them back in).
|
||||
*/
|
||||
export class DepthSnapshot {
|
||||
/**
|
||||
* @param {{
|
||||
* width: number, height: number, bytes: Uint8Array,
|
||||
* frustums: Array<{near: number, far: number}>, logDepth: boolean,
|
||||
* view: ArrayLike<number>, tanHalfFovX: number, tanHalfFovY: number,
|
||||
* }} fields
|
||||
*/
|
||||
constructor(fields) {
|
||||
this.width = fields.width;
|
||||
this.height = fields.height;
|
||||
this.bytes = fields.bytes;
|
||||
this.frustums = fields.frustums;
|
||||
this.logDepth = fields.logDepth === true;
|
||||
this.view = fields.view;
|
||||
this.tanHalfFovX = fields.tanHalfFovX;
|
||||
this.tanHalfFovY = fields.tanHalfFovY;
|
||||
this._projected = { u: 0, v: 0, threshold: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance along the view direction to the nearest surface drawn at a texel.
|
||||
* @param {number} column
|
||||
* @param {number} row Counted from the bottom of the frame.
|
||||
* @returns {number} Metres, or NaN where nothing was drawn.
|
||||
*/
|
||||
surfaceViewDistance(column, row) {
|
||||
const { width, height, bytes, frustums, logDepth } = this;
|
||||
const texel = (row * width + column) * 4;
|
||||
const layer = width * height * 4;
|
||||
for (let i = 0; i < frustums.length; i++) {
|
||||
const distance = depthToViewDistance(
|
||||
unpackDepth(bytes, i * layer + texel),
|
||||
frustums[i].near,
|
||||
frustums[i].far,
|
||||
logDepth,
|
||||
);
|
||||
if (distance === distance) return distance;
|
||||
}
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the frame drew something in front of a world position.
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @returns {boolean|null} Null when the position is outside the frame.
|
||||
*/
|
||||
hides(position) {
|
||||
const projected = this._projected;
|
||||
if (
|
||||
!projectContact(
|
||||
this.view,
|
||||
this.tanHalfFovX,
|
||||
this.tanHalfFovY,
|
||||
position,
|
||||
projected,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
this.surfaceViewDistance(
|
||||
Math.floor(projected.u * this.width),
|
||||
Math.floor(projected.v * this.height),
|
||||
) < projected.threshold
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact is tested again at most this long, and at least half this long,
|
||||
* after its last test was started, so a view full of contacts spreads its
|
||||
* tests over about ten frames instead of repeating all of them on every one.
|
||||
* With more contacts in view than the probe's per-frame budget can serve at
|
||||
* that pace, the interval stretches (see `SurfaceOcclusion.beginFrame`).
|
||||
*/
|
||||
export const SURFACE_RETEST_MS = 200;
|
||||
|
||||
/**
|
||||
* A contact stays hidden at least this long after the last test that found it
|
||||
* hidden, and never less than three retest intervals.
|
||||
*
|
||||
* Going behind something takes effect as soon as the answer arrives. Coming
|
||||
* back takes several visible answers in a row, so a contact on a silhouette
|
||||
* edge, which tests either way from one frame to the next while the camera
|
||||
* orbits, stays off instead of blinking. It is also the clean failure: when
|
||||
* answers stop arriving (no depth to test against) every contact is drawn
|
||||
* again within this time, exactly as before this test existed.
|
||||
*/
|
||||
export const SURFACE_REAPPEAR_MS = 600;
|
||||
|
||||
/**
|
||||
* A contact that has never had an answer is held back this long, at most,
|
||||
* while a working probe gets it one.
|
||||
*
|
||||
* A contact new to the view would otherwise be drawn for the frame or two its
|
||||
* first test takes, and then vanish if it turned out to be behind something:
|
||||
* a camera orbiting a city would flash a bracket for every vehicle that
|
||||
* swings into the frame behind a building. With no working probe nothing is
|
||||
* held back, and past this time the contact is drawn whatever happens.
|
||||
*/
|
||||
export const SURFACE_FIRST_ANSWER_MS = 400;
|
||||
|
||||
/**
|
||||
* The most one frame advances the occlusion clock.
|
||||
*
|
||||
* Every interval above is measured on a clock that only runs while the
|
||||
* overlay is painting: each painted frame moves it on by the time since the
|
||||
* last one, but never by more than this. Answers only arrive while frames are
|
||||
* painted, so on a wall clock a pause (a hidden tab, a scene with nothing to
|
||||
* redraw) would run out every "hidden" at once and the first frame afterwards
|
||||
* would paint every label over the buildings again until fresh answers came.
|
||||
* On this clock a pause of any length counts as one slow frame.
|
||||
*/
|
||||
export const SURFACE_MAX_FRAME_STEP_MS = 50;
|
||||
|
||||
/** A contact not drawn for this long is forgotten. */
|
||||
export const SURFACE_FORGET_MS = 2000;
|
||||
|
||||
/**
|
||||
* What is remembered about one contact.
|
||||
* @typedef {{lastHiddenAt: number, hiddenUntil: number, appliedHiddenAt: number,
|
||||
* queuedAt: number, pending: boolean, answered: boolean, firstSeenAt: number,
|
||||
* seenAt: number}} SurfaceContact Times are on the occlusion clock.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-contact occlusion answers, and the schedule that keeps them fresh.
|
||||
*
|
||||
* `probe` is whatever tests a contact against the drawn scene. It has
|
||||
* `add(contact, position)`, which starts a test (setting `contact.pending`)
|
||||
* and returns false when it cannot take one now, and it reports back by
|
||||
* clearing `contact.pending`, setting `contact.answered` and, when the
|
||||
* contact was hidden, setting `contact.lastHiddenAt`. `probe.maxPerFrame`, if
|
||||
* set, is how many tests it takes in one frame; `probe.mode` reads `'async'`
|
||||
* while answers come a frame or two after the test.
|
||||
*/
|
||||
export class SurfaceOcclusion {
|
||||
/**
|
||||
* @param {{add: (contact: SurfaceContact, position: object) => boolean}} probe
|
||||
*/
|
||||
constructor(probe) {
|
||||
this.probe = probe;
|
||||
/** @type {Map<string, Map<*, SurfaceContact>>} Layer id -> source id. */
|
||||
this.contacts = new Map();
|
||||
/**
|
||||
* The occlusion clock, in milliseconds of painted time (see
|
||||
* `SURFACE_MAX_FRAME_STEP_MS`). The probe must date its answers by it.
|
||||
*/
|
||||
this.now = 0;
|
||||
this._wall = Number.NEGATIVE_INFINITY;
|
||||
/** Depth-tested contacts drawn or hidden this frame. */
|
||||
this.testedCount = 0;
|
||||
this.hiddenCount = 0;
|
||||
this.retestMs = SURFACE_RETEST_MS;
|
||||
this.reappearMs = SURFACE_REAPPEAR_MS;
|
||||
this.firstAnswerMs = SURFACE_FIRST_ANSWER_MS;
|
||||
this._phase = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a frame and move the occlusion clock on.
|
||||
* @param {number} wallNow Monotonic milliseconds.
|
||||
*/
|
||||
beginFrame(wallNow) {
|
||||
// Contacts arrive in the same order every frame and the probe takes only
|
||||
// so many, so if more fell due per frame than it takes, the ones at the
|
||||
// end of the order would never be tested. Pace the retests to what the
|
||||
// last frame showed the probe can keep up with: all of the contacts, once
|
||||
// per interval, with a third of the budget to spare.
|
||||
const elapsed = wallNow - this._wall;
|
||||
const frameMs =
|
||||
elapsed > 0 ? Math.min(SURFACE_MAX_FRAME_STEP_MS, elapsed) : 0;
|
||||
this._wall = wallNow;
|
||||
const budget = Number(this.probe.maxPerFrame) || Number.POSITIVE_INFINITY;
|
||||
this.retestMs = Math.max(
|
||||
SURFACE_RETEST_MS,
|
||||
(this.testedCount / budget) * frameMs * 2,
|
||||
);
|
||||
this.reappearMs = Math.max(SURFACE_REAPPEAR_MS, this.retestMs * 3);
|
||||
// At a stretched pace a crowd of new contacts takes longer to get its
|
||||
// first answers; the hold-back stretches with it.
|
||||
this.firstAnswerMs = Math.max(SURFACE_FIRST_ANSWER_MS, this.retestMs * 2);
|
||||
this.now += frameMs;
|
||||
this.testedCount = 0;
|
||||
this.hiddenCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a contact's bracket and callout should be left out of this frame.
|
||||
* Starts a fresh test of the contact when one is due.
|
||||
* @param {string} layerId
|
||||
* @param {*} sourceId
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHidden(layerId, sourceId, position) {
|
||||
const now = this.now;
|
||||
let layer = this.contacts.get(layerId);
|
||||
if (!layer) {
|
||||
layer = new Map();
|
||||
this.contacts.set(layerId, layer);
|
||||
}
|
||||
let contact = layer.get(sourceId);
|
||||
if (!contact || now - contact.seenAt > this.retestMs) {
|
||||
// New, or back in view after longer than a retest interval. A returning
|
||||
// contact's last answer is older than any contact that stayed in view
|
||||
// runs on, and the view has probably turned since: it is held back
|
||||
// like a new one until a fresh answer comes, or orbiting flashes it
|
||||
// behind the building it went behind while out of view. It gets a new
|
||||
// record, so a test still in flight from before answers the old one
|
||||
// and cannot end the hold-back with what the previous view showed. A
|
||||
// "hidden" it already had keeps running.
|
||||
contact = {
|
||||
lastHiddenAt: contact?.lastHiddenAt ?? Number.NEGATIVE_INFINITY,
|
||||
hiddenUntil: contact?.hiddenUntil ?? Number.NEGATIVE_INFINITY,
|
||||
appliedHiddenAt: contact?.appliedHiddenAt ?? Number.NEGATIVE_INFINITY,
|
||||
queuedAt: Number.NEGATIVE_INFINITY,
|
||||
pending: false,
|
||||
answered: false,
|
||||
firstSeenAt: now,
|
||||
seenAt: now,
|
||||
};
|
||||
layer.set(sourceId, contact);
|
||||
}
|
||||
contact.seenAt = now;
|
||||
this.testedCount++;
|
||||
if (
|
||||
!contact.pending &&
|
||||
now - contact.queuedAt >= this.retestMs &&
|
||||
this.probe.add(contact, position)
|
||||
) {
|
||||
// Contacts that came into view together would otherwise all fall due on
|
||||
// the same frames for ever. Each test backdates itself by a different
|
||||
// part of half the interval, which spreads them out within a second.
|
||||
contact.queuedAt = now - this.retestMs * 0.5 * this._phase;
|
||||
this._phase = (this._phase + 0.618034) % 1;
|
||||
}
|
||||
// A new "hidden" answer sets when the contact may come back, once, with
|
||||
// the pace in force now. A later change of pace must not bring back a
|
||||
// "hidden" that has already run out.
|
||||
if (contact.lastHiddenAt !== contact.appliedHiddenAt) {
|
||||
contact.appliedHiddenAt = contact.lastHiddenAt;
|
||||
contact.hiddenUntil = contact.lastHiddenAt + this.reappearMs;
|
||||
}
|
||||
const hidden =
|
||||
now < contact.hiddenUntil ||
|
||||
// Not yet answered: held back while a working probe finds out.
|
||||
(!contact.answered &&
|
||||
this.probe.mode === 'async' &&
|
||||
now - contact.firstSeenAt < this.firstAnswerMs);
|
||||
if (hidden) this.hiddenCount++;
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/** Forget contacts that are no longer drawn. */
|
||||
prune() {
|
||||
for (const [layerId, layer] of this.contacts) {
|
||||
for (const [sourceId, contact] of layer) {
|
||||
if (this.now - contact.seenAt > SURFACE_FORGET_MS)
|
||||
layer.delete(sourceId);
|
||||
}
|
||||
if (layer.size === 0) this.contacts.delete(layerId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget every contact (mode off, viewer swap). */
|
||||
clear() {
|
||||
this.contacts.clear();
|
||||
this.testedCount = 0;
|
||||
this.hiddenCount = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DepthSnapshot,
|
||||
SURFACE_FIRST_ANSWER_MS,
|
||||
SURFACE_FORGET_MS,
|
||||
SURFACE_MAX_FRAME_STEP_MS,
|
||||
SURFACE_OCCLUSION_MIN_MARGIN_M,
|
||||
SURFACE_REAPPEAR_MS,
|
||||
SURFACE_RETEST_MS,
|
||||
SurfaceOcclusion,
|
||||
depthToViewDistance,
|
||||
projectContact,
|
||||
surfaceHidesContact,
|
||||
surfaceOcclusionMargin,
|
||||
unpackDepth,
|
||||
} from './detectionOcclusion.js';
|
||||
import {
|
||||
depthImage,
|
||||
packDepth,
|
||||
viewDistanceToDepth,
|
||||
} from './fixtures/fakeDepthScene.mjs';
|
||||
|
||||
test('the margin is 3 m near the camera and 2% of the range beyond 150 m', () => {
|
||||
assert.equal(surfaceOcclusionMargin(10), SURFACE_OCCLUSION_MIN_MARGIN_M);
|
||||
assert.equal(surfaceOcclusionMargin(150), 3);
|
||||
assert.equal(surfaceOcclusionMargin(500), 10);
|
||||
assert.equal(surfaceOcclusionMargin(5000), 100);
|
||||
assert.equal(surfaceOcclusionMargin(0), 3);
|
||||
assert.equal(surfaceOcclusionMargin(Number.NaN), 3);
|
||||
});
|
||||
|
||||
test('looking down on a contact, the margin is what a 2 m rise on its line of sight takes', () => {
|
||||
// 1 km away, seen from 30 degrees up: 2 m of rise is 4 m along the ray.
|
||||
assert.ok(Math.abs(surfaceOcclusionMargin(1000, 0.5) - 4) < 1e-9);
|
||||
// Straight down: the 3 m floor.
|
||||
assert.equal(surfaceOcclusionMargin(1000, 1), SURFACE_OCCLUSION_MIN_MARGIN_M);
|
||||
// Grazing, level or from below: never more than 2% of the range.
|
||||
assert.equal(surfaceOcclusionMargin(1000, 0.01), 20);
|
||||
assert.equal(surfaceOcclusionMargin(1000, 0), 20);
|
||||
assert.equal(surfaceOcclusionMargin(1000, -0.3), 20);
|
||||
assert.equal(surfaceOcclusionMargin(1000, Number.NaN), 20);
|
||||
assert.equal(surfaceOcclusionMargin(100, 0.05), 3);
|
||||
// A low wall 15 m in front of a street 1 km off, seen from 25 degrees up,
|
||||
// stands about 6 m above the vehicle's line of sight: it hides it.
|
||||
const sin25 = Math.sin((25 * Math.PI) / 180);
|
||||
assert.equal(surfaceHidesContact(1000, 985, sin25), true);
|
||||
assert.equal(surfaceHidesContact(1000, 985), false, 'unknown angle: 2%');
|
||||
assert.equal(surfaceHidesContact(1000, 996, sin25), false, 'its own road');
|
||||
});
|
||||
|
||||
test('a surface hides a contact only when nearer by more than the margin', () => {
|
||||
// 500 m away: margin 10 m.
|
||||
assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front');
|
||||
assert.equal(surfaceHidesContact(500, 491), false, 'its own road or dot');
|
||||
assert.equal(surfaceHidesContact(500, 500), false);
|
||||
assert.equal(surfaceHidesContact(500, 520), false, 'the ground behind it');
|
||||
// Close range keeps the 3 m floor.
|
||||
assert.equal(surfaceHidesContact(60, 56), true);
|
||||
assert.equal(surfaceHidesContact(60, 58), false);
|
||||
});
|
||||
|
||||
test('nothing drawn, or no distance, never hides a contact', () => {
|
||||
assert.equal(surfaceHidesContact(500, undefined), false);
|
||||
assert.equal(surfaceHidesContact(500, Number.NaN), false);
|
||||
assert.equal(surfaceHidesContact(Number.NaN, 10), false);
|
||||
});
|
||||
|
||||
test('packed depth unpacks most significant byte first', () => {
|
||||
assert.equal(unpackDepth([0, 0, 0, 0], 0), 0);
|
||||
assert.equal(unpackDepth([255, 0, 0, 0], 0), 1);
|
||||
assert.equal(unpackDepth([9, 9, 51, 0, 0, 0], 2), 0.2);
|
||||
for (const depth of [0.000123, 0.25, 0.5, 0.987654321]) {
|
||||
const round = unpackDepth(packDepth(depth), 0);
|
||||
assert.ok(Math.abs(round - depth) < 1e-9, `${depth} -> ${round}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('logarithmic depth decodes to metres in front of the camera', () => {
|
||||
const near = 0.1;
|
||||
const far = 10_000_000;
|
||||
// depth = log2(z - near + 1) / log2(far - near + 1)
|
||||
for (const z of [0.5, 12, 480, 25_000, 3_000_000]) {
|
||||
const depth = Math.log2(z - near + 1) / Math.log2(far - near + 1);
|
||||
const decoded = depthToViewDistance(depth, near, far, true);
|
||||
assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('standard perspective depth decodes to metres in front of the camera', () => {
|
||||
const near = 1;
|
||||
const far = 5000;
|
||||
// depth = far * (z - near) / (z * (far - near))
|
||||
for (const z of [1.5, 40, 900, 4999]) {
|
||||
const depth = (far * (z - near)) / (z * (far - near));
|
||||
const decoded = depthToViewDistance(depth, near, far, false);
|
||||
assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(depthToViewDistance(0.5, 1, 5000, false) - 10000 / 5001) < 1e-9,
|
||||
'half depth sits just past twice the near plane',
|
||||
);
|
||||
});
|
||||
|
||||
test('the ends of the depth range, and a bad range, mean nothing was drawn', () => {
|
||||
for (const log of [true, false]) {
|
||||
assert.ok(Number.isNaN(depthToViewDistance(0, 1, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(1, 1, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(Number.NaN, 1, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(0.5, 0, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(0.5, 100, 100, log)));
|
||||
}
|
||||
});
|
||||
|
||||
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
/** A point `distance` metres ahead, `side` of the way to the frame edge. */
|
||||
const ahead = (distance, side = 0, up = 0) => ({
|
||||
x: side * distance,
|
||||
y: up * distance,
|
||||
z: -distance,
|
||||
});
|
||||
|
||||
test('a contact projects to its place in the frame, from the bottom-left corner', () => {
|
||||
const out = { u: -1, v: -1, threshold: -1 };
|
||||
// 90 degree field of view: the frame edge is as far out as the point is ahead.
|
||||
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500), out), true);
|
||||
assert.deepEqual([out.u, out.v], [0.5, 0.5]);
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, ahead(500, -0.5, 0.5), out),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual([out.u, out.v], [0.25, 0.75]);
|
||||
// A wider horizontal field of view pulls the same point towards the middle.
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 2, 1, ahead(500, -0.5, 0.5), out),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual([out.u, out.v], [0.375, 0.75]);
|
||||
});
|
||||
|
||||
test('the threshold is the margin rule restated as a view distance', () => {
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
// Straight ahead, distance and view distance are the same thing.
|
||||
projectContact(IDENTITY, 1, 1, ahead(500), out);
|
||||
assert.ok(Math.abs(out.threshold - 490) < 1e-9, '500 m less 2%');
|
||||
projectContact(IDENTITY, 1, 1, ahead(60), out);
|
||||
assert.ok(Math.abs(out.threshold - 57) < 1e-9, '60 m less the 3 m floor');
|
||||
// Off axis the ray is longer than the view distance; the rule holds along
|
||||
// the ray, so a surface at view distance d sits d * range / viewDistance out.
|
||||
const corner = ahead(400, -0.9, 0.9);
|
||||
const range = Math.hypot(corner.x, corner.y, corner.z);
|
||||
projectContact(IDENTITY, 1, 1, corner, out);
|
||||
for (const surfaceView of [out.threshold - 0.01, out.threshold + 0.01]) {
|
||||
const alongRay = (surfaceView * range) / 400;
|
||||
assert.equal(
|
||||
surfaceHidesContact(range, alongRay),
|
||||
surfaceView < out.threshold,
|
||||
);
|
||||
}
|
||||
assert.ok(Math.abs(out.threshold - 400 * 0.98) < 1e-9);
|
||||
});
|
||||
|
||||
test('the threshold uses how steeply the camera looks down on the contact', () => {
|
||||
// A street on a sphere the size of the Earth, under a camera 1 km up and
|
||||
// 1 km back along the surface, looking straight at it: 45 degrees down.
|
||||
const R = 6_371_000;
|
||||
const street = { x: 0, y: 0, z: R };
|
||||
const camera = { x: 0, y: -1000, z: R + 1000 };
|
||||
// Camera axes: x along world x, looking along (0, 1, -1) / sqrt 2.
|
||||
const s = Math.SQRT1_2;
|
||||
const forward = [0, s, -s];
|
||||
const up = [0, s, s];
|
||||
const right = [1, 0, 0];
|
||||
const t = (axis) =>
|
||||
-(axis[0] * camera.x + axis[1] * camera.y + axis[2] * camera.z);
|
||||
// Rows: right, up, -forward (the camera looks down its -Z).
|
||||
const back = forward.map((c) => -c);
|
||||
const view = [
|
||||
right[0],
|
||||
up[0],
|
||||
back[0],
|
||||
0,
|
||||
right[1],
|
||||
up[1],
|
||||
back[1],
|
||||
0,
|
||||
right[2],
|
||||
up[2],
|
||||
back[2],
|
||||
0,
|
||||
t(right),
|
||||
t(up),
|
||||
t(back),
|
||||
1,
|
||||
];
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
assert.equal(projectContact(view, 1, 1, street, out), true);
|
||||
assert.ok(Math.abs(out.u - 0.5) < 1e-9 && Math.abs(out.v - 0.5) < 1e-9);
|
||||
const range = Math.SQRT2 * 1000;
|
||||
// 2 m of rise at 45 degrees is 2.83 m along the ray: the 3 m floor, not
|
||||
// 2% of 1414 m (28 m).
|
||||
assert.ok(Math.abs(out.threshold - (range - 3)) < 1e-6, `${out.threshold}`);
|
||||
});
|
||||
|
||||
test('off the middle of the frame, with the camera turned, the threshold matches the world-space rule', () => {
|
||||
// Rotations about the three axes, then a camera placed and turned freely;
|
||||
// the threshold must equal the margin rule worked out in world space.
|
||||
const rot = (ax, ay, az) => {
|
||||
const [cx, sx, cy, sy, cz, sz] = [
|
||||
Math.cos(ax),
|
||||
Math.sin(ax),
|
||||
Math.cos(ay),
|
||||
Math.sin(ay),
|
||||
Math.cos(az),
|
||||
Math.sin(az),
|
||||
];
|
||||
// Rows of Rz * Ry * Rx.
|
||||
return [
|
||||
[cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx],
|
||||
[sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx],
|
||||
[-sy, cy * sx, cy * cx],
|
||||
];
|
||||
};
|
||||
const R = 6_371_000;
|
||||
let checked = 0;
|
||||
for (let k = 0; k < 200; k++) {
|
||||
// A deterministic spread of poses.
|
||||
const a = (n) => Math.sin(k * 12.9898 + n * 78.233) * 3;
|
||||
const m = rot(a(1), a(2), a(3)); // world-to-camera rotation
|
||||
const lat = a(4) / 3;
|
||||
const lon = a(5);
|
||||
const contact = {
|
||||
x: R * Math.cos(lat) * Math.cos(lon),
|
||||
y: R * Math.cos(lat) * Math.sin(lon),
|
||||
z: R * Math.sin(lat),
|
||||
};
|
||||
// Camera 300 to 2300 m from the contact, in a direction above or below it.
|
||||
const d = [a(6), a(7), a(8)];
|
||||
const dl = Math.hypot(...d) || 1;
|
||||
const dist = 1300 + 1000 * Math.sin(k);
|
||||
const cam = {
|
||||
x: contact.x + (d[0] / dl) * dist,
|
||||
y: contact.y + (d[1] / dl) * dist,
|
||||
z: contact.z + (d[2] / dl) * dist,
|
||||
};
|
||||
const t = m.map(
|
||||
(row) => -(row[0] * cam.x + row[1] * cam.y + row[2] * cam.z),
|
||||
);
|
||||
const view = [
|
||||
m[0][0],
|
||||
m[1][0],
|
||||
m[2][0],
|
||||
0,
|
||||
m[0][1],
|
||||
m[1][1],
|
||||
m[2][1],
|
||||
0,
|
||||
m[0][2],
|
||||
m[1][2],
|
||||
m[2][2],
|
||||
0,
|
||||
t[0],
|
||||
t[1],
|
||||
t[2],
|
||||
1,
|
||||
];
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
if (!projectContact(view, 2, 2, contact, out)) continue;
|
||||
checked++;
|
||||
const toCam = [cam.x - contact.x, cam.y - contact.y, cam.z - contact.z];
|
||||
const range = Math.hypot(...toCam);
|
||||
const sinElevation =
|
||||
(toCam[0] * contact.x + toCam[1] * contact.y + toCam[2] * contact.z) /
|
||||
(range * R);
|
||||
const viewDistance = -(
|
||||
m[2][0] * contact.x +
|
||||
m[2][1] * contact.y +
|
||||
m[2][2] * contact.z +
|
||||
t[2]
|
||||
);
|
||||
const expected =
|
||||
viewDistance * (1 - surfaceOcclusionMargin(range, sinElevation) / range);
|
||||
assert.ok(
|
||||
Math.abs(out.threshold - expected) < 1e-6,
|
||||
`pose ${k}: ${out.threshold} vs ${expected}`,
|
||||
);
|
||||
}
|
||||
assert.ok(checked > 20, `${checked} poses in frame`);
|
||||
});
|
||||
|
||||
test('behind the camera or outside the frame there is nothing to test', () => {
|
||||
const out = { u: 7, v: 7, threshold: 7 };
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 500 }, out),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 0 }, out),
|
||||
false,
|
||||
);
|
||||
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, -1.2), out), false);
|
||||
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, 0, 1.01), out), false);
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, ahead(500, 1), out),
|
||||
false,
|
||||
'u = 1',
|
||||
);
|
||||
assert.deepEqual(out, { u: 7, v: 7, threshold: 7 }, 'left untouched');
|
||||
});
|
||||
|
||||
test('a contact is projected with the camera it is given', () => {
|
||||
// Camera 1000 m up the +Z axis looking down -Z: view = translate(0, 0, -1000).
|
||||
const view = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -1000, 1];
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
assert.equal(
|
||||
projectContact(view, 1, 1, { x: -250, y: 0, z: 500 }, out),
|
||||
true,
|
||||
);
|
||||
assert.equal(out.u, 0.25);
|
||||
assert.equal(projectContact(view, 1, 1, { x: 0, y: 0, z: 1500 }, out), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* A snapshot from a camera at the origin looking down -Z with a 90 degree
|
||||
* field of view. By default one depth range in which the left half of the
|
||||
* frame is a wall `wall` metres away and the right half is ground `ground`
|
||||
* metres away.
|
||||
*/
|
||||
function snapshotWithWall({
|
||||
wall = 400,
|
||||
ground = 500,
|
||||
logDepth = true,
|
||||
size = 8,
|
||||
frustums,
|
||||
images,
|
||||
} = {}) {
|
||||
const ranges = frustums || [{ near: 1, far: 100_000 }];
|
||||
const layers = images || [
|
||||
depthImage(size, size, (column) =>
|
||||
viewDistanceToDepth(
|
||||
column < size / 2 ? wall : ground,
|
||||
ranges[0].near,
|
||||
ranges[0].far,
|
||||
logDepth,
|
||||
),
|
||||
),
|
||||
];
|
||||
const bytes = new Uint8Array(size * size * 4 * layers.length);
|
||||
layers.forEach((image, i) => bytes.set(image.data, i * size * size * 4));
|
||||
return new DepthSnapshot({
|
||||
width: size,
|
||||
height: size,
|
||||
bytes,
|
||||
frustums: ranges,
|
||||
logDepth,
|
||||
view: IDENTITY,
|
||||
tanHalfFovX: 1,
|
||||
tanHalfFovY: 1,
|
||||
});
|
||||
}
|
||||
|
||||
test('a contact behind a drawn wall is hidden; one on drawn ground, or in front of the wall, is not', () => {
|
||||
for (const logDepth of [true, false]) {
|
||||
const snapshot = snapshotWithWall({ logDepth });
|
||||
assert.equal(snapshot.hides(ahead(500, -0.5)), true);
|
||||
assert.equal(snapshot.hides(ahead(500, 0.5)), false);
|
||||
assert.equal(snapshot.hides(ahead(300, -0.5)), false);
|
||||
// 2% of 405 m is 8.1 m: 5 m behind the wall is still the wall's own face.
|
||||
assert.equal(snapshot.hides(ahead(405, -0.5)), false);
|
||||
assert.equal(snapshot.hides(ahead(412, -0.5)), true);
|
||||
assert.equal(snapshot.hides(ahead(500, -1.5)), null, 'outside the frame');
|
||||
}
|
||||
});
|
||||
|
||||
test('rows are read bottom to top', () => {
|
||||
const size = 8;
|
||||
const image = depthImage(size, size, (_column, row) =>
|
||||
viewDistanceToDepth(row < size / 2 ? 400 : 500, 1, 100_000, true),
|
||||
);
|
||||
const snapshot = snapshotWithWall({ images: [image] });
|
||||
assert.equal(snapshot.hides(ahead(500, 0, -0.5)), true, 'below centre');
|
||||
assert.equal(snapshot.hides(ahead(500, 0, 0.5)), false, 'above centre');
|
||||
});
|
||||
|
||||
test('the nearest depth range that drew something answers', () => {
|
||||
const size = 4;
|
||||
const frustums = [
|
||||
{ near: 1, far: 1000 },
|
||||
{ near: 999, far: 1_000_000 },
|
||||
];
|
||||
// Near range: a wall 400 m away on the left, nothing on the right.
|
||||
const nearImage = depthImage(size, size, (column) =>
|
||||
column < 2 ? viewDistanceToDepth(400, 1, 1000, true) : 1,
|
||||
);
|
||||
// Far range: ground 20 km away everywhere.
|
||||
const farImage = depthImage(size, size, () =>
|
||||
viewDistanceToDepth(20_000, 999, 1_000_000, true),
|
||||
);
|
||||
const snapshot = snapshotWithWall({
|
||||
size,
|
||||
frustums,
|
||||
images: [nearImage, farImage],
|
||||
});
|
||||
assert.ok(Math.abs(snapshot.surfaceViewDistance(0, 0) - 400) < 0.01);
|
||||
assert.ok(Math.abs(snapshot.surfaceViewDistance(3, 0) - 20_000) < 1);
|
||||
assert.equal(snapshot.hides(ahead(15_000, -0.5)), true);
|
||||
assert.equal(snapshot.hides(ahead(15_000, 0.5)), false);
|
||||
assert.equal(snapshot.hides(ahead(30_000, 0.5)), true);
|
||||
});
|
||||
|
||||
test('where nothing was drawn a contact is visible', () => {
|
||||
const size = 4;
|
||||
const snapshot = snapshotWithWall({
|
||||
size,
|
||||
images: [depthImage(size, size, () => 1)],
|
||||
});
|
||||
assert.ok(Number.isNaN(snapshot.surfaceViewDistance(1, 1)));
|
||||
assert.equal(snapshot.hides(ahead(500)), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* A probe that answers when told to. `hidden` decides each test's answer at
|
||||
* the time it is resolved.
|
||||
*/
|
||||
function manualProbe() {
|
||||
const inFlight = [];
|
||||
return {
|
||||
accepting: true,
|
||||
started: 0,
|
||||
inFlight,
|
||||
add(contact) {
|
||||
if (!this.accepting) return false;
|
||||
this.started++;
|
||||
contact.pending = true;
|
||||
inFlight.push(contact);
|
||||
return true;
|
||||
},
|
||||
/** Deliver every answer in flight. */
|
||||
answer(hidden, now) {
|
||||
for (const contact of inFlight.splice(0)) {
|
||||
contact.pending = false;
|
||||
contact.answered = true;
|
||||
if (hidden) contact.lastHiddenAt = now;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const anywhere = { x: 0, y: 0, z: -500 };
|
||||
|
||||
test('a contact is drawn until an answer says it is hidden, then hidden at once', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(1000);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
assert.equal(probe.started, 1, 'a new contact is tested straight away');
|
||||
occlusion.beginFrame(1016);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
assert.equal(probe.started, 1, 'one test in flight per contact');
|
||||
probe.answer(true, occlusion.now);
|
||||
occlusion.beginFrame(1032);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
assert.equal(occlusion.testedCount, 1);
|
||||
assert.equal(occlusion.hiddenCount, 1);
|
||||
// The same source id on another layer is another contact.
|
||||
assert.equal(occlusion.isHidden('transit', 'a', anywhere), false);
|
||||
});
|
||||
|
||||
test('with a working probe a new contact is held back until its first answer, and no longer than the limit', () => {
|
||||
const probe = manualProbe();
|
||||
probe.mode = 'async';
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
// New to the view: tested at once, and not drawn while the answer is out.
|
||||
assert.equal(occlusion.isHidden('traffic', 'street', anywhere), true);
|
||||
assert.equal(occlusion.isHidden('traffic', 'roofed', anywhere), true);
|
||||
assert.equal(occlusion.isHidden('traffic', 'unanswered', anywhere), true);
|
||||
assert.equal(probe.started, 3);
|
||||
const [street, roofed] = probe.inFlight.splice(0, 2);
|
||||
street.pending = false;
|
||||
street.answered = true;
|
||||
roofed.pending = false;
|
||||
roofed.answered = true;
|
||||
roofed.lastHiddenAt = occlusion.now;
|
||||
occlusion.beginFrame(16);
|
||||
assert.equal(
|
||||
occlusion.isHidden('traffic', 'street', anywhere),
|
||||
false,
|
||||
'drawn from its first answer',
|
||||
);
|
||||
assert.equal(
|
||||
occlusion.isHidden('traffic', 'roofed', anywhere),
|
||||
true,
|
||||
'never drawn at all',
|
||||
);
|
||||
// The third answer never comes. It is drawn once the limit has passed.
|
||||
const firstSeen = occlusion.now - 16;
|
||||
const drawn = [];
|
||||
for (let wall = 32; wall < 1000; wall += 16) {
|
||||
occlusion.beginFrame(wall);
|
||||
drawn.push([
|
||||
occlusion.now - firstSeen,
|
||||
!occlusion.isHidden('traffic', 'unanswered', anywhere),
|
||||
]);
|
||||
}
|
||||
for (const [age, shown] of drawn)
|
||||
assert.equal(shown, age >= SURFACE_FIRST_ANSWER_MS, `at ${age} ms`);
|
||||
|
||||
// No working probe: a new contact is drawn at once, as before.
|
||||
for (const mode of ['off', 'sync', undefined]) {
|
||||
const idle = manualProbe();
|
||||
idle.mode = mode;
|
||||
const plain = new SurfaceOcclusion(idle);
|
||||
plain.beginFrame(0);
|
||||
assert.equal(plain.isHidden('traffic', 'a', anywhere), false, String(mode));
|
||||
}
|
||||
});
|
||||
|
||||
test('a contact is retested at most every retest interval and at least every half of it', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const starts = [];
|
||||
let before = 0;
|
||||
for (let now = 0; now <= 2000; now += 4) {
|
||||
probe.answer(false, now);
|
||||
occlusion.beginFrame(now);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
if (probe.started !== before) {
|
||||
starts.push(now);
|
||||
before = probe.started;
|
||||
}
|
||||
}
|
||||
const gaps = starts.slice(1).map((at, i) => at - starts[i]);
|
||||
assert.ok(gaps.length >= 9);
|
||||
assert.ok(
|
||||
gaps.every((gap) => gap <= SURFACE_RETEST_MS),
|
||||
`${gaps}`,
|
||||
);
|
||||
assert.ok(
|
||||
gaps.every((gap) => gap >= SURFACE_RETEST_MS / 2),
|
||||
`${gaps}`,
|
||||
);
|
||||
assert.ok(new Set(gaps).size > 3, 'not all on the same beat');
|
||||
});
|
||||
|
||||
test('contacts that appear together drift apart instead of all testing on the same frames', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const perFrame = [];
|
||||
for (let frame = 0; frame < 240; frame++) {
|
||||
const now = frame * 16;
|
||||
probe.answer(false, now);
|
||||
occlusion.beginFrame(now);
|
||||
const before = probe.started;
|
||||
for (let id = 0; id < 600; id++)
|
||||
occlusion.isHidden('traffic', id, anywhere);
|
||||
perFrame.push(probe.started - before);
|
||||
}
|
||||
assert.equal(perFrame[0], 600, 'every new contact is tested at once');
|
||||
const settled = perFrame.slice(120);
|
||||
assert.ok(Math.max(...settled) < 200, `peak ${Math.max(...settled)}`);
|
||||
assert.ok(Math.min(...settled) > 0);
|
||||
});
|
||||
|
||||
test('with more contacts than the probe takes per frame, the retests slow down and none is starved', () => {
|
||||
// A probe that takes 256 tests a frame, 2,400 contacts in view, 30 fps.
|
||||
const inFlight = [];
|
||||
let taken = 0;
|
||||
const probe = {
|
||||
maxPerFrame: 256,
|
||||
add(contact) {
|
||||
if (taken >= 256) return false;
|
||||
taken++;
|
||||
contact.pending = true;
|
||||
inFlight.push(contact);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const tests = new Array(2400).fill(0);
|
||||
const lastStart = new Array(2400).fill(0);
|
||||
let longestGap = 0;
|
||||
for (let frame = 0; frame < 300; frame++) {
|
||||
const now = frame * 33;
|
||||
for (const contact of inFlight.splice(0)) contact.pending = false;
|
||||
taken = 0;
|
||||
occlusion.beginFrame(now);
|
||||
for (let id = 0; id < 2400; id++) {
|
||||
const before = taken;
|
||||
occlusion.isHidden('traffic', id, anywhere);
|
||||
if (taken !== before) {
|
||||
tests[id]++;
|
||||
if (frame > 60) longestGap = Math.max(longestGap, now - lastStart[id]);
|
||||
lastStart[id] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
Math.min(...tests) >= 8,
|
||||
`fewest tests for one contact: ${Math.min(...tests)}`,
|
||||
);
|
||||
assert.ok(occlusion.retestMs > SURFACE_RETEST_MS);
|
||||
assert.ok(longestGap <= occlusion.retestMs + 33, `longest gap ${longestGap}`);
|
||||
assert.ok(occlusion.reappearMs >= 3 * occlusion.retestMs);
|
||||
// A view the budget covers easily keeps the normal pace.
|
||||
const few = new SurfaceOcclusion(probe);
|
||||
for (let frame = 0; frame < 10; frame++) {
|
||||
taken = 0;
|
||||
few.beginFrame(frame * 16);
|
||||
for (let id = 0; id < 300; id++) few.isHidden('traffic', id, anywhere);
|
||||
}
|
||||
assert.equal(few.retestMs, SURFACE_RETEST_MS);
|
||||
assert.equal(few.reappearMs, SURFACE_REAPPEAR_MS);
|
||||
});
|
||||
|
||||
test('a hidden contact comes back only after the reappear time without a hidden answer', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
const hiddenAt = occlusion.now;
|
||||
probe.answer(true, hiddenAt);
|
||||
// Visible answers from now on; the contact still waits out the time.
|
||||
let reappeared = null;
|
||||
for (let wall = 16; wall < 2000 && reappeared === null; wall += 16) {
|
||||
probe.answer(false, occlusion.now);
|
||||
occlusion.beginFrame(wall);
|
||||
if (!occlusion.isHidden('traffic', 'a', anywhere))
|
||||
reappeared = occlusion.now;
|
||||
}
|
||||
assert.ok(reappeared - hiddenAt >= SURFACE_REAPPEAR_MS);
|
||||
assert.ok(reappeared - hiddenAt < SURFACE_REAPPEAR_MS + 17);
|
||||
});
|
||||
|
||||
test('a contact on an edge, answering either way in turn, stays hidden', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const drawn = [];
|
||||
let answers = 0;
|
||||
for (let wall = 0; wall < 4000; wall += 16) {
|
||||
if (probe.inFlight.length > 0)
|
||||
probe.answer(answers++ % 2 === 0, occlusion.now);
|
||||
occlusion.beginFrame(wall);
|
||||
drawn.push(!occlusion.isHidden('traffic', 'a', anywhere));
|
||||
}
|
||||
// Drawn only until the first answer arrives.
|
||||
assert.equal(drawn.lastIndexOf(true), 0);
|
||||
});
|
||||
|
||||
test('when the probe stops answering, hidden contacts are drawn again and stay drawn', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
const hiddenAt = occlusion.now;
|
||||
probe.answer(true, hiddenAt);
|
||||
// No depth any more: the probe takes no tests and answers nothing.
|
||||
probe.accepting = false;
|
||||
const drawn = [];
|
||||
for (let wall = 16; wall < 3000; wall += 16) {
|
||||
occlusion.beginFrame(wall);
|
||||
drawn.push([
|
||||
occlusion.now - hiddenAt,
|
||||
!occlusion.isHidden('traffic', 'a', anywhere),
|
||||
]);
|
||||
}
|
||||
for (const [age, shown] of drawn)
|
||||
assert.equal(shown, age >= SURFACE_REAPPEAR_MS, `at ${age} ms`);
|
||||
// A probe that never accepted anything hides nothing.
|
||||
const dead = new SurfaceOcclusion({ add: () => false });
|
||||
dead.beginFrame(0);
|
||||
assert.equal(dead.isHidden('traffic', 'a', anywhere), false);
|
||||
});
|
||||
|
||||
test('a change of pace does not bring back a hidden that has run out', () => {
|
||||
// 2,400 contacts with a 256-a-frame probe: the hide time is longer at 33 ms
|
||||
// frames than at 16 ms ones.
|
||||
const inFlight = [];
|
||||
let taken = 0;
|
||||
const probe = {
|
||||
maxPerFrame: 256,
|
||||
add(contact) {
|
||||
if (taken >= 256) return false;
|
||||
taken++;
|
||||
contact.pending = true;
|
||||
inFlight.push(contact);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
let wall = 0;
|
||||
const paint = (step) => {
|
||||
wall += step;
|
||||
for (const contact of inFlight.splice(0)) contact.pending = false;
|
||||
taken = 0;
|
||||
occlusion.beginFrame(wall);
|
||||
let watched = false;
|
||||
for (let id = 0; id < 2400; id++) {
|
||||
const hidden = occlusion.isHidden('traffic', id, anywhere);
|
||||
if (id === 0) watched = hidden;
|
||||
}
|
||||
return watched;
|
||||
};
|
||||
for (let i = 0; i < 30; i++) paint(16);
|
||||
// One hidden answer for contact 0, then visible answers only.
|
||||
occlusion.contacts.get('traffic').get(0).lastHiddenAt = occlusion.now;
|
||||
const states = [];
|
||||
for (let i = 0; i < 80; i++) states.push(paint(16));
|
||||
assert.equal(states[0], true);
|
||||
assert.equal(states.at(-1), false, 'ran out at the fast pace');
|
||||
// The frames slow down, which lengthens the hide time for new answers.
|
||||
const slow = [];
|
||||
for (let i = 0; i < 40; i++) slow.push(paint(33));
|
||||
assert.ok(occlusion.reappearMs > SURFACE_REAPPEAR_MS);
|
||||
assert.ok(
|
||||
slow.every((hidden) => hidden === false),
|
||||
'and stays out',
|
||||
);
|
||||
});
|
||||
|
||||
test('a pause in painting does not run the hidden time out', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
probe.answer(true, occlusion.now);
|
||||
occlusion.beginFrame(16);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
// Nothing is painted for a minute (a hidden tab, an idle scene). On the
|
||||
// first frame back the contact is still hidden: no answer could have come.
|
||||
const before = occlusion.now;
|
||||
occlusion.beginFrame(60_016);
|
||||
assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
// A clock that jumps backwards moves nothing.
|
||||
occlusion.beginFrame(100);
|
||||
assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS);
|
||||
});
|
||||
|
||||
test('prune forgets contacts that are no longer drawn; clear forgets everything', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
let wall = 0;
|
||||
/** Paint frames for `ms` of painted time, drawing the given contacts. */
|
||||
const paint = (ms, ids) => {
|
||||
for (const end = occlusion.now + ms; occlusion.now < end;) {
|
||||
wall += 50;
|
||||
occlusion.beginFrame(wall);
|
||||
for (const id of ids) occlusion.isHidden('traffic', id, anywhere);
|
||||
}
|
||||
};
|
||||
paint(100, ['gone', 'here']);
|
||||
paint(SURFACE_FORGET_MS + 50, ['here']);
|
||||
occlusion.prune();
|
||||
assert.deepEqual([...occlusion.contacts.get('traffic').keys()], ['here']);
|
||||
paint(SURFACE_FORGET_MS + 50, []);
|
||||
occlusion.prune();
|
||||
assert.equal(occlusion.contacts.size, 0);
|
||||
|
||||
occlusion.isHidden('traffic', 'again', anywhere);
|
||||
occlusion.clear();
|
||||
assert.equal(occlusion.contacts.size, 0);
|
||||
assert.equal(occlusion.testedCount, 0);
|
||||
});
|
||||
|
||||
test('a contact back in view after a while waits for a fresh answer, like a new one', () => {
|
||||
const probe = manualProbe();
|
||||
probe.mode = 'async';
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
let wall = 0;
|
||||
const step = () => occlusion.beginFrame((wall += 16));
|
||||
step();
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
probe.answer(false, occlusion.now);
|
||||
step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
// Out of view for a moment shorter than a retest: drawn straight away.
|
||||
for (let i = 0; i < 5; i++) step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
// Out of view for longer (the camera swung away and back): the old
|
||||
// "visible" may now be behind a building. Held back, and tested at once.
|
||||
const started = probe.started;
|
||||
for (let i = 0; i < 30; i++) step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
assert.equal(probe.started, started + 1);
|
||||
probe.answer(true, occlusion.now);
|
||||
step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
|
||||
// It leaves with a test in flight, and comes back. That test's answer was
|
||||
// about the previous view: when it lands, it must not end the hold-back.
|
||||
const other = new SurfaceOcclusion(probe);
|
||||
probe.answer(false, 0);
|
||||
other.beginFrame((wall += 16));
|
||||
other.isHidden('traffic', 'b', anywhere);
|
||||
assert.equal(probe.inFlight.length, 1, 'tested, unanswered');
|
||||
for (let i = 0; i < 30; i++) other.beginFrame((wall += 16));
|
||||
const before = probe.started;
|
||||
assert.equal(other.isHidden('traffic', 'b', anywhere), true);
|
||||
assert.equal(probe.started, before + 1, 'tested afresh at once');
|
||||
const [stale] = probe.inFlight.splice(0, 1);
|
||||
stale.pending = false;
|
||||
stale.answered = true;
|
||||
other.beginFrame((wall += 16));
|
||||
assert.equal(
|
||||
other.isHidden('traffic', 'b', anywhere),
|
||||
true,
|
||||
'the old answer did not end the hold-back',
|
||||
);
|
||||
probe.answer(false, other.now);
|
||||
other.beginFrame((wall += 16));
|
||||
assert.equal(other.isHidden('traffic', 'b', anywhere), false);
|
||||
});
|
||||
|
||||
test('at a stretched retest pace, new contacts are held back longer for their first answer', () => {
|
||||
const probe = manualProbe();
|
||||
probe.mode = 'async';
|
||||
probe.maxPerFrame = 100;
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
for (let id = 0; id < 2000; id++) occlusion.isHidden('traffic', id, anywhere);
|
||||
occlusion.beginFrame(33);
|
||||
// 2000 contacts at 100 a frame and 33 ms frames: a retest every 1.3 s.
|
||||
assert.ok(occlusion.retestMs > 1000, `${occlusion.retestMs}`);
|
||||
assert.equal(occlusion.firstAnswerMs, occlusion.retestMs * 2);
|
||||
assert.ok(occlusion.firstAnswerMs > SURFACE_FIRST_ANSWER_MS);
|
||||
// A contact the probe has not reached yet is still held back past the
|
||||
// usual limit, and drawn once the stretched one has run out.
|
||||
probe.accepting = false;
|
||||
let wall = 33;
|
||||
const ages = [];
|
||||
const firstSeen = occlusion.now;
|
||||
occlusion.isHidden('traffic', 'late', anywhere);
|
||||
while (occlusion.now - firstSeen < occlusion.firstAnswerMs + 100) {
|
||||
occlusion.beginFrame((wall += 33));
|
||||
for (let id = 0; id < 2000; id++)
|
||||
occlusion.isHidden('traffic', id, anywhere);
|
||||
ages.push([
|
||||
occlusion.now - firstSeen,
|
||||
occlusion.isHidden('traffic', 'late', anywhere),
|
||||
]);
|
||||
}
|
||||
const limit = occlusion.firstAnswerMs;
|
||||
assert.ok(
|
||||
ages.some(([age, hidden]) => age > SURFACE_FIRST_ANSWER_MS + 50 && hidden),
|
||||
'held past 400 ms',
|
||||
);
|
||||
for (const [age, hidden] of ages)
|
||||
if (age >= limit) assert.equal(hidden, false, `at ${age} ms`);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */
|
||||
export function normalizeDisplayText(value, maxLength = 180) {
|
||||
if (value == null || typeof value === 'object') return '';
|
||||
return String(value)
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
.replace(/[\uD800-\uDBFF]$/, '')
|
||||
.trim();
|
||||
}
|
||||
@@ -276,7 +276,7 @@ test('a click on neither sprite nor card clears without moving the camera', () =
|
||||
const SIBLING_PICKS = [
|
||||
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
||||
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
|
||||
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['ais-live-vessels', '353136000', { id: { id: '353136000', mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
||||
['satellites', '25544', { id: 25544 }],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* A stand-in scene and WebGL context for tests of the occlusion probe: just
|
||||
* the members `sceneOcclusionProbe.js` uses, with depth images the test
|
||||
* controls.
|
||||
*
|
||||
* The fake context cannot run GLSL. When the probe draws a point inside a
|
||||
* query, the fake decides whether the point draws by applying the same rule
|
||||
* to the same inputs the vertex shader gets (the uploaded attributes, the
|
||||
* bound depth textures and the uniforms), using the CPU decode from
|
||||
* `detectionOcclusion.js`. That checks everything around the shader; the
|
||||
* shader itself is checked against `scene.pickPosition` in a real browser.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { depthToViewDistance, unpackDepth } from '../detectionOcclusion.js';
|
||||
|
||||
/** Inverse of `unpackDepth`: four base-255 digits, most significant first. */
|
||||
export function packDepth(depth) {
|
||||
const bytes = [];
|
||||
let rest = Math.min(Math.max(depth, 0), 1);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
rest *= 255;
|
||||
const digit = Math.min(255, Math.floor(rest));
|
||||
bytes.push(digit);
|
||||
rest -= digit;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Inverse of `depthToViewDistance`. */
|
||||
export function viewDistanceToDepth(distance, near, far, logDepth) {
|
||||
if (logDepth)
|
||||
return Math.log2(distance - near + 1) / Math.log2(far - near + 1);
|
||||
return (far * (distance - near)) / (distance * (far - near));
|
||||
}
|
||||
|
||||
/**
|
||||
* An RGBA8 depth image filled from a function of the texel.
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {(column: number, row: number) => number} depthAt Window depth; rows
|
||||
* count from the bottom.
|
||||
*/
|
||||
export function depthImage(width, height, depthAt) {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
for (let row = 0; row < height; row++)
|
||||
for (let column = 0; column < width; column++)
|
||||
data.set(packDepth(depthAt(column, row)), (row * width + column) * 4);
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
const GL = {
|
||||
FRAMEBUFFER: 0x8d40,
|
||||
READ_FRAMEBUFFER: 0x8ca8,
|
||||
DRAW_FRAMEBUFFER: 0x8ca9,
|
||||
FRAMEBUFFER_BINDING: 0x8ca6,
|
||||
DRAW_FRAMEBUFFER_BINDING: 0x8ca6,
|
||||
READ_FRAMEBUFFER_BINDING: 0x8caa,
|
||||
TEXTURE_BINDING_2D: 0x8069,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
TEXTURE0: 0x84c0,
|
||||
ACTIVE_TEXTURE: 0x84e0,
|
||||
COLOR_ATTACHMENT0: 0x8ce0,
|
||||
FRAMEBUFFER_COMPLETE: 0x8cd5,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
ARRAY_BUFFER_BINDING: 0x8894,
|
||||
VERTEX_ARRAY_BINDING: 0x85b5,
|
||||
CURRENT_PROGRAM: 0x8b8d,
|
||||
STREAM_DRAW: 0x88e0,
|
||||
SCISSOR_TEST: 0x0c11,
|
||||
VIEWPORT: 0x0ba2,
|
||||
POINTS: 0x0000,
|
||||
FLOAT: 0x1406,
|
||||
RGBA: 0x1908,
|
||||
RGBA8: 0x8058,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
ANY_SAMPLES_PASSED_CONSERVATIVE: 0x8d6a,
|
||||
QUERY_RESULT: 0x8866,
|
||||
QUERY_RESULT_AVAILABLE: 0x8867,
|
||||
};
|
||||
|
||||
/**
|
||||
* A recording WebGL context. `webgl2: false` leaves out queries, vertex
|
||||
* arrays and immutable textures, as a WebGL 1 context does. Query results
|
||||
* stay unavailable until `finishGpu()`.
|
||||
*/
|
||||
export function createFakeGl({ webgl2 = true } = {}) {
|
||||
const state = {
|
||||
read: null,
|
||||
draw: null,
|
||||
units: [null, null, null, null, null, null, null, null],
|
||||
unit: 0,
|
||||
array: null,
|
||||
vertexArray: null,
|
||||
program: null,
|
||||
scissor: false,
|
||||
viewport: [0, 0, 1920, 1080],
|
||||
lost: false,
|
||||
/** Whether a point that should draw does (false: a broken draw path). */
|
||||
drawsLand: true,
|
||||
/** Whether shaders compile: a boolean, or a function of the shader type. */
|
||||
compiles: true,
|
||||
};
|
||||
const counts = {
|
||||
readPixels: 0,
|
||||
queries: 0,
|
||||
draws: 0,
|
||||
resultReads: 0,
|
||||
deleted: 0,
|
||||
};
|
||||
const liveQueries = new Set();
|
||||
const liveShaders = new Set();
|
||||
let activeQuery = null;
|
||||
const gl = {
|
||||
...GL,
|
||||
state,
|
||||
counts,
|
||||
liveQueries,
|
||||
liveShaders,
|
||||
isContextLost: () => state.lost,
|
||||
getParameter(name) {
|
||||
if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read;
|
||||
if (name === GL.DRAW_FRAMEBUFFER_BINDING) return state.draw;
|
||||
if (name === GL.TEXTURE_BINDING_2D) return state.units[state.unit];
|
||||
if (name === GL.ACTIVE_TEXTURE) return GL.TEXTURE0 + state.unit;
|
||||
if (name === GL.ARRAY_BUFFER_BINDING) return state.array;
|
||||
if (name === GL.VERTEX_ARRAY_BINDING) return state.vertexArray;
|
||||
if (name === GL.CURRENT_PROGRAM) return state.program;
|
||||
if (name === GL.VIEWPORT) return Int32Array.from(state.viewport);
|
||||
return null;
|
||||
},
|
||||
isEnabled: (cap) => cap === GL.SCISSOR_TEST && state.scissor,
|
||||
enable(cap) {
|
||||
if (cap === GL.SCISSOR_TEST) state.scissor = true;
|
||||
},
|
||||
disable(cap) {
|
||||
if (cap === GL.SCISSOR_TEST) state.scissor = false;
|
||||
},
|
||||
createFramebuffer: () => ({ image: null }),
|
||||
createTexture: () => ({ image: null }),
|
||||
createBuffer: () => ({ data: null }),
|
||||
deleteFramebuffer() {
|
||||
counts.deleted++;
|
||||
},
|
||||
deleteTexture() {
|
||||
counts.deleted++;
|
||||
},
|
||||
deleteBuffer() {
|
||||
counts.deleted++;
|
||||
},
|
||||
activeTexture(unit) {
|
||||
state.unit = unit - GL.TEXTURE0;
|
||||
},
|
||||
bindTexture(_target, texture) {
|
||||
state.units[state.unit] = texture;
|
||||
},
|
||||
bindFramebuffer(target, framebuffer) {
|
||||
if (target !== GL.DRAW_FRAMEBUFFER) state.read = framebuffer;
|
||||
if (target !== GL.READ_FRAMEBUFFER) state.draw = framebuffer;
|
||||
},
|
||||
bindBuffer(_target, buffer) {
|
||||
state.array = buffer;
|
||||
},
|
||||
readPixels(x, y, width, height, _format, _type, destination) {
|
||||
counts.readPixels++;
|
||||
const image = state.read.image;
|
||||
for (let row = 0; row < height; row++) {
|
||||
const from = ((y + row) * image.width + x) * 4;
|
||||
destination.set(
|
||||
image.data.subarray(from, from + width * 4),
|
||||
row * width * 4,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (webgl2) {
|
||||
Object.assign(gl, {
|
||||
texStorage2D(_target, _levels, _format, width, height) {
|
||||
state.units[state.unit].image = {
|
||||
width,
|
||||
height,
|
||||
data: new Uint8Array(width * height * 4),
|
||||
};
|
||||
},
|
||||
framebufferTexture2D(_target, _attachment, _kind, texture) {
|
||||
state.draw.image = texture.image;
|
||||
},
|
||||
checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE,
|
||||
createShader(type) {
|
||||
const shader = { type };
|
||||
liveShaders.add(shader);
|
||||
return shader;
|
||||
},
|
||||
shaderSource() {},
|
||||
compileShader() {},
|
||||
getShaderParameter: (shader) =>
|
||||
typeof state.compiles === 'function'
|
||||
? state.compiles(shader.type)
|
||||
: state.compiles,
|
||||
deleteShader(shader) {
|
||||
liveShaders.delete(shader);
|
||||
},
|
||||
createProgram: () => ({ uniforms: {} }),
|
||||
attachShader() {},
|
||||
linkProgram() {},
|
||||
getProgramParameter: () => true,
|
||||
deleteProgram() {
|
||||
counts.deleted++;
|
||||
},
|
||||
getUniformLocation: (_program, name) => name,
|
||||
useProgram(program) {
|
||||
state.program = program;
|
||||
},
|
||||
viewport(x, y, width, height) {
|
||||
state.viewport = [x, y, width, height];
|
||||
},
|
||||
uniform1i(name, value) {
|
||||
state.program.uniforms[name] = value;
|
||||
},
|
||||
uniform4f(name, x, y, z, w) {
|
||||
state.program.uniforms[name] = [x, y, z, w];
|
||||
},
|
||||
createVertexArray: () => ({ buffer: null }),
|
||||
deleteVertexArray() {
|
||||
counts.deleted++;
|
||||
},
|
||||
bindVertexArray(vertexArray) {
|
||||
state.vertexArray = vertexArray;
|
||||
},
|
||||
enableVertexAttribArray() {},
|
||||
vertexAttribPointer() {
|
||||
state.vertexArray.buffer = state.array;
|
||||
},
|
||||
bufferData(_target, data) {
|
||||
state.array.data = Float32Array.from(data);
|
||||
},
|
||||
createQuery() {
|
||||
const query = { drew: false, available: false, ended: false };
|
||||
liveQueries.add(query);
|
||||
return query;
|
||||
},
|
||||
deleteQuery(query) {
|
||||
liveQueries.delete(query);
|
||||
},
|
||||
beginQuery(_target, query) {
|
||||
counts.queries++;
|
||||
query.drew = false;
|
||||
query.available = false;
|
||||
query.ended = false;
|
||||
activeQuery = query;
|
||||
},
|
||||
endQuery() {
|
||||
activeQuery.ended = true;
|
||||
activeQuery = null;
|
||||
},
|
||||
/** The vertex shader's rule, applied to what the shader would be given. */
|
||||
drawArrays(_mode, first) {
|
||||
counts.draws++;
|
||||
const uniforms = state.program.uniforms;
|
||||
const data = state.vertexArray.buffer.data;
|
||||
const u = data[first * 3];
|
||||
const v = data[first * 3 + 1];
|
||||
const threshold = data[first * 3 + 2];
|
||||
let surface = -1;
|
||||
for (let i = 0; i < uniforms.u_count && surface < 0; i++) {
|
||||
const image = state.units[uniforms[`u_depth${i}`]].image;
|
||||
const column = Math.min(image.width - 1, Math.floor(u * image.width));
|
||||
const row = Math.min(image.height - 1, Math.floor(v * image.height));
|
||||
const distance = depthToViewDistance(
|
||||
unpackDepth(image.data, (row * image.width + column) * 4),
|
||||
uniforms.u_near[i],
|
||||
uniforms.u_far[i],
|
||||
uniforms.u_logDepth === 1,
|
||||
);
|
||||
if (distance === distance) surface = distance;
|
||||
}
|
||||
const hidden = surface > 0 && surface < threshold;
|
||||
// A point at the middle of the viewport lands only if that is the
|
||||
// target's one pixel and the scissor test is not in the way. A
|
||||
// sampler left on a unit with no texture fails the whole draw.
|
||||
const samplersBound = [0, 1, 2, 3].every(
|
||||
(i) => state.units[uniforms[`u_depth${i}`]]?.image,
|
||||
);
|
||||
const lands =
|
||||
state.drawsLand &&
|
||||
samplersBound &&
|
||||
!state.scissor &&
|
||||
state.draw?.image?.width === 1 &&
|
||||
state.viewport.join() === '0,0,1,1';
|
||||
if (activeQuery && !hidden && lands) activeQuery.drew = true;
|
||||
},
|
||||
getQueryParameter(query, name) {
|
||||
if (name === GL.QUERY_RESULT_AVAILABLE) return query.available;
|
||||
counts.resultReads++;
|
||||
return query.available && query.drew ? 1 : 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
/** Let every query issued so far finish. */
|
||||
gl.finishGpu = () => {
|
||||
for (const query of liveQueries) if (query.ended) query.available = true;
|
||||
};
|
||||
return gl;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scene drawn in the given depth ranges, nearest first.
|
||||
* @param {{
|
||||
* gl?: object,
|
||||
* ranges: Array<{near: number, far: number, image: object}>,
|
||||
* logDepth?: boolean, fovy?: number, aspectRatio?: number,
|
||||
* viewMatrix?: Cesium.Matrix4,
|
||||
* }} options
|
||||
*/
|
||||
export function createFakeDepthScene({
|
||||
gl = createFakeGl(),
|
||||
ranges,
|
||||
logDepth = true,
|
||||
fovy = Math.PI / 2,
|
||||
aspectRatio = 1,
|
||||
viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
}) {
|
||||
return {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
useDepthPicking: true,
|
||||
opaqueFrustumNearOffset: 0.9999,
|
||||
context: { _gl: gl, depthTexture: true, webgl2: !!gl.createQuery },
|
||||
_environmentState: { useGlobeDepthFramebuffer: true },
|
||||
frameState: { useLogDepth: logDepth },
|
||||
camera: { frustum: { fovy, aspectRatio }, viewMatrix },
|
||||
view: {
|
||||
frustumCommandsList: ranges.map(({ near, far }) => ({ near, far })),
|
||||
pickDepths: ranges.map(({ image }) => {
|
||||
const texture = { image };
|
||||
return {
|
||||
framebuffer: {
|
||||
_framebuffer: { image },
|
||||
getColorTexture: () => ({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
_texture: texture,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,5 +40,7 @@ export const _driveFleetModelHandoffForTest =
|
||||
flightsLayer.testing._driveFleetModelHandoffForTest;
|
||||
export const _ensureFleetModelForTest =
|
||||
flightsLayer.testing._ensureFleetModelForTest;
|
||||
export const _resetEnrichmentForTest =
|
||||
flightsLayer.testing._resetEnrichmentForTest;
|
||||
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
|
||||
export default flightsLayer;
|
||||
Loaded 100 of 406 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user