mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
Merge commit '0ceee8df40b97c6c693e86a4443794cd4ed1ff35' into HEAD
This commit is contained in:
commit
f878efe22d
306 files changed
+39352
-1624
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"
|
||||
+9
-2
@@ -30,7 +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, linked to its Planespotters page; "Planespotters.net" in Data attribution |
|
||||
| **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) |
|
||||
@@ -40,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)" |
|
||||
@@ -202,7 +208,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
|
||||
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, whose page is an `https://www.planespotters.net` URL, and which names its photographer is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name is visible text next to it ("Photo © {photographer}"), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
|
||||
- **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.
|
||||
@@ -270,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) |
|
||||
|
||||
@@ -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
|
||||
@@ -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. |
|
||||
|
||||
|
||||
+1
-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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- Give transit vehicle icons a readable front: the bus, tram, metro and unknown
|
||||
glyphs now carry a nose or cab in their outline, so the direction of travel
|
||||
shows at fleet size and survives the sensor profiles. A vehicle with no known
|
||||
course gets a symmetric outline instead of one that points north, and
|
||||
switches to the pointed glyph once a course arrives (IMBrandao, #1574).
|
||||
@@ -0,0 +1,4 @@
|
||||
- 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,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
|
||||
|
||||
@@ -634,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.
|
||||
|
||||
+249
-52
@@ -167,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
|
||||
@@ -767,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
|
||||
@@ -1092,7 +1135,7 @@ 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 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.
|
||||
|
||||
@@ -1444,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
|
||||
@@ -1500,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
|
||||
@@ -1762,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
|
||||
@@ -1974,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
|
||||
@@ -2447,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,
|
||||
@@ -2913,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.
|
||||
The `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
|
||||
including zero-padded hours and minutes; missing or invalid dates and times
|
||||
@@ -3507,6 +3646,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
|
||||
@@ -4250,8 +4425,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.
|
||||
@@ -4640,7 +4818,9 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
||||
|
||||
### Interface localization (October 2026)
|
||||
|
||||
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
|
||||
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **he**,
|
||||
**it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and
|
||||
**zh-Hant**. The code lives in
|
||||
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
|
||||
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
||||
|
||||
@@ -4654,19 +4834,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
|
||||
@@ -4715,35 +4897,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;
|
||||
@@ -5348,8 +5532,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
|
||||
@@ -5826,7 +6013,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.
|
||||
@@ -6220,7 +6407,7 @@ 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-two 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`, `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`, `next_iss_pass`, and `next_satellite_pass`.
|
||||
|
||||
@@ -6260,6 +6447,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.
|
||||
@@ -6492,6 +6680,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.
|
||||
@@ -6749,11 +6946,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
|
||||
|
||||
+41
-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,22 @@ 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`)
|
||||
|
||||
@@ -284,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
|
||||
@@ -329,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
|
||||
@@ -368,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`
|
||||
|
||||
+1
-1
@@ -286,7 +286,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 |
|
||||
|
||||
+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`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -45,9 +45,10 @@ Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change,
|
||||
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
|
||||
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
|
||||
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`; the chrome mirrors through logical CSS properties (`inset-inline-*`, `margin-inline-*`), and `src/ui/styles/rtl.css` holds what those cannot express (see the Arabic notes). If the language should keep Western digits, map it in `INTL_TAGS` in `locale.js`.
|
||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
|
||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json` and in `scripts/format-scope.json`.
|
||||
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
|
||||
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||
6. If the language is not written in Latin letters, also add it to `NON_LATIN_LOCALES` in `src/hudSummaryResponse.js` — that guard falls back to the translated text when a summary provider answers in English.
|
||||
7. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -63,4 +64,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
|
||||
|
||||
## Language notes
|
||||
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Hebrew](./translations/he.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
@@ -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, בלי שינוי |
|
||||
|
||||
+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
|
||||
|
||||
@@ -138,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"
|
||||
},
|
||||
@@ -261,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1230,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",
|
||||
@@ -1284,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",
|
||||
@@ -1294,6 +1303,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"src/ui/views/panels/CockpitSignalList.vue",
|
||||
"src/ui/views/panels/CockpitUtilityControls.vue",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -73,6 +74,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -507,6 +509,7 @@
|
||||
"server/providers/regional/weather-effects.js",
|
||||
"server/providers/regional/weather.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/sources/httpBody.js",
|
||||
@@ -529,6 +532,32 @@
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"news-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/news"],
|
||||
"modules": [
|
||||
"server/providers/news.js",
|
||||
"server/providers/news/keyed.js",
|
||||
"server/providers/news/categorize.js",
|
||||
"server/providers/news/areas.js",
|
||||
"server/providers/news/gdeltEvents.js",
|
||||
"server/providers/news/upstream.js",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/local_data/natural_earth/countries.json",
|
||||
"src/data/local_data/natural_earth/disputed_areas.json",
|
||||
"src/layers/news/source.js",
|
||||
"src/layers/news/records.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/capability.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"openai-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/openai"],
|
||||
@@ -607,6 +636,7 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/bundledJson.js",
|
||||
@@ -656,6 +686,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -704,6 +735,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -730,6 +762,7 @@
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
@@ -745,6 +778,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -765,6 +799,11 @@
|
||||
"src/ui/rail/RailTimeline.vue",
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/weather/WeatherReadout.vue",
|
||||
"src/ui/newsPanel.js",
|
||||
"src/ui/news/NewsFeed.vue",
|
||||
"src/ui/news/NewsCard.vue",
|
||||
"src/ui/kit/UiEmpty.vue",
|
||||
"src/ui/kit/UiSelect.js",
|
||||
"src/ui/kit/UiButton.vue",
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/rail/fieldValue.js",
|
||||
@@ -788,6 +827,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -815,6 +855,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -845,6 +886,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -882,6 +924,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -906,6 +949,7 @@
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -951,6 +995,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -978,6 +1023,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1065,6 +1111,7 @@
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -1116,6 +1163,7 @@
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
@@ -1180,6 +1228,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1213,6 +1262,11 @@
|
||||
"src/ui/rail/RailTimeline.vue",
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/weather/WeatherReadout.vue",
|
||||
"src/ui/newsPanel.js",
|
||||
"src/ui/news/NewsFeed.vue",
|
||||
"src/ui/news/NewsCard.vue",
|
||||
"src/ui/kit/UiEmpty.vue",
|
||||
"src/ui/kit/UiSelect.js",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/radio.js",
|
||||
@@ -1283,6 +1337,7 @@
|
||||
"src/ui/styles/first-run.css",
|
||||
"src/ui/styles/provider-settings.css",
|
||||
"src/ui/styles/weather.css",
|
||||
"src/ui/styles/news.css",
|
||||
"src/ui/styles/recent-imagery.css"
|
||||
],
|
||||
"external": []
|
||||
@@ -1372,6 +1427,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1447,7 +1503,11 @@
|
||||
"src/layers/flights/state.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
@@ -1459,6 +1519,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1502,6 +1563,9 @@
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
"src/layers/military/policy.js",
|
||||
@@ -1510,6 +1574,7 @@
|
||||
"src/layers/military/state.js",
|
||||
"src/layers/military/testing.js",
|
||||
"src/layers/military/tracking.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
@@ -1521,6 +1586,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1528,6 +1594,7 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
],
|
||||
@@ -1573,6 +1640,10 @@
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
@@ -1598,6 +1669,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1635,6 +1707,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1676,6 +1749,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1716,6 +1790,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1764,6 +1839,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1816,6 +1892,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1863,6 +1940,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1926,6 +2004,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1963,6 +2042,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2020,6 +2100,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2092,6 +2173,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2133,6 +2215,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2183,6 +2266,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2213,6 +2297,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2277,6 +2362,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2355,6 +2441,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2523,8 +2610,10 @@
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionDraw.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/detectionOcclusion.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/sceneOcclusionProbe.js",
|
||||
"src/data/earthquakes.js",
|
||||
"src/data/fireAnchors.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
@@ -2603,6 +2692,7 @@
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
@@ -2703,6 +2793,8 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -2744,6 +2836,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -2826,6 +2919,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
@@ -2922,6 +3016,7 @@
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -2968,6 +3063,7 @@
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
@@ -3097,11 +3193,25 @@
|
||||
"src/layers/ghostMaps/records.js",
|
||||
"src/layers/ghostMaps/kml.js",
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/app/layers/shipwrecks.js",
|
||||
"src/layers/shipwrecks/index.js",
|
||||
"src/layers/shipwrecks/source.js",
|
||||
"src/layers/shipwrecks/records.js",
|
||||
"src/layers/shipwrecks/cards.js",
|
||||
"src/layers/shipwrecks/eras.js",
|
||||
"src/layers/shipwrecks/period.js",
|
||||
"src/app/layers/droneAirspace.js",
|
||||
"src/layers/droneAirspace/index.js",
|
||||
"src/layers/droneAirspace/source.js",
|
||||
"src/layers/droneAirspace/records.js",
|
||||
"src/layers/droneAirspace/cards.js",
|
||||
"src/app/layers/news.js",
|
||||
"src/layers/news/index.js",
|
||||
"src/layers/news/model.js",
|
||||
"src/layers/news/records.js",
|
||||
"src/layers/news/source.js",
|
||||
"src/layers/news/sprites.js",
|
||||
"src/layers/news/cards.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/sources/reference.js",
|
||||
"src/search/nominatim.js",
|
||||
@@ -3305,6 +3415,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -3354,6 +3465,10 @@
|
||||
"src/ui/rail/RailTimeline.vue",
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/weather/WeatherReadout.vue",
|
||||
"src/ui/newsPanel.js",
|
||||
"src/ui/news/NewsFeed.vue",
|
||||
"src/ui/news/NewsCard.vue",
|
||||
"src/ui/kit/UiSelect.js",
|
||||
"src/ui/recentImagery/RecentImageryCard.vue",
|
||||
"src/ui/recentImagery/RecentImageryReadout.vue",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
@@ -3601,6 +3716,7 @@
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/keylessGeocoder.js",
|
||||
@@ -3691,6 +3807,9 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -3732,6 +3851,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -3813,6 +3933,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
@@ -3893,15 +4014,30 @@
|
||||
"src/layers/ghostMaps/records.js",
|
||||
"src/layers/ghostMaps/kml.js",
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/app/layers/shipwrecks.js",
|
||||
"src/layers/shipwrecks/index.js",
|
||||
"src/layers/shipwrecks/source.js",
|
||||
"src/layers/shipwrecks/records.js",
|
||||
"src/layers/shipwrecks/cards.js",
|
||||
"src/layers/shipwrecks/eras.js",
|
||||
"src/layers/shipwrecks/period.js",
|
||||
"src/app/layers/droneAirspace.js",
|
||||
"src/layers/droneAirspace/index.js",
|
||||
"src/layers/droneAirspace/source.js",
|
||||
"src/layers/droneAirspace/records.js",
|
||||
"src/layers/droneAirspace/cards.js",
|
||||
"src/app/layers/news.js",
|
||||
"src/layers/news/index.js",
|
||||
"src/layers/news/model.js",
|
||||
"src/layers/news/records.js",
|
||||
"src/layers/news/source.js",
|
||||
"src/layers/news/sprites.js",
|
||||
"src/layers/news/cards.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/nominatim.js",
|
||||
@@ -3969,6 +4105,8 @@
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/detectionOcclusion.js",
|
||||
"src/data/sceneOcclusionProbe.js",
|
||||
"src/data/modelEligibility.js",
|
||||
"src/app/layers/recentImagery.js",
|
||||
"src/layers/recentImagery/index.js",
|
||||
@@ -4043,6 +4181,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4175,6 +4314,7 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/nominatim.js",
|
||||
@@ -4227,6 +4367,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4260,6 +4401,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4314,6 +4456,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4352,6 +4495,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4458,6 +4602,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4481,7 +4626,11 @@
|
||||
},
|
||||
"regional-source": {
|
||||
"exports": ["./sources/regional"],
|
||||
"modules": ["src/data/regionalModel.js", "src/geoDistance.js"],
|
||||
"modules": [
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"voice-action-schemas": {
|
||||
@@ -4531,6 +4680,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4690,6 +4840,7 @@
|
||||
"src/search/google.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -4714,6 +4865,7 @@
|
||||
"src/nominatimGeocode.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js"
|
||||
@@ -4823,6 +4975,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4878,6 +5031,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4941,6 +5095,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4965,6 +5120,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5035,6 +5191,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5059,6 +5216,17 @@
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"news-source": {
|
||||
"exports": ["./layers/news/source"],
|
||||
"modules": [
|
||||
"src/layers/news/source.js",
|
||||
"src/layers/news/records.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/capability.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"perimeters-source": {
|
||||
"exports": ["./layers/perimeters/source"],
|
||||
"modules": [
|
||||
@@ -5084,6 +5252,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5291,6 +5460,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5337,6 +5507,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
+153
-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).
|
||||
*
|
||||
@@ -362,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)
|
||||
@@ -1498,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.
|
||||
@@ -1744,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
|
||||
@@ -1765,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}`);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -573,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:
|
||||
@@ -639,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';
|
||||
@@ -189,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/);
|
||||
|
||||
+85
-1
@@ -50,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,
|
||||
@@ -268,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;
|
||||
@@ -372,6 +390,9 @@ export function destroyDetection() {
|
||||
_suspended = false;
|
||||
_suspendReason = '';
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.destroy();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastDiagnostics = null;
|
||||
@@ -697,6 +718,9 @@ function _applyModeState() {
|
||||
_syncSurfaceVisibility();
|
||||
_setLanesActive(false);
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.reset();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastSolveSnapshot = {
|
||||
@@ -1337,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
|
||||
@@ -1447,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);
|
||||
@@ -1547,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);
|
||||
@@ -1557,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,
|
||||
@@ -1643,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
|
||||
@@ -1698,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;
|
||||
|
||||
@@ -36,6 +36,12 @@ import {
|
||||
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([
|
||||
@@ -1369,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`);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -693,6 +693,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'i',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'news', token: '00', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'radio',
|
||||
token: 'r',
|
||||
@@ -722,6 +723,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
optionOwner: 'satellites',
|
||||
}),
|
||||
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'shipwrecks', token: '01', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'street-level',
|
||||
|
||||
@@ -203,8 +203,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 36);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 38);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 38);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,17 +243,23 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '00');
|
||||
// News holds 00 and Shipwrecks 01, the first pairs: the next token is 02.
|
||||
assert.equal(nextLayerStateToken(), '02');
|
||||
// The allocation rules below read the ledger before either took a pair.
|
||||
const beforeNews = { ...LAYER_STATE_TOKEN_RESERVATIONS };
|
||||
delete beforeNews.news;
|
||||
delete beforeNews.shipwrecks;
|
||||
assert.equal(nextLayerStateToken(beforeNews), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...beforeNews,
|
||||
alpha: '9',
|
||||
bravo: '00',
|
||||
}),
|
||||
'01',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...beforeNews,
|
||||
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
@@ -304,8 +310,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
validateLayerStateAllocations(beforeNews, {
|
||||
...beforeNews,
|
||||
future: '00',
|
||||
next: '01',
|
||||
}),
|
||||
@@ -313,8 +319,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
validateLayerStateAllocations(beforeNews, {
|
||||
...beforeNews,
|
||||
future: '01',
|
||||
}),
|
||||
/next free token 00/,
|
||||
@@ -341,8 +347,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
|
||||
{ ...beforeNews, merged: '9' },
|
||||
{ ...beforeNews, merged: '9', competing: '9' },
|
||||
),
|
||||
/next free token 00/,
|
||||
);
|
||||
@@ -500,7 +506,7 @@ test('unknown enabled-layer tokens reject the payload instead of becoming an emp
|
||||
});
|
||||
|
||||
test('malformed enabled-layer lists reject the entire payload', () => {
|
||||
for (const value of ['.c', 'c.', 'c..e', 'c.c', '00', 'c.00']) {
|
||||
for (const value of ['.c', 'c.', 'c..e', 'c.c', 'zz', 'c.zz']) {
|
||||
assert.equal(
|
||||
decodeLayerStateParams(new URLSearchParams(`v=2&l=${value}`)),
|
||||
null,
|
||||
|
||||
@@ -97,8 +97,10 @@ function withPublishedBase(
|
||||
}
|
||||
}
|
||||
|
||||
const preWildlifeRows = reservationRows.filter(
|
||||
([id]) => id !== 'wildlife-sightings',
|
||||
// The ledger before Wildlife Sightings took the last digit and News and
|
||||
// Shipwrecks the first pairs, for the allocation rules written against it.
|
||||
const beforeLastDigitRows = reservationRows.filter(
|
||||
([id]) => !['wildlife-sightings', 'news', 'shipwrecks'].includes(id),
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
@@ -115,6 +117,16 @@ function writeCodecFixture(cwd, rows, entries) {
|
||||
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
if (!rows.some(([id]) => id === 'news'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'news',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
if (!rows.some(([id]) => id === 'shipwrecks'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'shipwrecks',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
@@ -138,7 +150,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 38);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -151,6 +163,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
'wildlife-sightings': '9',
|
||||
news: '00',
|
||||
shipwrecks: '01',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -176,9 +190,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '02']])
|
||||
.future,
|
||||
'00',
|
||||
'02',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -196,7 +210,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...preWildlifeRows, ['retired-layer', '9']] },
|
||||
{ rows: [...beforeLastDigitRows, ['retired-layer', '9']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
@@ -228,80 +242,86 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
|
||||
withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
'scripts/check-layer-state-tokens.mjs',
|
||||
);
|
||||
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
|
||||
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
|
||||
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
|
||||
const runCheck = () =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
withPublishedBase(
|
||||
{ rows: [...beforeLastDigitRows, ['pr-a', '9']] },
|
||||
(cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
'scripts/check-layer-state-tokens.mjs',
|
||||
);
|
||||
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
|
||||
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
|
||||
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
|
||||
const runCheck = () =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...beforeLastDigitRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...beforeLastDigitRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
assert.equal(staleRebase.status, 1);
|
||||
assert.match(
|
||||
staleRebase.stderr,
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...preWildlifeRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
assert.equal(staleRebase.status, 1);
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...preWildlifeRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 00/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
'the helper reports a token without rewriting the ledger',
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /36 published, 1 new/);
|
||||
});
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...beforeLastDigitRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 00/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
'the helper reports a token without rewriting the ledger',
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...beforeLastDigitRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /36 published, 1 new/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...preWildlifeRows,
|
||||
...beforeLastDigitRows,
|
||||
...digitRows,
|
||||
]);
|
||||
const beforeNine = { ...allDigits };
|
||||
@@ -360,7 +380,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
token,
|
||||
}));
|
||||
const fixtureRows = [
|
||||
...preWildlifeRows,
|
||||
...beforeLastDigitRows,
|
||||
...priorDigits,
|
||||
...fixtureLayers.map(({ id, token }) => [id, token]),
|
||||
];
|
||||
@@ -369,13 +389,16 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
assert.equal(codec.validateLayerStateRegistry(), true);
|
||||
assert.equal(
|
||||
nextLayerStateToken(
|
||||
parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
|
||||
parseLayerStateTokenReservations([
|
||||
...beforeLastDigitRows,
|
||||
...priorDigits,
|
||||
]),
|
||||
),
|
||||
'00',
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
parseLayerStateTokenReservations(preWildlifeRows),
|
||||
parseLayerStateTokenReservations(beforeLastDigitRows),
|
||||
codec.LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
),
|
||||
true,
|
||||
@@ -400,19 +423,19 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
|
||||
withPublishedBase(
|
||||
{
|
||||
rows: [...reservationRows, ['retired-layer', '00']],
|
||||
rows: [...reservationRows, ['retired-layer', '02']],
|
||||
source:
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "00" });\n',
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "02" });\n',
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 37);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.equal(Object.keys(published).length, 39);
|
||||
assert.equal(published['retired-layer'], '02');
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '00',
|
||||
newcomer: '02',
|
||||
}),
|
||||
/changed or removed/,
|
||||
);
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
["military", "m"],
|
||||
["military-awareness", "g"],
|
||||
["military-installations", "i"],
|
||||
["news", "00"],
|
||||
["radio", "r"],
|
||||
["recent-imagery", "1"],
|
||||
["rocket-launches", "x"],
|
||||
["s2-ghostmaps", "5"],
|
||||
["satellites", "s"],
|
||||
["sea-marks", "6"],
|
||||
["shipwrecks", "01"],
|
||||
["ski-pistes", "7"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Natural Earth packs
|
||||
|
||||
Offline named-region polygons for the voice-annotation resolver
|
||||
(`src/data/naturalEarthRegions.js`) — "outline the Alps" resolves to the real
|
||||
range geometry with no network dependency.
|
||||
Offline polygons with no network dependency. The voice-annotation resolver
|
||||
(`src/data/naturalEarthRegions.js`) reads the named regions, so "outline the
|
||||
Alps" resolves to the real range geometry; area annotations read the
|
||||
countries, states and provinces (`src/data/adminBoundaries.js`); and the News
|
||||
layer reads the countries and the disputed areas
|
||||
(`server/providers/news/areas.js`).
|
||||
|
||||
| File | Source dataset | Features |
|
||||
|------|----------------|----------|
|
||||
@@ -10,6 +13,7 @@ range geometry with no network dependency.
|
||||
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
|
||||
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
|
||||
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
|
||||
| `disputed_areas.json` | `ne_10m_admin_0_disputed_areas` (disputed, breakaway and UN-patrolled areas; a hand-picked set, see below) | 11 of 99 |
|
||||
| `populated_places.json` | `ne_10m_populated_places_simple` (cities and towns, as points) | 7,342 |
|
||||
|
||||
**Source:** Natural Earth 10m physical vectors, via the canonical
|
||||
@@ -105,6 +109,44 @@ Includes England, Scotland, Wales and Northern Ireland from
|
||||
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
|
||||
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
|
||||
|
||||
## Disputed areas (`disputed_areas.json`)
|
||||
|
||||
The News layer's areas that no one country's name may stand for
|
||||
(`server/providers/news/areas.js`): a place inside one is named with the
|
||||
area ("Crimea"), and Local there reads no country's outlets.
|
||||
|
||||
**Source:** `geojson/ne_10m_admin_0_disputed_areas.geojson` at the same pinned
|
||||
commit (SHA-256
|
||||
`9cafef8b7dfb6b164dc58f218f981f4ace9f716f6c03795d4c62d1ac9f3d50f5`), fetched
|
||||
2026-11-05. Public domain, as above.
|
||||
|
||||
**Selection:** a hand-picked 11 of the source's 99 features. No rule picks
|
||||
them; this is the list:
|
||||
|
||||
| Area | Natural Earth's note (`NOTE_BRK`) |
|
||||
|------|-----------------------------------|
|
||||
| Golan Heights | Admin. By Israel; Claimed by Syria |
|
||||
| UNDOF Zone | UN Patrolled Zone in Syria |
|
||||
| Transnistria | Self admin.; Claimed by Moldova |
|
||||
| Abyei | Admin. by Sudan; Claimed by South Sudan |
|
||||
| Crimea | Admin. by Russia; Claimed by Ukraine |
|
||||
| Shebaa Farms | Admin. By Israel; Claimed by Lebanon |
|
||||
| Bir Tawil | Between Egypt and Sudan |
|
||||
| Halayib Triangle | Admin. by Egypt; Claimed by Sudan |
|
||||
| East Jerusalem | Admin. By Israel; Claimed by Palestine |
|
||||
| Abu Musa I. | Admin. by Iran; Claimed by UAE |
|
||||
| Doumera Island | Admin. By Eritrea; Claimed by Djibouti |
|
||||
|
||||
The source names three of these by a country ("Moldova", "Sudan", "Iran");
|
||||
the pack names them as the area. The 2014 Donetsk and Luhansk lines are left
|
||||
out as out of date. Every other feature is left out too, so a place there is
|
||||
named by its country in `countries.json`.
|
||||
|
||||
**Curation:** `kind` (`held`, `self`, `un`, `unclaimed`), `heldBy` and
|
||||
`claimedBy` are read from `NOTE_BRK`, and `note` keeps it as published. Every
|
||||
part is kept; each ring is Douglas-Peucker simplified at 0.003°, coordinates
|
||||
are rounded to 3 decimals and rings are stored open. Provenance is in the
|
||||
pack's `meta`. Result: 14,504 bytes.
|
||||
## Populated places (`populated_places.json`)
|
||||
|
||||
The orbital view's "near <city>" (`src/data/placeBelow.js`): from 8x it
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-0 disputed areas, for the News layer","source":"ne_10m_admin_0_disputed_areas","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_disputed_areas.geojson","sha256":"9cafef8b7dfb6b164dc58f218f981f4ace9f716f6c03795d4c62d1ac9f3d50f5","fetched":"2026-11-05","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","curation":"A hand-picked 11 of the source's 99 features: Golan Heights, UNDOF Zone, Transnistria, Abyei, Crimea, Shebaa Farms, Bir Tawil, Halayib Triangle, East Jerusalem, Abu Musa I. and Doumera Island. The source names three of them by a country (Moldova, Sudan, Iran); they are named here as the area. kind, heldBy and claimedBy are read from NOTE_BRK; note is NOTE_BRK as published. The 2014 Donetsk and Luhansk lines are left out as out of date. Every part kept; each ring Douglas-Peucker simplified at 0.003 degrees, coordinates rounded to 3 decimals, rings stored open."},"areas":[{"name":"Golan Heights","kind":"held","heldBy":"IL","claimedBy":"SY","note":"Admin. By Israel; Claimed by Syria","rings":[[[35.821,33.407],[35.81,33.36],[35.786,33.343],[35.764,33.334],[35.802,33.312],[35.769,33.273],[35.804,33.248],[35.808,33.202],[35.83,33.19],[35.833,33.161],[35.811,33.127],[35.811,33.112],[35.849,33.099],[35.846,33.085],[35.859,32.99],[35.888,32.945],[35.85,32.896],[35.838,32.866],[35.842,32.854],[35.834,32.828],[35.784,32.778],[35.758,32.744],[35.74,32.741],[35.685,32.711],[35.636,32.679],[35.612,32.682],[35.595,32.707],[35.6,32.717],[35.634,32.752],[35.635,32.769],[35.641,32.772],[35.635,32.794],[35.641,32.82],[35.638,32.832],[35.641,32.865],[35.638,32.875],[35.602,32.909],[35.615,32.921],[35.62,32.984],[35.588,33.018],[35.621,33.05],[35.634,33.052],[35.637,33.077],[35.656,33.125],[35.672,33.15],[35.661,33.213],[35.66,33.236],[35.666,33.247],[35.635,33.249],[35.608,33.239],[35.605,33.244],[35.618,33.256],[35.62,33.27],[35.631,33.275],[35.654,33.262],[35.712,33.307],[35.718,33.317],[35.716,33.327],[35.744,33.331],[35.769,33.343],[35.786,33.358],[35.806,33.391]]]},{"name":"UNDOF Zone","kind":"un","note":"UN Patrolled Zone in Syria","rings":[[[35.758,32.744],[35.784,32.778],[35.834,32.828],[35.842,32.854],[35.838,32.866],[35.85,32.896],[35.888,32.945],[35.859,32.99],[35.846,33.085],[35.849,33.099],[35.811,33.112],[35.811,33.127],[35.833,33.161],[35.83,33.19],[35.808,33.202],[35.804,33.248],[35.769,33.273],[35.802,33.312],[35.764,33.334],[35.786,33.343],[35.81,33.36],[35.821,33.407],[35.87,33.431],[35.873,33.416],[35.87,33.407],[35.845,33.389],[35.83,33.365],[35.83,33.351],[35.843,33.322],[35.845,33.304],[35.84,33.295],[35.819,33.284],[35.82,33.281],[35.851,33.276],[35.866,33.226],[35.889,33.199],[35.882,33.189],[35.885,33.179],[35.9,33.18],[35.911,33.166],[35.911,33.135],[35.892,33.124],[35.897,33.117],[35.903,33.117],[35.903,33.11],[35.868,33.107],[35.865,33.082],[35.877,32.993],[35.898,32.986],[35.924,32.968],[35.912,32.947],[35.89,32.929],[35.861,32.88],[35.843,32.822],[35.815,32.787],[35.791,32.774],[35.77,32.748],[35.764,32.747]]]},{"name":"Transnistria","kind":"self","claimedBy":"MD","note":"Self admin.; Claimed by Moldova","rings":[[[29.899,46.553],[29.868,46.58],[29.84,46.582],[29.831,46.588],[29.824,46.601],[29.776,46.58],[29.728,46.594],[29.748,46.617],[29.776,46.629],[29.759,46.658],[29.749,46.665],[29.728,46.67],[29.703,46.665],[29.696,46.676],[29.667,46.676],[29.69,46.707],[29.698,46.728],[29.665,46.801],[29.652,46.813],[29.62,46.815],[29.611,46.827],[29.619,46.832],[29.615,46.841],[29.59,46.824],[29.563,46.787],[29.549,46.793],[29.543,46.793],[29.531,46.783],[29.512,46.787],[29.498,46.801],[29.502,46.821],[29.495,46.828],[29.494,46.862],[29.477,46.889],[29.471,46.941],[29.46,46.953],[29.436,46.957],[29.376,46.935],[29.35,46.937],[29.316,46.958],[29.296,46.992],[29.325,46.988],[29.371,47.003],[29.378,47.012],[29.374,47.011],[29.364,47.033],[29.381,47.029],[29.383,47.042],[29.378,47.071],[29.372,47.078],[29.33,47.108],[29.296,47.122],[29.291,47.14],[29.237,47.162],[29.227,47.149],[29.221,47.149],[29.213,47.158],[29.188,47.162],[29.179,47.156],[29.18,47.145],[29.194,47.124],[29.19,47.111],[29.157,47.113],[29.148,47.096],[29.125,47.074],[29.099,47.092],[29.07,47.101],[29.095,47.135],[29.112,47.15],[29.145,47.163],[29.164,47.192],[29.179,47.197],[29.164,47.231],[29.094,47.31],[29.08,47.332],[29.079,47.348],[29.089,47.351],[29.123,47.329],[29.14,47.329],[29.154,47.336],[29.165,47.348],[29.139,47.376],[29.083,47.406],[29.091,47.437],[29.108,47.454],[29.111,47.464],[29.104,47.478],[29.093,47.487],[29.049,47.506],[29.034,47.518],[29.025,47.533],[29.016,47.582],[28.993,47.605],[28.987,47.622],[28.996,47.651],[28.988,47.661],[28.981,47.687],[28.976,47.743],[28.981,47.765],[29.018,47.827],[29.019,47.853],[28.981,47.875],[28.861,47.899],[28.829,47.917],[28.831,47.941],[28.813,47.965],[28.785,47.983],[28.754,47.979],[28.719,47.999],[28.698,48.022],[28.682,48.031]Line truncated
|
||||
@@ -1807,7 +1807,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
[
|
||||
'aisLiveVessels',
|
||||
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
||||
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
/hasContact\(\s*id,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byId\s*\|\|\s*state\.records\.byId\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
],
|
||||
]) {
|
||||
assert.match(
|
||||
|
||||
@@ -61,9 +61,9 @@ export function formatAwarenessLabel(value) {
|
||||
value.callsign,
|
||||
value.name,
|
||||
value.label,
|
||||
value.mmsi,
|
||||
value.id,
|
||||
value.icao24,
|
||||
value.mmsi,
|
||||
]) {
|
||||
const label = formatAwarenessLabel(candidate);
|
||||
if (label !== '—') return label;
|
||||
@@ -154,7 +154,7 @@ export function getAwarenessNavigationTargets(
|
||||
? cohort.summary.nearest
|
||||
: [];
|
||||
for (const item of sourceItems) {
|
||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
||||
const id = item?.icao24 || item?.id || item?.mmsi;
|
||||
if (!id) continue;
|
||||
const key = `${layerId}:${id}`;
|
||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
||||
|
||||
+181
-8
@@ -198,6 +198,157 @@ export function staleCoastLimitSeconds({
|
||||
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
||||
}
|
||||
|
||||
/** @constant {number} Dead reckoning never moves a contact faster than this
|
||||
* (1,500 kt, the trail's continuity limit), so a corrupt reported speed
|
||||
* cannot fly a coasting plane hundreds of kilometres. */
|
||||
export const COAST_MAX_SPEED_MPS = (1_500 * 1852) / 3600;
|
||||
|
||||
/** @constant {number} A contact with no fresh report coasts at most this long past it, whatever its source limit; there is no distance cap. */
|
||||
export const STALE_COAST_MAX_SEC = 300;
|
||||
|
||||
/** @constant {number} A report this close to a known ground height holds
|
||||
* instead of coasting. */
|
||||
export const STALE_HOLD_MAX_HEIGHT_M = 150;
|
||||
|
||||
/**
|
||||
* Whether a contact whose reports have stopped holds at its last reported
|
||||
* position instead of coasting: it reported on the ground, or a known ground
|
||||
* floor puts it within STALE_HOLD_MAX_HEIGHT_M of the ground (landing or
|
||||
* taking off). A plane that low has no evidence of being anywhere but where
|
||||
* it was last heard. Where the ground is unknown, barometric altitude is not
|
||||
* read as height above it; the contact coasts under the stale cap.
|
||||
* @param {object} report
|
||||
* @param {boolean} [report.onGround]
|
||||
* @param {number} [report.heightM] - Render height (ellipsoidal metres).
|
||||
* @param {number} [report.groundM] - Ellipsoidal ground under the report, if known.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function staleHoldsAtLastReport({ onGround, heightM, groundM } = {}) {
|
||||
if (onGround === true) return true;
|
||||
return (
|
||||
Number.isFinite(heightM) &&
|
||||
Number.isFinite(groundM) &&
|
||||
heightM - groundM < STALE_HOLD_MAX_HEIGHT_M
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hold-or-coast decision for a report, made once and kept on the report,
|
||||
* so a ground floor that resolves while the contact coasts cannot flip it and
|
||||
* snap the icon back. Height and position come from the report itself.
|
||||
* @param {{position: Cesium.Cartesian3, staleHolds?: boolean}} fix - History
|
||||
* fix; `staleHolds` is stored on it.
|
||||
* @param {object} args
|
||||
* @param {boolean} [args.onGround] - The contact's ground flag.
|
||||
* @param {(latDeg: number, lonDeg: number) => number|null} [args.cachedGroundFloor]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function staleHoldsForFix(fix, { onGround, cachedGroundFloor } = {}) {
|
||||
if (typeof fix.staleHolds === 'boolean') return fix.staleHolds;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
fix.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_holdCarto,
|
||||
);
|
||||
const groundM =
|
||||
carto && typeof cachedGroundFloor === 'function'
|
||||
? cachedGroundFloor(
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
)
|
||||
: null;
|
||||
fix.staleHolds = staleHoldsAtLastReport({
|
||||
onGround,
|
||||
heightM: carto?.height,
|
||||
groundM,
|
||||
});
|
||||
return fix.staleHolds;
|
||||
}
|
||||
|
||||
const _holdCarto = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Seconds a contact coasts past `newest` at a display time `elapsedSec`
|
||||
* after it, counting any coast a synthetic fix already took since the real
|
||||
* report it was built from (`reportEpochMs`), so a chain of synthetic fixes
|
||||
* never coasts further than one report would.
|
||||
* @param {object} args
|
||||
* @param {{epochMs: number, reportEpochMs?: number, velocity?: number}} args.newest
|
||||
* @param {number} args.elapsedSec - Display time past `newest`.
|
||||
* @param {number} args.coastLimitSec - Source limit past `newest`.
|
||||
* @param {boolean} args.holds
|
||||
* @returns {number}
|
||||
*/
|
||||
export function staleCoastSecondsFrom({
|
||||
newest,
|
||||
elapsedSec,
|
||||
coastLimitSec,
|
||||
holds,
|
||||
}) {
|
||||
const alreadySec = Number.isFinite(newest?.reportEpochMs)
|
||||
? Math.max(0, (newest.epochMs - newest.reportEpochMs) / 1000)
|
||||
: 0;
|
||||
return Math.max(
|
||||
0,
|
||||
staleCoastSeconds({
|
||||
elapsedSec: elapsedSec + alreadySec,
|
||||
coastLimitSec: coastLimitSec + alreadySec,
|
||||
holds,
|
||||
}) - alreadySec,
|
||||
);
|
||||
}
|
||||
|
||||
/** @constant {number} A coasted position sits at least this far (m) above the ground. */
|
||||
export const COAST_FLOOR_LIFT_M = 1.5;
|
||||
|
||||
/**
|
||||
* Lift a coasted position to COAST_FLOOR_LIFT_M above the ground where the
|
||||
* ground is known; a coast runs level from the report and terrain may rise
|
||||
* under it.
|
||||
* @param {Cesium.Cartesian3} position - Mutated in place.
|
||||
* @param {(latDeg: number, lonDeg: number) => number|null} [cachedGroundFloor]
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
export function floorCoastedPosition(position, cachedGroundFloor) {
|
||||
if (typeof cachedGroundFloor !== 'function') return position;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_coastCarto,
|
||||
);
|
||||
if (!carto) return position;
|
||||
const ground = cachedGroundFloor(
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
);
|
||||
if (!Number.isFinite(ground) || carto.height >= ground + COAST_FLOOR_LIFT_M)
|
||||
return position;
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
ground + COAST_FLOOR_LIFT_M,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
position,
|
||||
);
|
||||
}
|
||||
|
||||
const _coastCarto = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Seconds to coast past the last report: the elapsed time, bounded by the
|
||||
* source's coast limit and STALE_COAST_MAX_SEC; none for a contact that
|
||||
* holds.
|
||||
* @param {object} args
|
||||
* @param {number} args.elapsedSec - Display time past the last report.
|
||||
* @param {number} args.coastLimitSec - From `staleCoastLimitSeconds`.
|
||||
* @param {boolean} args.holds - From `staleHoldsAtLastReport`.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function staleCoastSeconds({ elapsedSec, coastLimitSec, holds }) {
|
||||
if (holds || !(elapsedSec > 0)) return 0;
|
||||
return Math.max(0, Math.min(elapsedSec, coastLimitSec, STALE_COAST_MAX_SEC));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest-arc rate limiter: step `prevDeg` toward `targetDeg` by at most
|
||||
* `maxDegPerSec * dtSec`. Seeds directly on the target when prev is null.
|
||||
@@ -446,12 +597,21 @@ const _forwardFixOffset = new Cesium.Cartesian3();
|
||||
* kinematics. This prevents a late course change from reprojecting the entire
|
||||
* stale interval and snapping the rendered contact by kilometres.
|
||||
*
|
||||
* The same stale rules as the display apply: no fix for a contact that holds
|
||||
* at its report (`staleHoldsForFix`, decided before calling), a coast of at
|
||||
* most STALE_COAST_MAX_SEC counted from the real report
|
||||
* (`reportEpochMs` carries it along a chain), and never below known ground.
|
||||
* The result is a motion fix only, never a report for the trail.
|
||||
*
|
||||
* @param {{time: Cesium.JulianDate, epochMs?: number, position: Cesium.Cartesian3,
|
||||
* velocity?: number, track?: number}} newest Existing immutable history fix.
|
||||
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number}} next
|
||||
* velocity?: number, track?: number, staleHolds?: boolean, reportEpochMs?: number}} newest
|
||||
* Existing immutable history fix.
|
||||
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number,
|
||||
* cachedGroundFloor?: Function}} next
|
||||
* New kinematics and the wall/source epoch at which they become authoritative.
|
||||
* @returns {{time: Cesium.JulianDate, epochMs: number, position: Cesium.Cartesian3,
|
||||
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
||||
* velocity: number, track: number, staleHolds?: boolean, reportEpochMs: number}|null}
|
||||
* Forward synthetic fix, or null.
|
||||
*/
|
||||
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
|
||||
@@ -461,12 +621,18 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
|
||||
return null;
|
||||
if (newest.staleHolds === true) return null;
|
||||
|
||||
const previousVelocity = Number.isFinite(newest.velocity)
|
||||
? Math.max(0, newest.velocity)
|
||||
: 0;
|
||||
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
||||
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
||||
const dtSec = staleCoastSecondsFrom({
|
||||
newest: { epochMs: startEpochMs, reportEpochMs: newest.reportEpochMs },
|
||||
elapsedSec: (next.epochMs - startEpochMs) / 1000,
|
||||
coastLimitSec: Infinity,
|
||||
holds: false,
|
||||
});
|
||||
arcOffsetEnu(
|
||||
previousVelocity,
|
||||
previousTrack,
|
||||
@@ -485,14 +651,21 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_forwardFixEnu,
|
||||
);
|
||||
const position = Cesium.Matrix4.multiplyByPoint(
|
||||
enu,
|
||||
_forwardFixOffset,
|
||||
new Cesium.Cartesian3(),
|
||||
const position = floorCoastedPosition(
|
||||
Cesium.Matrix4.multiplyByPoint(
|
||||
enu,
|
||||
_forwardFixOffset,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
next.cachedGroundFloor,
|
||||
);
|
||||
return {
|
||||
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
||||
epochMs: next.epochMs,
|
||||
reportEpochMs: Number.isFinite(newest.reportEpochMs)
|
||||
? newest.reportEpochMs
|
||||
: startEpochMs,
|
||||
staleHolds: newest.staleHolds,
|
||||
position,
|
||||
velocity: Number.isFinite(next.velocity)
|
||||
? Math.max(0, next.velocity)
|
||||
|
||||
@@ -21,7 +21,7 @@ const _owners = new Map();
|
||||
*
|
||||
* Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use
|
||||
* strings, satellites use numeric NORAD catalog ids, live AIS vessels attach
|
||||
* the vessel record OBJECT (identity = its `mmsi`), and entity picks surface
|
||||
* the vessel record OBJECT (identity = its `id`), and entity picks surface
|
||||
* the Cesium Entity (identity = its string `id`). Everything is coerced to a
|
||||
* String so predicates match against one canonical form.
|
||||
*
|
||||
@@ -33,9 +33,7 @@ export function resolvePickId(picked) {
|
||||
const unwrap = (id) => {
|
||||
if (id === null || id === undefined) return undefined;
|
||||
if (typeof id === 'object') {
|
||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||
return id.mmsi;
|
||||
// AIS vessel record or Cesium Entity: both are keyed by their `.id`.
|
||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () =
|
||||
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
|
||||
});
|
||||
|
||||
test('resolvePickId: AIS vessel record object → its mmsi', () => {
|
||||
const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
test('resolvePickId: AIS vessel record object → its id, not its MMSI', () => {
|
||||
const record = { id: '367123450', mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
assert.equal(resolvePickId({ id: record }), '367123450');
|
||||
assert.equal(resolvePickId({ primitive: { id: record } }), '367123450');
|
||||
const shared = { id: 'hull-b', mmsi: '367123450', name: 'EVER GIVEN' };
|
||||
assert.equal(resolvePickId({ id: shared }), 'hull-b');
|
||||
});
|
||||
|
||||
test('resolvePickId: Cesium-Entity-like object → its string id', () => {
|
||||
@@ -48,7 +50,7 @@ test('resolvePickId: unresolvable picks → null', () => {
|
||||
assert.equal(resolvePickId(null), null);
|
||||
assert.equal(resolvePickId(undefined), null);
|
||||
assert.equal(resolvePickId({}), null);
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no mmsi/id
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no id
|
||||
assert.equal(resolvePickId({ primitive: {} }), null);
|
||||
});
|
||||
|
||||
@@ -67,8 +69,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', ()
|
||||
try {
|
||||
// Flights asking about a satellite pick (raw numeric id → String upstream)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
|
||||
// Flights asking about a vessel pick (record object → mmsi)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true);
|
||||
// Flights asking about a vessel pick (record object → id)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { id: '367123450', mmsi: '367123450' } })), true);
|
||||
// A layer never owns its own pick via the sibling scan
|
||||
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
|
||||
// Unknown ids belong to nobody
|
||||
@@ -147,7 +149,7 @@ test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CS
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
for (const first of [{ id: 'military' }, { id: { id: '123456789', mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
|
||||
@@ -64,8 +64,8 @@ function contextCarrierRecord(picked, dataManager) {
|
||||
function fromRecord(record) {
|
||||
const layerId = record.layerId || null;
|
||||
const id =
|
||||
layerId === 'ais-live-vessels' && record.properties?.mmsi
|
||||
? String(record.properties.mmsi)
|
||||
layerId === 'ais-live-vessels' && record.properties?.vesselId
|
||||
? String(record.properties.vesselId)
|
||||
: String(record.id);
|
||||
return {
|
||||
layerId,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* @module planespottersPhoto
|
||||
* @description Reads a Planespotters photo answer into the one credited
|
||||
* thumbnail every surface shows. Planespotters lets the thumbnail be hotlinked
|
||||
* on the condition that the photographer is credited next to it and the image
|
||||
* links back to the photo's page, so a photo without a photographer, or whose
|
||||
* image or page is not on Planespotters' own hosts, is "no photo": nothing
|
||||
* arbitrary is ever shown or linked.
|
||||
* @description Reads a Planespotters photo answer into the one thumbnail
|
||||
* every surface shows. Planespotters lets the thumbnail be hotlinked on the
|
||||
* condition that the photographer is credited next to it and the image links
|
||||
* back to the photo's page. A photo whose answer names no photographer is
|
||||
* still shown, linked to its page, with no credit line; a photo whose image or
|
||||
* page is not on Planespotters' own hosts is "no photo": nothing arbitrary is
|
||||
* ever shown or linked.
|
||||
*
|
||||
* Accepts the upstream answer (`GET https://api.planespotters.net/pub/photos/hex/{hex}`,
|
||||
* `{photos: [...]}`), the cached server shape
|
||||
@@ -63,7 +64,7 @@ function checked({ src, width, height, photographer, link }) {
|
||||
const credit = typeof photographer === 'string' ? photographer.trim() : '';
|
||||
const image = imageSrc(src);
|
||||
const page = pageLink(link);
|
||||
if (!credit || !image || !page) return null;
|
||||
if (!image || !page) return null;
|
||||
return {
|
||||
found: true,
|
||||
src: image,
|
||||
|
||||
@@ -105,6 +105,16 @@ test('a server’s cached shape falls back to its small thumbnail when the large
|
||||
}
|
||||
});
|
||||
|
||||
test('a photo that names no photographer is still a photo, uncredited', () => {
|
||||
for (const photographer of ['', ' ', 42, undefined]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(photo({ photographer })),
|
||||
{ ...PHOTO, photographer: '' },
|
||||
String(photographer),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no photo, junk, and missing fields are "no photo"', () => {
|
||||
for (const payload of [
|
||||
{ photos: [] },
|
||||
@@ -114,9 +124,6 @@ test('no photo, junk, and missing fields are "no photo"', () => {
|
||||
'nope',
|
||||
{ photos: 'nope' },
|
||||
{ photos: [null] },
|
||||
photo({ photographer: '' }),
|
||||
photo({ photographer: ' ' }),
|
||||
photo({ photographer: 42 }),
|
||||
photo({ link: '' }),
|
||||
photo({ thumbnail: null, thumbnail_large: null }),
|
||||
]) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
REGIONAL_NEWS_WINDOW_HOURS,
|
||||
googleNewsWindowOperator,
|
||||
normalizeRegionalArticles,
|
||||
recentRegionalArticles,
|
||||
regionalNewsWindowHours,
|
||||
normalizeRegionalPlace,
|
||||
normalizeRegionalWeather,
|
||||
regionalDistanceM,
|
||||
@@ -61,3 +65,37 @@ test('regional distance handles nearby movement and missing positions', () => {
|
||||
assert.ok(distance > 11000 && distance < 11200);
|
||||
assert.equal(regionalDistanceM(null, null), Infinity);
|
||||
});
|
||||
|
||||
test('regional news is held to one 24-hour window, newest first, undated dropped', () => {
|
||||
assert.equal(REGIONAL_NEWS_WINDOW_HOURS, 24);
|
||||
assert.equal(googleNewsWindowOperator(), 'when:1d');
|
||||
assert.equal(googleNewsWindowOperator(6), 'when:6h');
|
||||
assert.equal(regionalNewsWindowHours('12'), 12);
|
||||
assert.equal(regionalNewsWindowHours(0), 24);
|
||||
assert.equal(regionalNewsWindowHours(undefined), 24);
|
||||
const now = Date.parse('2026-11-04T12:00:00Z');
|
||||
const at = (hours) => new Date(now - hours * 3_600_000).toISOString();
|
||||
const rows = [
|
||||
{ title: 'a', publishedAt: at(23) },
|
||||
{ title: 'stale', publishedAt: at(25) },
|
||||
{ title: 'undated', publishedAt: null },
|
||||
{ title: 'garbled', publishedAt: 'not a date' },
|
||||
{ title: 'b', publishedAt: at(1) },
|
||||
{ title: 'future', publishedAt: at(-2) },
|
||||
{ title: 'c', publishedAt: at(5) },
|
||||
];
|
||||
assert.deepEqual(
|
||||
recentRegionalArticles(rows, { now }).map((row) => row.title),
|
||||
['b', 'c', 'a'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
recentRegionalArticles(rows, { now, limit: 2 }).map((row) => row.title),
|
||||
['b', 'c'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
recentRegionalArticles(rows, { now, windowHours: 4 }).map((row) => row.title),
|
||||
['b'],
|
||||
);
|
||||
assert.deepEqual(recentRegionalArticles([], { now }), []);
|
||||
assert.deepEqual(recentRegionalArticles(undefined, { now }), []);
|
||||
});
|
||||
@@ -55,6 +55,13 @@ export function normalizeRegionalPlace(payload) {
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
REGIONAL_NEWS_WINDOW_HOURS,
|
||||
googleNewsWindowOperator,
|
||||
recentRegionalArticles,
|
||||
regionalNewsWindowHours,
|
||||
} from './regionalNewsWindow.js';
|
||||
|
||||
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
|
||||
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/** The regional-news freshness window, shared by the servers and the Cockpit. */
|
||||
|
||||
/** At most this many articles answer a regional brief. */
|
||||
export const REGIONAL_NEWS_ROWS = 5;
|
||||
|
||||
/**
|
||||
* The regional-news freshness window, in hours. The servers ask upstream for
|
||||
* this window, drop anything older and report it as `newsWindowHours`; the
|
||||
* Cockpit labels its list with it.
|
||||
*/
|
||||
export const REGIONAL_NEWS_WINDOW_HOURS = 24;
|
||||
|
||||
// A publish time this far ahead of the clock is skew, not a future story.
|
||||
const REGIONAL_NEWS_CLOCK_SKEW_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* A served window in hours, or the default when it is missing or out of range.
|
||||
* @param {unknown} value
|
||||
* @returns {number}
|
||||
*/
|
||||
export function regionalNewsWindowHours(value) {
|
||||
const hours = Number(value);
|
||||
return Number.isInteger(hours) && hours >= 1 && hours <= 168
|
||||
? hours
|
||||
: REGIONAL_NEWS_WINDOW_HOURS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google News search operator asking for the window: `when:1d` for 24 h.
|
||||
* @param {number} [hours]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function googleNewsWindowOperator(hours = REGIONAL_NEWS_WINDOW_HOURS) {
|
||||
const span = regionalNewsWindowHours(hours);
|
||||
return span % 24 === 0 ? `when:${span / 24}d` : `when:${span}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The articles published inside the window, newest first, at most `limit`.
|
||||
* An article without a parseable publish time is dropped: nothing shows it is
|
||||
* recent. One dated more than 15 minutes ahead of `now` is dropped too.
|
||||
* @param {Array<{publishedAt?: string|null}>} articles
|
||||
* @param {{now?: number, windowHours?: number, limit?: number}} [options]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function recentRegionalArticles(
|
||||
articles,
|
||||
{
|
||||
now = Date.now(),
|
||||
windowHours = REGIONAL_NEWS_WINDOW_HOURS,
|
||||
limit = REGIONAL_NEWS_ROWS,
|
||||
} = {},
|
||||
) {
|
||||
const oldest = now - regionalNewsWindowHours(windowHours) * 3_600_000;
|
||||
return (Array.isArray(articles) ? articles : [])
|
||||
.map((article) => ({ article, at: Date.parse(article?.publishedAt) }))
|
||||
.filter(
|
||||
({ at }) =>
|
||||
Number.isFinite(at) &&
|
||||
at >= oldest &&
|
||||
at <= now + REGIONAL_NEWS_CLOCK_SKEW_MS,
|
||||
)
|
||||
.sort((a, b) => b.at - a.at)
|
||||
.slice(0, Math.max(0, limit))
|
||||
.map(({ article }) => article);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import createViteConfig, {
|
||||
validMilitaryInstallationBox,
|
||||
validRegionalPoint,
|
||||
} from '../../vite.config.js';
|
||||
import { fetchRegionalNews } from '../../server/providers/regional/news.js';
|
||||
|
||||
test('missing Google place context is a quiet keyless capability, not a 503', () => {
|
||||
assert.deepEqual(keylessGooglePlacesResponse(undefined), {
|
||||
@@ -113,3 +114,50 @@ test('regional brief treats an all-source outage as total failure', () => {
|
||||
news: { status: 'unavailable' },
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('local regional news asks for the last day, drops old and undated items, newest first', async (t) => {
|
||||
const realFetch = globalThis.fetch;
|
||||
t.after(() => { globalThis.fetch = realFetch; });
|
||||
const urls = [];
|
||||
let rss;
|
||||
let gdelt;
|
||||
globalThis.fetch = async (url) => {
|
||||
urls.push(new URL(String(url)));
|
||||
const answer = String(url).includes('news.google') ? rss : gdelt;
|
||||
return typeof answer === 'function' ? answer() : answer.clone();
|
||||
};
|
||||
const at = (hours) => new Date(Date.now() - hours * 3_600_000).toUTCString();
|
||||
const item = (title, date) =>
|
||||
`<item><title>${title}</title><link>https://news.example/${title}</link>${date ? `<pubDate>${date}</pubDate>` : ''}</item>`;
|
||||
rss = new Response(`<rss><channel>${[
|
||||
item('old', at(4 * 24)),
|
||||
item('undated', null),
|
||||
item('day', at(20)),
|
||||
item('hour', at(1)),
|
||||
].join('')}</channel></rss>`);
|
||||
const place = { locality: 'Austin' };
|
||||
const ready = await fetchRegionalNews(place);
|
||||
assert.equal(urls[0].searchParams.get('q'), 'Austin when:1d');
|
||||
assert.equal(ready.status, 'ready');
|
||||
assert.deepEqual(ready.articles.map((row) => row.title), ['hour', 'day']);
|
||||
|
||||
// Nothing recent in RSS: GDELT is asked for the same 24 h window.
|
||||
rss = new Response(`<rss><channel>${item('old', at(30))}</channel></rss>`);
|
||||
gdelt = Response.json({ articles: [] });
|
||||
urls.length = 0;
|
||||
const empty = await fetchRegionalNews(place);
|
||||
assert.equal(urls[1].searchParams.get('timespan'), '24h');
|
||||
assert.deepEqual(empty, { status: 'empty', query: 'Austin', articles: [], source: 'GDELT fallback' });
|
||||
|
||||
// RSS answered with nothing recent and GDELT failed: still a true empty.
|
||||
rss = new Response('<rss><channel></channel></rss>');
|
||||
gdelt = () => Promise.reject(new TypeError('network'));
|
||||
assert.equal((await fetchRegionalNews(place)).status, 'empty');
|
||||
|
||||
// Neither source answered, or no place: unavailable.
|
||||
rss = () => Promise.resolve(new Response('busy', { status: 503 }));
|
||||
const failed = await fetchRegionalNews(place);
|
||||
assert.equal(failed.status, 'unavailable');
|
||||
assert.deepEqual(failed.articles, []);
|
||||
assert.equal((await fetchRegionalNews(null)).status, 'unavailable');
|
||||
});
|
||||
@@ -46,7 +46,13 @@ function alongTrackKm(position, origin, destination) {
|
||||
}
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
/** Least allowed distance off the great circle, and how far past either
|
||||
* endpoint the plane may be along it. */
|
||||
const CROSS_TRACK_KM = 200;
|
||||
/** On a long route the allowed distance off the great circle grows to this
|
||||
* share of its length: airways and winds take a coast-to-coast flight several
|
||||
* hundred kilometres off it (JFK–SFO: 623 km). */
|
||||
const CROSS_TRACK_ROUTE_FRACTION = 0.15;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOCAL_AIRPORT_KM = 150;
|
||||
@@ -90,6 +96,9 @@ export function routePlausible({
|
||||
// existing loose check when the segment cannot be judged reliably.
|
||||
const segmentKnown =
|
||||
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
|
||||
const corridorKm = segmentKnown
|
||||
? Math.max(CROSS_TRACK_KM, CROSS_TRACK_ROUTE_FRACTION * routeKm)
|
||||
: CROSS_TRACK_KM;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
@@ -100,7 +109,7 @@ export function routePlausible({
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM &&
|
||||
) < corridorKm &&
|
||||
(!segmentKnown ||
|
||||
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||
|
||||
@@ -27,6 +27,24 @@ test('plane in London with an SFO→LAX route: implausible', () => {
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('a long route allows a wider corridor; a short one does not', () => {
|
||||
// JFK→SFO over Kansas, 384 km south of the great circle.
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 38.21, lonDeg: -99.69, altitudeM: 10370, verticalRateMps: 0,
|
||||
origin: JFK, destination: SFO,
|
||||
}), true);
|
||||
// 516 km off the 543 km SFO→LAX route is still the wrong route.
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 37.9, lonDeg: -115.0, altitudeM: 10000, verticalRateMps: 0,
|
||||
origin: SFO, destination: LAX,
|
||||
}), false);
|
||||
// 960 km off, past 15 % of the 4,152 km JFK→SFO route, is still wrong.
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 33.0, lonDeg: -99.69, altitudeM: 10000, verticalRateMps: 0,
|
||||
origin: JFK, destination: SFO,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('vertical trend: low climbing plane near SFO — origin must be local', () => {
|
||||
const nearSfo = { latDeg: 37.7, lonDeg: -122.4, altitudeM: 2500 };
|
||||
// JFK→SFO passes geometry (near destination), but a CLIMBING plane here just
|
||||
|
||||
@@ -140,7 +140,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
services.aircraftPresentation = { isTr3b: () => false };
|
||||
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||
const parts = {
|
||||
queries: { _contactLabel: (_, info) => info.callsign },
|
||||
motion: { _signalLost: () => null },
|
||||
};
|
||||
const tracking = createTracking({ flightState, services, parts });
|
||||
for (const [position, expected] of [
|
||||
[beyond, false],
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* Asks the GPU, per contact, whether the scene drew something in front of it,
|
||||
* without waiting for the answer.
|
||||
*
|
||||
* The scene already keeps the depth of the frame it has just drawn (for
|
||||
* picking). `scene.pickPosition` reads one texel of it back and waits, a
|
||||
* fraction of a millisecond to over a millisecond per call; asking once per
|
||||
* labelled contact turns a 2 ms frame into a 40 ms one. Copying the whole
|
||||
* depth image back instead is no better where it matters: Chromium answers
|
||||
* `getBufferSubData` with a round trip to its GPU process even after a sync
|
||||
* object has signalled, measured here at 1.5 ms on a quiet machine and 15 to
|
||||
* 25 ms on a busy one.
|
||||
*
|
||||
* So nothing is read back at all. Each tested contact becomes one point drawn
|
||||
* into a private 1x1 target, inside an occlusion query. The vertex shader looks
|
||||
* up the frame's depth at the contact's pixel, decodes it, and throws the point
|
||||
* away when the drawn surface is nearer than the contact's threshold (see
|
||||
* `projectContact`). The query's "did anything draw" bit is the answer, and
|
||||
* asking whether a query result is in is the one question WebGL 2 lets a page
|
||||
* put to the GPU without waiting (Chromium publishes results to the page
|
||||
* through shared memory between frames): `_collect` just looks.
|
||||
*
|
||||
* - `beginFrame(scene, now)` collects the answers that have arrived.
|
||||
* - `add(contact, position)` queues a contact for testing against this frame.
|
||||
* - `endFrame()` draws the queued points. Answers land a frame or two later.
|
||||
*
|
||||
* A WebGL 1 context has no queries. There the probe falls back to reading the
|
||||
* frame's depth back synchronously, at most every `SYNC_READ_INTERVAL_MS`, and
|
||||
* answering on the CPU; `mode` then reads `'sync'`. Where the scene keeps no
|
||||
* pick depth, or is not a 3D perspective view, `mode` reads `'off'`, nothing
|
||||
* is tested and callers carry on as if no contact were hidden.
|
||||
*
|
||||
* Every read of renderer internals is in `readSceneDepthSource` below; the
|
||||
* rest of the file is plain WebGL on the handles that function returns.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { DepthSnapshot, projectContact } from './detectionOcclusion.js';
|
||||
|
||||
/**
|
||||
* Depth ranges tested, nearest first. Logarithmic depth draws nearly every
|
||||
* view in one or two; anything past the fourth is beyond where depth-tested
|
||||
* contacts are labelled.
|
||||
*/
|
||||
export const PROBE_MAX_FRUSTUMS = 4;
|
||||
|
||||
/** Contacts tested per frame; the rest wait for the next one. */
|
||||
export const PROBE_MAX_PER_FRAME = 256;
|
||||
|
||||
/** Painted frames a batch of queries may stay unanswered before it is dropped. */
|
||||
export const PROBE_MAX_PENDING_FRAMES = 120;
|
||||
|
||||
/** Batches in a row whose control point failed before the probe stands down. */
|
||||
export const PROBE_MAX_CONTROL_FAILURES = 3;
|
||||
|
||||
/** How long the probe stands down after a failure before trying again. */
|
||||
export const PROBE_RETRY_AFTER_MS = 10_000;
|
||||
|
||||
/** Shortest gap between two synchronous depth reads (WebGL 1 fallback). */
|
||||
export const SYNC_READ_INTERVAL_MS = 125;
|
||||
|
||||
/**
|
||||
* Everything this module takes from the renderer, in one place.
|
||||
*
|
||||
* RENDERER INTERNALS. Checked against cesium 1.138.0 (@cesium/engine 22.3.0);
|
||||
* re-check on an engine upgrade. Undocumented members used, and what each is
|
||||
* assumed to be:
|
||||
*
|
||||
* - `scene.context._gl`: the WebGL context. `scene.context.webgl2` says which.
|
||||
* - `scene.view.frustumCommandsList[i].near` / `.far`: the depth ranges the
|
||||
* last frame was drawn in, nearest first.
|
||||
* - `scene.view.pickDepths[i].framebuffer`: one framebuffer per depth range,
|
||||
* refreshed every frame while `scene.useDepthPicking` is on and
|
||||
* `scene._environmentState.useGlobeDepthFramebuffer` is true, whose first
|
||||
* colour attachment is an RGBA8 texture holding window-space depth packed
|
||||
* four bytes to a texel (`czm_packDepth`; see `unpackDepth`).
|
||||
* `framebuffer._framebuffer` is the raw WebGL framebuffer, and
|
||||
* `framebuffer.getColorTexture(0)` the texture (`._texture` its raw handle).
|
||||
* - Opaque geometry in every range but the nearest is drawn with its near
|
||||
* plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as
|
||||
* `scene.pickPosition` assumes when it decodes the same textures.
|
||||
* - `scene.frameState.useLogDepth`: whether depth is logarithmic (see
|
||||
* `depthToViewDistance`). Anything but a boolean there is "no depth".
|
||||
*
|
||||
* Any of these missing or shaped differently returns null, which callers treat
|
||||
* as "no depth".
|
||||
*
|
||||
* @param {Cesium.Scene} scene
|
||||
* @param {Float64Array} [viewScratch] Sixteen numbers to write the view matrix
|
||||
* into instead of allocating; the result's `view` is then this array.
|
||||
* @returns {null | {
|
||||
* gl: WebGLRenderingContext|WebGL2RenderingContext, webgl2: boolean,
|
||||
* width: number, height: number, framebuffers: WebGLFramebuffer[],
|
||||
* textures: WebGLTexture[],
|
||||
* frustums: Array<{near: number, far: number}>, logDepth: boolean,
|
||||
* view: Float64Array, tanHalfFovX: number, tanHalfFovY: number,
|
||||
* }}
|
||||
*/
|
||||
export function readSceneDepthSource(scene, viewScratch) {
|
||||
try {
|
||||
if (!scene || scene.mode !== Cesium.SceneMode.SCENE3D) return null;
|
||||
if (scene.useDepthPicking !== true) return null;
|
||||
const context = scene.context;
|
||||
const gl = context?._gl;
|
||||
if (!gl || context.depthTexture !== true) return null;
|
||||
if (scene._environmentState?.useGlobeDepthFramebuffer !== true) return null;
|
||||
|
||||
const camera = scene.camera;
|
||||
const frustum = camera?.frustum;
|
||||
const fovy = frustum?.fovy;
|
||||
const aspectRatio = frustum?.aspectRatio;
|
||||
if (!(fovy > 0 && fovy < Math.PI) || !(aspectRatio > 0)) return null;
|
||||
if (frustum.xOffset || frustum.yOffset) return null;
|
||||
|
||||
const view = scene.view;
|
||||
const ranges = view?.frustumCommandsList;
|
||||
const pickDepths = view?.pickDepths;
|
||||
if (!ranges?.length || !pickDepths?.length) return null;
|
||||
const count = Math.min(
|
||||
ranges.length,
|
||||
pickDepths.length,
|
||||
PROBE_MAX_FRUSTUMS,
|
||||
);
|
||||
const nearOffset = Number(scene.opaqueFrustumNearOffset) || 1;
|
||||
const framebuffers = [];
|
||||
const textures = [];
|
||||
const frustums = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const framebuffer = pickDepths[i]?.framebuffer;
|
||||
const raw = framebuffer?._framebuffer;
|
||||
const texture = framebuffer?.getColorTexture?.(0);
|
||||
const near = ranges[i]?.near * (i === 0 ? 1 : nearOffset);
|
||||
const far = ranges[i]?.far;
|
||||
if (!raw || !texture?._texture || !(near > 0) || !(far > near))
|
||||
return null;
|
||||
if (i === 0) {
|
||||
width = texture.width;
|
||||
height = texture.height;
|
||||
} else if (texture.width !== width || texture.height !== height) {
|
||||
return null;
|
||||
}
|
||||
framebuffers.push(raw);
|
||||
textures.push(texture._texture);
|
||||
frustums.push({ near, far });
|
||||
}
|
||||
if (!(width > 0) || !(height > 0)) return null;
|
||||
// Read as standard depth, logarithmic depth puts every surface a few
|
||||
// metres from the camera and would hide every contact: unknown is "no".
|
||||
const logDepth = scene.frameState?.useLogDepth;
|
||||
if (typeof logDepth !== 'boolean') return null;
|
||||
|
||||
const tanHalfFovY = Math.tan(fovy / 2);
|
||||
return {
|
||||
gl,
|
||||
webgl2: context.webgl2 === true,
|
||||
width,
|
||||
height,
|
||||
framebuffers,
|
||||
textures,
|
||||
frustums,
|
||||
logDepth,
|
||||
view: Cesium.Matrix4.toArray(
|
||||
camera.viewMatrix,
|
||||
viewScratch || new Float64Array(16),
|
||||
),
|
||||
tanHalfFovX: tanHalfFovY * aspectRatio,
|
||||
tanHalfFovY,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One point per contact. `a_contact` is (u, v, threshold): where the contact
|
||||
* is in the frame, and the view distance a drawn surface must be under to hide
|
||||
* it. A hidden contact's point is moved outside the clip volume, so nothing is
|
||||
* drawn and its query reports no samples. A visible one lands on the single
|
||||
* pixel of the 1x1 target (the viewport is set to match while these draw).
|
||||
*/
|
||||
export const PROBE_VERTEX_SHADER = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp sampler2D;
|
||||
layout(location = 0) in vec3 a_contact;
|
||||
uniform sampler2D u_depth0;
|
||||
uniform sampler2D u_depth1;
|
||||
uniform sampler2D u_depth2;
|
||||
uniform sampler2D u_depth3;
|
||||
uniform int u_count;
|
||||
uniform bool u_logDepth;
|
||||
uniform vec4 u_near;
|
||||
uniform vec4 u_far;
|
||||
|
||||
float viewDistance(sampler2D depthTexture, float near, float far) {
|
||||
ivec2 size = textureSize(depthTexture, 0);
|
||||
ivec2 texel = min(ivec2(a_contact.xy * vec2(size)), size - 1);
|
||||
float depth = dot(
|
||||
texelFetch(depthTexture, texel, 0),
|
||||
vec4(1.0, 1.0 / 255.0, 1.0 / 65025.0, 1.0 / 16581375.0));
|
||||
if (depth <= 0.0 || depth >= 1.0) return -1.0;
|
||||
if (u_logDepth) return near + exp2(depth * log2(far - near + 1.0)) - 1.0;
|
||||
return near * far / (far - depth * (far - near));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float surface = viewDistance(u_depth0, u_near.x, u_far.x);
|
||||
if (surface < 0.0 && u_count > 1) surface = viewDistance(u_depth1, u_near.y, u_far.y);
|
||||
if (surface < 0.0 && u_count > 2) surface = viewDistance(u_depth2, u_near.z, u_far.z);
|
||||
if (surface < 0.0 && u_count > 3) surface = viewDistance(u_depth3, u_near.w, u_far.w);
|
||||
bool hidden = surface > 0.0 && surface < a_contact.z;
|
||||
gl_Position = hidden ? vec4(2.0, 2.0, 2.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0);
|
||||
gl_PointSize = 1.0;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = `#version 300 es
|
||||
precision mediump float;
|
||||
out vec4 color;
|
||||
void main() { color = vec4(1.0); }
|
||||
`;
|
||||
|
||||
function canQuery(gl) {
|
||||
return (
|
||||
typeof gl.createQuery === 'function' &&
|
||||
typeof gl.beginQuery === 'function' &&
|
||||
typeof gl.getQueryParameter === 'function' &&
|
||||
typeof gl.createVertexArray === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function compile(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
/** Owns the GPU objects and the query batches in flight. */
|
||||
export class SceneOcclusionProbe {
|
||||
/**
|
||||
* @param {{
|
||||
* now?: () => number,
|
||||
* readSource?: typeof readSceneDepthSource,
|
||||
* }} [options]
|
||||
*/
|
||||
constructor({
|
||||
now = () => performance.now(),
|
||||
readSource = readSceneDepthSource,
|
||||
} = {}) {
|
||||
this._clock = now;
|
||||
this._readSource = readSource;
|
||||
/** Most tests one frame takes; read by whoever schedules them. */
|
||||
this.maxPerFrame = PROBE_MAX_PER_FRAME;
|
||||
/** `'async'` (queries), `'sync'` (blocking read), or `'off'`. */
|
||||
this.mode = 'off';
|
||||
/** Milliseconds the last frame spent collecting answers and drawing. */
|
||||
this.lastFrameMs = 0;
|
||||
/** Contact tests started in the last frame. */
|
||||
this.lastFrameTests = 0;
|
||||
this._scene = null;
|
||||
this._now = 0;
|
||||
this._source = undefined;
|
||||
this._gl = null;
|
||||
/** A context the probe's shader failed to build on; never retried. */
|
||||
this._unsupportedGl = null;
|
||||
this._resources = null;
|
||||
this._queryPool = [];
|
||||
this._batches = [];
|
||||
this._attributes = new Float32Array((PROBE_MAX_PER_FRAME + 1) * 3);
|
||||
this._queued = [];
|
||||
this._projected = { u: 0, v: 0, threshold: 0 };
|
||||
this._controlFailures = 0;
|
||||
this._retryAt = Number.NEGATIVE_INFINITY;
|
||||
this._frame = 0;
|
||||
this._view = new Float64Array(16);
|
||||
this._snapshot = null;
|
||||
this._snapshotAt = Number.NEGATIVE_INFINITY;
|
||||
this._spent = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a frame: take in the answers that have arrived. Call after the scene
|
||||
* has rendered (a post-render listener), before `add`.
|
||||
* @param {Cesium.Scene} scene
|
||||
* @param {number} now The caller's clock, in milliseconds. Answers are
|
||||
* dated by it, so it must be the clock the caller judges their age on.
|
||||
*/
|
||||
beginFrame(scene, now) {
|
||||
const started = this._clock();
|
||||
this._scene = scene;
|
||||
this._now = now;
|
||||
this._frame++;
|
||||
this._source = undefined;
|
||||
// A frame that never reached `endFrame` leaves its contacts untested.
|
||||
this._release(this._queued);
|
||||
this._queued.length = 0;
|
||||
this.lastFrameTests = 0;
|
||||
this._collect();
|
||||
this._spent = this._clock() - started;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a contact for testing against the frame just drawn.
|
||||
* @param {{lastHiddenAt: number, pending: boolean, answered: boolean}}
|
||||
* contact Updated when the answer arrives: `pending` cleared, `answered`
|
||||
* set and, if the contact was hidden, `lastHiddenAt` set to the time of
|
||||
* the frame it was tested against.
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @returns {boolean} False when the contact cannot be tested now.
|
||||
*/
|
||||
add(contact, position) {
|
||||
if (this._source === undefined) this._openSource();
|
||||
const source = this._source;
|
||||
if (!source) return false;
|
||||
if (this.mode === 'sync') {
|
||||
const hidden = this._snapshot?.hides(position);
|
||||
if (hidden === null || hidden === undefined) return false;
|
||||
contact.answered = true;
|
||||
if (hidden) contact.lastHiddenAt = this._now;
|
||||
this.lastFrameTests++;
|
||||
return true;
|
||||
}
|
||||
const queued = this._queued;
|
||||
if (queued.length >= PROBE_MAX_PER_FRAME) return false;
|
||||
const projected = this._projected;
|
||||
if (
|
||||
!projectContact(
|
||||
source.view,
|
||||
source.tanHalfFovX,
|
||||
source.tanHalfFovY,
|
||||
position,
|
||||
projected,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
// Slot 0 is the control point; contacts start at 1.
|
||||
const at = (queued.length + 1) * 3;
|
||||
const attributes = this._attributes;
|
||||
attributes[at] = projected.u;
|
||||
attributes[at + 1] = projected.v;
|
||||
attributes[at + 2] = projected.threshold;
|
||||
queued.push(contact);
|
||||
contact.pending = true;
|
||||
this.lastFrameTests++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Close the frame: draw the queued contacts' queries. */
|
||||
endFrame() {
|
||||
const started = this._clock();
|
||||
if (this._queued.length > 0 && this._source && this.mode === 'async') {
|
||||
try {
|
||||
this._draw(this._source);
|
||||
} catch {
|
||||
this._release(this._queued);
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
this._queued.length = 0;
|
||||
this._source = undefined;
|
||||
this.lastFrameMs = this._spent + (this._clock() - started);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every test in flight but keep the GPU objects, so that switching the
|
||||
* overlay off and on again does not compile the shader a second time.
|
||||
*/
|
||||
reset() {
|
||||
const gl = this._gl;
|
||||
for (const batch of this._batches) this._release(batch.contacts);
|
||||
this._release(this._queued);
|
||||
try {
|
||||
if (gl && !gl.isContextLost?.()) {
|
||||
for (const batch of this._batches)
|
||||
for (let i = batch.cursor; i < batch.queries.length; i++)
|
||||
this._queryPool.push(batch.queries[i]);
|
||||
}
|
||||
} catch {
|
||||
// A context that is going away has already released them.
|
||||
}
|
||||
this._batches.length = 0;
|
||||
this._queued.length = 0;
|
||||
this._source = undefined;
|
||||
this._snapshot = null;
|
||||
this._snapshotAt = Number.NEGATIVE_INFINITY;
|
||||
this._controlFailures = 0;
|
||||
this._retryAt = Number.NEGATIVE_INFINITY;
|
||||
this.mode = 'off';
|
||||
}
|
||||
|
||||
/** Delete the GPU objects and drop every test in flight. */
|
||||
destroy() {
|
||||
this.reset();
|
||||
const gl = this._gl;
|
||||
try {
|
||||
if (gl && !gl.isContextLost?.()) {
|
||||
for (const query of this._queryPool) gl.deleteQuery(query);
|
||||
const resources = this._resources;
|
||||
if (resources) {
|
||||
gl.deleteProgram(resources.program);
|
||||
gl.deleteVertexArray(resources.vertexArray);
|
||||
gl.deleteBuffer(resources.buffer);
|
||||
gl.deleteFramebuffer(resources.framebuffer);
|
||||
gl.deleteTexture(resources.texture);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A context that is going away has already released them.
|
||||
}
|
||||
this._queryPool.length = 0;
|
||||
this._resources = null;
|
||||
this._gl = null;
|
||||
this._unsupportedGl = null;
|
||||
}
|
||||
|
||||
/** Tests that will never be answered stop counting as in flight. */
|
||||
_release(contacts) {
|
||||
for (const contact of contacts) if (contact) contact.pending = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Something in the query path broke: stand down for a while and keep
|
||||
* everything drawn, then try again in case the cause was passing.
|
||||
*/
|
||||
_fail() {
|
||||
this._retryAt = this._now + PROBE_RETRY_AFTER_MS;
|
||||
this._controlFailures = 0;
|
||||
this.mode = 'off';
|
||||
}
|
||||
|
||||
/** Read this frame's depth source, once, when the first contact asks. */
|
||||
_openSource() {
|
||||
this._source = null;
|
||||
if (this._now < this._retryAt) return;
|
||||
const source = this._readSource(this._scene, this._view);
|
||||
if (!source) {
|
||||
this.mode = 'off';
|
||||
return;
|
||||
}
|
||||
if (source.gl !== this._gl) {
|
||||
// A different context (a rebuilt viewer) orphans whatever was in flight.
|
||||
this.destroy();
|
||||
this._gl = source.gl;
|
||||
}
|
||||
try {
|
||||
if (source.gl.isContextLost?.()) {
|
||||
// Everything made on the lost context went with it.
|
||||
this.reset();
|
||||
this._queryPool.length = 0;
|
||||
this._resources = null;
|
||||
return;
|
||||
}
|
||||
if (source.gl === this._unsupportedGl) {
|
||||
// The shader did not build on this context: nothing is tested here.
|
||||
this.mode = 'off';
|
||||
return;
|
||||
}
|
||||
if (source.webgl2 && canQuery(source.gl)) {
|
||||
if (!this._resources && !this._createResources(source.gl)) {
|
||||
this._fail();
|
||||
return;
|
||||
}
|
||||
this.mode = 'async';
|
||||
} else {
|
||||
this.mode = 'sync';
|
||||
if (this._now - this._snapshotAt >= SYNC_READ_INTERVAL_MS) {
|
||||
this._snapshot = this._readSync(source);
|
||||
this._snapshotAt = this._now;
|
||||
}
|
||||
}
|
||||
this._source = source;
|
||||
} catch {
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the shader and make the 1x1 target. This is the one place the
|
||||
* probe does wait on the GPU process (compile, link and framebuffer status
|
||||
* are answered synchronously): once per WebGL context, on the first frame a
|
||||
* depth-tested contact is in view. `reset` keeps the result.
|
||||
*/
|
||||
_createResources(gl) {
|
||||
const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER);
|
||||
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
||||
if (!vertex || !fragment) {
|
||||
if (vertex) gl.deleteShader(vertex);
|
||||
if (fragment) gl.deleteShader(fragment);
|
||||
// The same source will not compile on this context next time either.
|
||||
this._unsupportedGl = gl;
|
||||
return false;
|
||||
}
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vertex);
|
||||
gl.attachShader(program, fragment);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vertex);
|
||||
gl.deleteShader(fragment);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program);
|
||||
this._unsupportedGl = gl;
|
||||
return false;
|
||||
}
|
||||
const uniform = (name) => gl.getUniformLocation(program, name);
|
||||
const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
|
||||
const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
|
||||
const previousTexture = gl.getParameter(gl.TEXTURE_BINDING_2D);
|
||||
const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING);
|
||||
|
||||
const vertexArray = gl.createVertexArray();
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindVertexArray(vertexArray);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(previousArray);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer);
|
||||
|
||||
const texture = gl.createTexture();
|
||||
const framebuffer = gl.createFramebuffer();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, previousTexture);
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, framebuffer);
|
||||
gl.framebufferTexture2D(
|
||||
gl.DRAW_FRAMEBUFFER,
|
||||
gl.COLOR_ATTACHMENT0,
|
||||
gl.TEXTURE_2D,
|
||||
texture,
|
||||
0,
|
||||
);
|
||||
const complete =
|
||||
gl.checkFramebufferStatus(gl.DRAW_FRAMEBUFFER) ===
|
||||
gl.FRAMEBUFFER_COMPLETE;
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw);
|
||||
if (!complete) {
|
||||
// Leave nothing half-made behind, so a later retry starts from scratch.
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteVertexArray(vertexArray);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteFramebuffer(framebuffer);
|
||||
gl.deleteTexture(texture);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._resources = {
|
||||
program,
|
||||
vertexArray,
|
||||
buffer,
|
||||
texture,
|
||||
framebuffer,
|
||||
depth: [0, 1, 2, 3].map((i) => uniform(`u_depth${i}`)),
|
||||
count: uniform('u_count'),
|
||||
logDepth: uniform('u_logDepth'),
|
||||
near: uniform('u_near'),
|
||||
far: uniform('u_far'),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one query per queued contact, preceded by a control point that must
|
||||
* always draw. The renderer caches the framebuffer, viewport and scissor
|
||||
* state it last set, so those are put back exactly as found, along with
|
||||
* every binding touched here. Depth, stencil, blending and the colour mask
|
||||
* need no care: the target has no depth or stencil attachment, and a query
|
||||
* counts samples whether or not they are written.
|
||||
*/
|
||||
_draw(source) {
|
||||
const gl = source.gl;
|
||||
const resources = this._resources;
|
||||
const contacts = this._queued.slice();
|
||||
const count = contacts.length + 1;
|
||||
const attributes = this._attributes;
|
||||
// The control point: mid-frame, with a threshold nothing can be under.
|
||||
attributes[0] = 0.5;
|
||||
attributes[1] = 0.5;
|
||||
attributes[2] = -1;
|
||||
const layers = source.textures.length;
|
||||
const frustums = source.frustums;
|
||||
|
||||
const previousProgram = gl.getParameter(gl.CURRENT_PROGRAM);
|
||||
const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
|
||||
const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
|
||||
const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING);
|
||||
const previousUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
|
||||
const previousViewport = gl.getParameter(gl.VIEWPORT);
|
||||
const previousTextures = [];
|
||||
const scissor = gl.isEnabled(gl.SCISSOR_TEST);
|
||||
const queries = [];
|
||||
const pool = this._queryPool;
|
||||
let open = false;
|
||||
try {
|
||||
for (let i = 0; i < layers; i++) {
|
||||
gl.activeTexture(gl.TEXTURE0 + i);
|
||||
previousTextures.push(gl.getParameter(gl.TEXTURE_BINDING_2D));
|
||||
gl.bindTexture(gl.TEXTURE_2D, source.textures[i]);
|
||||
}
|
||||
if (scissor) gl.disable(gl.SCISSOR_TEST);
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resources.framebuffer);
|
||||
gl.viewport(0, 0, 1, 1);
|
||||
gl.useProgram(resources.program);
|
||||
// Samplers past the ranges in use point at the first range's texture:
|
||||
// every sampler a program declares must be drawable, read or not.
|
||||
for (let i = 0; i < PROBE_MAX_FRUSTUMS; i++)
|
||||
gl.uniform1i(resources.depth[i], i < layers ? i : 0);
|
||||
gl.uniform1i(resources.count, layers);
|
||||
gl.uniform1i(resources.logDepth, source.logDepth ? 1 : 0);
|
||||
gl.uniform4f(
|
||||
resources.near,
|
||||
frustums[0].near,
|
||||
frustums[1]?.near ?? 1,
|
||||
frustums[2]?.near ?? 1,
|
||||
frustums[3]?.near ?? 1,
|
||||
);
|
||||
gl.uniform4f(
|
||||
resources.far,
|
||||
frustums[0].far,
|
||||
frustums[1]?.far ?? 2,
|
||||
frustums[2]?.far ?? 2,
|
||||
frustums[3]?.far ?? 2,
|
||||
);
|
||||
gl.bindVertexArray(resources.vertexArray);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, resources.buffer);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
attributes.subarray(0, count * 3),
|
||||
gl.STREAM_DRAW,
|
||||
);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const query = pool.pop() || gl.createQuery();
|
||||
queries.push(query);
|
||||
gl.beginQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE, query);
|
||||
open = true;
|
||||
gl.drawArrays(gl.POINTS, i, 1);
|
||||
gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE);
|
||||
open = false;
|
||||
}
|
||||
} catch (error) {
|
||||
// Leave no query open and none unaccounted for; the caller stands down.
|
||||
try {
|
||||
if (open) gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE);
|
||||
for (const query of queries) gl.deleteQuery(query);
|
||||
} catch {
|
||||
// Nothing more can be done for them.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer);
|
||||
gl.bindVertexArray(previousArray);
|
||||
gl.useProgram(previousProgram);
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw);
|
||||
if (previousViewport)
|
||||
gl.viewport(
|
||||
previousViewport[0],
|
||||
previousViewport[1],
|
||||
previousViewport[2],
|
||||
previousViewport[3],
|
||||
);
|
||||
if (scissor) gl.enable(gl.SCISSOR_TEST);
|
||||
for (let i = previousTextures.length - 1; i >= 0; i--) {
|
||||
gl.activeTexture(gl.TEXTURE0 + i);
|
||||
gl.bindTexture(gl.TEXTURE_2D, previousTextures[i]);
|
||||
}
|
||||
gl.activeTexture(previousUnit);
|
||||
}
|
||||
|
||||
this._batches.push({
|
||||
queries,
|
||||
contacts,
|
||||
at: this._now,
|
||||
frame: this._frame,
|
||||
cursor: 0,
|
||||
control: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Take in whatever answers have arrived, oldest batch first, without
|
||||
* waiting for any that have not. An answer is dated to the frame its test
|
||||
* was drawn on, so one that arrives late (the overlay was not painting for
|
||||
* a while) is as stale as it is old.
|
||||
*/
|
||||
_collect() {
|
||||
const batches = this._batches;
|
||||
if (batches.length === 0) return;
|
||||
const gl = this._gl;
|
||||
try {
|
||||
if (gl.isContextLost?.()) {
|
||||
// Queries made on a lost context are gone with it.
|
||||
this.reset();
|
||||
this._queryPool.length = 0;
|
||||
this._resources = null;
|
||||
return;
|
||||
}
|
||||
while (batches.length > 0) {
|
||||
const batch = batches[0];
|
||||
const { queries, contacts } = batch;
|
||||
while (batch.cursor < queries.length) {
|
||||
const query = queries[batch.cursor];
|
||||
if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) break;
|
||||
const drew = !!gl.getQueryParameter(query, gl.QUERY_RESULT);
|
||||
if (batch.cursor === 0) {
|
||||
batch.control = drew;
|
||||
} else {
|
||||
const contact = contacts[batch.cursor - 1];
|
||||
contact.pending = false;
|
||||
// Without the control point's yes the batch means nothing: not a
|
||||
// "hidden", and not a first answer that ends a new contact's
|
||||
// hold-back either.
|
||||
if (batch.control) {
|
||||
contact.answered = true;
|
||||
if (!drew && batch.at > contact.lastHiddenAt)
|
||||
contact.lastHiddenAt = batch.at;
|
||||
}
|
||||
}
|
||||
this._queryPool.push(query);
|
||||
batch.cursor++;
|
||||
}
|
||||
if (batch.cursor < queries.length) break;
|
||||
batches.shift();
|
||||
if (batch.control) {
|
||||
this._controlFailures = 0;
|
||||
} else if (++this._controlFailures >= PROBE_MAX_CONTROL_FAILURES) {
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
// Whatever is still unanswered after too many frames is dropped,
|
||||
// wherever it stands in the queue, so its contacts can be tested again.
|
||||
const overdue = this._frame - PROBE_MAX_PENDING_FRAMES;
|
||||
for (let i = batches.length - 1; i >= 0; i--) {
|
||||
const batch = batches[i];
|
||||
if (batch.frame >= overdue) continue;
|
||||
for (let k = batch.cursor; k < batch.queries.length; k++)
|
||||
gl.deleteQuery(batch.queries[k]);
|
||||
for (
|
||||
let k = Math.max(0, batch.cursor - 1);
|
||||
k < batch.contacts.length;
|
||||
k++
|
||||
)
|
||||
batch.contacts[k].pending = false;
|
||||
batches.splice(i, 1);
|
||||
}
|
||||
} catch {
|
||||
for (const batch of batches) this._release(batch.contacts);
|
||||
batches.length = 0;
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No queries (WebGL 1): read each depth range at full size and wait for it.
|
||||
* Costs milliseconds per read; used only where nothing better exists.
|
||||
*/
|
||||
_readSync(source) {
|
||||
const gl = source.gl;
|
||||
const { width, height } = source;
|
||||
const layers = source.framebuffers.length;
|
||||
const layerBytes = width * height * 4;
|
||||
const byteLength = layerBytes * layers;
|
||||
const bytes =
|
||||
this._snapshot?.bytes.length === byteLength
|
||||
? this._snapshot.bytes
|
||||
: new Uint8Array(byteLength);
|
||||
const previous = gl.getParameter(gl.FRAMEBUFFER_BINDING);
|
||||
for (let i = 0; i < layers; i++) {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, source.framebuffers[i]);
|
||||
gl.readPixels(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
bytes.subarray(i * layerBytes, (i + 1) * layerBytes),
|
||||
);
|
||||
}
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, previous);
|
||||
return new DepthSnapshot({
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
frustums: source.frustums,
|
||||
logDepth: source.logDepth,
|
||||
// The source's matrix is a scratch array the next frame overwrites.
|
||||
view: Float64Array.from(source.view),
|
||||
tanHalfFovX: source.tanHalfFovX,
|
||||
tanHalfFovY: source.tanHalfFovY,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
PROBE_MAX_CONTROL_FAILURES,
|
||||
PROBE_MAX_FRUSTUMS,
|
||||
PROBE_MAX_PENDING_FRAMES,
|
||||
PROBE_MAX_PER_FRAME,
|
||||
PROBE_RETRY_AFTER_MS,
|
||||
PROBE_VERTEX_SHADER,
|
||||
SYNC_READ_INTERVAL_MS,
|
||||
SceneOcclusionProbe,
|
||||
readSceneDepthSource,
|
||||
} from './sceneOcclusionProbe.js';
|
||||
import {
|
||||
createFakeDepthScene,
|
||||
createFakeGl,
|
||||
depthImage,
|
||||
viewDistanceToDepth,
|
||||
} from './fixtures/fakeDepthScene.mjs';
|
||||
|
||||
const NEAR = 1;
|
||||
const FAR = 100_000;
|
||||
|
||||
/** Left half a wall 400 m away, right half ground 500 m away. */
|
||||
function wallImage(width, height, logDepth = true, wall = 400) {
|
||||
return depthImage(width, height, (column) =>
|
||||
viewDistanceToDepth(column < width / 2 ? wall : 500, NEAR, FAR, logDepth),
|
||||
);
|
||||
}
|
||||
|
||||
function wallScene({ width = 64, height = 32, logDepth = true, gl } = {}) {
|
||||
return createFakeDepthScene({
|
||||
gl,
|
||||
logDepth,
|
||||
aspectRatio: width / height,
|
||||
ranges: [
|
||||
{ near: NEAR, far: FAR, image: wallImage(width, height, logDepth) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const behindWall = { x: -250, y: 0, z: -500 };
|
||||
const onStreet = { x: 250, y: 0, z: -500 };
|
||||
const contact = () => ({
|
||||
lastHiddenAt: Number.NEGATIVE_INFINITY,
|
||||
pending: false,
|
||||
});
|
||||
|
||||
/** One frame: collect, queue the given contacts, draw. */
|
||||
function frame(probe, scene, now, tests = []) {
|
||||
probe.beginFrame(scene, now);
|
||||
const accepted = tests.map(([entry, position]) => probe.add(entry, position));
|
||||
probe.endFrame();
|
||||
return accepted;
|
||||
}
|
||||
|
||||
test('a test is drawn at once and answered on a later frame, without anything being read back', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
const shown = contact();
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, 1000, [
|
||||
[hidden, behindWall],
|
||||
[shown, onStreet],
|
||||
]),
|
||||
[true, true],
|
||||
);
|
||||
assert.equal(probe.mode, 'async');
|
||||
assert.equal(probe.lastFrameTests, 2);
|
||||
assert.equal(hidden.pending, true);
|
||||
assert.equal(
|
||||
gl.counts.queries,
|
||||
3,
|
||||
'the control point and one query per contact',
|
||||
);
|
||||
assert.equal(gl.counts.draws, 3);
|
||||
|
||||
// Frames pass before the GPU has answered: nothing changes, nothing waits.
|
||||
frame(probe, scene, 1016);
|
||||
frame(probe, scene, 1032);
|
||||
assert.equal(hidden.pending, true);
|
||||
assert.equal(hidden.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
assert.equal(gl.counts.resultReads, 0);
|
||||
assert.equal(gl.counts.readPixels, 0);
|
||||
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 1048);
|
||||
assert.equal(hidden.pending, false);
|
||||
assert.equal(shown.pending, false);
|
||||
assert.equal(hidden.answered, true);
|
||||
assert.equal(shown.answered, true);
|
||||
assert.equal(hidden.lastHiddenAt, 1000, 'dated to its test frame');
|
||||
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
assert.equal(gl.counts.readPixels, 0, 'depth never leaves the GPU');
|
||||
});
|
||||
|
||||
test('queries are reused, and every depth range is tested nearest first', () => {
|
||||
const width = 64;
|
||||
const height = 32;
|
||||
const farNear = 900;
|
||||
const farFar = 5_000_000;
|
||||
const scene = createFakeDepthScene({
|
||||
aspectRatio: 2,
|
||||
ranges: [
|
||||
// Near range: the wall on the left, nothing drawn on the right.
|
||||
{
|
||||
near: NEAR,
|
||||
far: 1000,
|
||||
image: depthImage(width, height, (column) =>
|
||||
column < width / 2 ? viewDistanceToDepth(400, NEAR, 1000, true) : 1,
|
||||
),
|
||||
},
|
||||
// Far range: ground 20 km away, drawn with its near plane pulled in.
|
||||
{
|
||||
near: farNear,
|
||||
far: farFar,
|
||||
image: depthImage(width, height, () =>
|
||||
viewDistanceToDepth(20_000, farNear * 0.9999, farFar, true),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const cases = [
|
||||
[contact(), { x: -7500, y: 0, z: -15_000 }, true], // behind the near wall
|
||||
[contact(), { x: 7500, y: 0, z: -15_000 }, false], // above the far ground
|
||||
[contact(), { x: 15_000, y: 0, z: -30_000 }, true], // under the far ground
|
||||
[contact(), { x: -150, y: 0, z: -300 }, false], // in front of the wall
|
||||
];
|
||||
for (let round = 0; round < 3; round++) {
|
||||
frame(
|
||||
probe,
|
||||
scene,
|
||||
round * 100,
|
||||
cases.map(([entry, position]) => [entry, position]),
|
||||
);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, round * 100 + 16);
|
||||
for (const [entry, , hidden] of cases) {
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt === round * 100, hidden);
|
||||
}
|
||||
}
|
||||
assert.equal(gl.liveQueries.size, 5, 'five queries serve every round');
|
||||
const source = readSceneDepthSource(scene);
|
||||
assert.equal(source.frustums[1].near, farNear * 0.9999);
|
||||
});
|
||||
|
||||
test('standard depth scenes are tested too', () => {
|
||||
const scene = wallScene({ logDepth: false });
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
const shown = contact();
|
||||
frame(probe, scene, 0, [
|
||||
[hidden, behindWall],
|
||||
[shown, onStreet],
|
||||
]);
|
||||
scene.context._gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(hidden.lastHiddenAt, 0);
|
||||
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
|
||||
test('every binding the renderer relies on is left as found', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const found = {
|
||||
read: { image: null },
|
||||
draw: { image: null },
|
||||
units: [{}, {}, {}, {}, {}, {}, {}, {}],
|
||||
unit: 5,
|
||||
array: {},
|
||||
vertexArray: {},
|
||||
program: { uniforms: {} },
|
||||
scissor: true,
|
||||
viewport: [12, 34, 640, 480],
|
||||
};
|
||||
Object.assign(gl.state, found, { units: [...found.units] });
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
frame(probe, scene, 0, [[hidden, behindWall]]);
|
||||
for (const key of [
|
||||
'read',
|
||||
'draw',
|
||||
'unit',
|
||||
'array',
|
||||
'vertexArray',
|
||||
'program',
|
||||
'scissor',
|
||||
])
|
||||
assert.equal(gl.state[key], found[key], key);
|
||||
assert.deepEqual(gl.state.units, found.units);
|
||||
assert.deepEqual(gl.state.viewport, [12, 34, 640, 480]);
|
||||
// The scissor test and the offset viewport would both have kept the points
|
||||
// off the target, so neither was in force for them: the control point drew,
|
||||
// and the answer counts.
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(hidden.lastHiddenAt, 0);
|
||||
assert.equal(gl.state.scissor, true);
|
||||
});
|
||||
|
||||
test('a contact outside the frame, or past the frame budget, is not taken', () => {
|
||||
const scene = wallScene();
|
||||
const probe = new SceneOcclusionProbe();
|
||||
probe.beginFrame(scene, 0);
|
||||
const outside = contact();
|
||||
assert.equal(probe.add(outside, { x: -1100, y: 0, z: -500 }), false);
|
||||
assert.equal(probe.add(outside, { x: 0, y: 0, z: 500 }), false);
|
||||
assert.equal(outside.pending, false);
|
||||
const taken = [];
|
||||
for (let i = 0; i < PROBE_MAX_PER_FRAME + 10; i++)
|
||||
taken.push(probe.add(contact(), onStreet));
|
||||
probe.endFrame();
|
||||
assert.equal(taken.filter(Boolean).length, PROBE_MAX_PER_FRAME);
|
||||
assert.equal(taken.lastIndexOf(true), PROBE_MAX_PER_FRAME - 1);
|
||||
assert.equal(scene.context._gl.counts.queries, PROBE_MAX_PER_FRAME + 1);
|
||||
});
|
||||
|
||||
test('a frame with nothing to test touches nothing', () => {
|
||||
let reads = 0;
|
||||
const probe = new SceneOcclusionProbe({
|
||||
readSource: () => {
|
||||
reads++;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
frame(probe, {}, 0);
|
||||
frame(probe, {}, 16);
|
||||
assert.equal(reads, 0);
|
||||
assert.equal(probe.lastFrameTests, 0);
|
||||
});
|
||||
|
||||
test('no depth source: nothing is tested and nothing is in flight', () => {
|
||||
const broken = [
|
||||
null,
|
||||
{},
|
||||
{ ...wallScene(), mode: Cesium.SceneMode.SCENE2D },
|
||||
{ ...wallScene(), mode: Cesium.SceneMode.COLUMBUS_VIEW },
|
||||
{ ...wallScene(), useDepthPicking: false },
|
||||
{ ...wallScene(), _environmentState: { useGlobeDepthFramebuffer: false } },
|
||||
{ ...wallScene(), view: { frustumCommandsList: [], pickDepths: [] } },
|
||||
{ ...wallScene(), view: undefined },
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.context.depthTexture = false;
|
||||
return scene;
|
||||
})(),
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.camera.frustum = { width: 1000, aspectRatio: 2 }; // orthographic
|
||||
return scene;
|
||||
})(),
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.camera.frustum.xOffset = 10;
|
||||
return scene;
|
||||
})(),
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet
|
||||
return scene;
|
||||
})(),
|
||||
// Whether depth is logarithmic, unknown: decoding it the wrong way would
|
||||
// put every surface in front of every contact.
|
||||
{ ...wallScene(), frameState: {} },
|
||||
{ ...wallScene(), frameState: { useLogDepth: 1 } },
|
||||
{ ...wallScene(), frameState: undefined },
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
Object.defineProperty(scene, 'view', {
|
||||
get() {
|
||||
throw new Error('renderer changed shape');
|
||||
},
|
||||
});
|
||||
return scene;
|
||||
})(),
|
||||
];
|
||||
for (const scene of broken) {
|
||||
assert.equal(readSceneDepthSource(scene), null);
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(entry.pending, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('depth that comes and goes is picked up frame by frame', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
scene.useDepthPicking = false;
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
scene.useDepthPicking = true;
|
||||
assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [true]);
|
||||
assert.equal(probe.mode, 'async');
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 32);
|
||||
assert.equal(entry.lastHiddenAt, 16);
|
||||
});
|
||||
|
||||
test('depth ranges past the limit are left out, nearest kept', () => {
|
||||
const image = wallImage(8, 8);
|
||||
const ranges = [];
|
||||
for (let i = 0; i < PROBE_MAX_FRUSTUMS + 2; i++)
|
||||
ranges.push({ near: 10 ** i, far: 10 ** (i + 1), image });
|
||||
const source = readSceneDepthSource(createFakeDepthScene({ ranges }));
|
||||
assert.equal(source.frustums.length, PROBE_MAX_FRUSTUMS);
|
||||
assert.equal(source.textures.length, PROBE_MAX_FRUSTUMS);
|
||||
assert.equal(source.frustums[0].near, 1);
|
||||
});
|
||||
|
||||
test('a control point that does not draw voids its batch; a run of them stands the probe down for a while', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
// Something outside the probe's reach stops every point from drawing, so
|
||||
// every query reads "nothing drew", which would otherwise mean "hidden".
|
||||
gl.state.drawsLand = false;
|
||||
let now = 0;
|
||||
for (let i = 0; i < PROBE_MAX_CONTROL_FAILURES; i++) {
|
||||
const entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [true]);
|
||||
gl.finishGpu();
|
||||
now += 16;
|
||||
frame(probe, scene, now);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed');
|
||||
assert.notEqual(entry.answered, true, 'and not a first answer either');
|
||||
now += 16;
|
||||
}
|
||||
assert.equal(probe.mode, 'off');
|
||||
const stoodDownAt = now - 16;
|
||||
assert.deepEqual(frame(probe, scene, now, [[contact(), behindWall]]), [
|
||||
false,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, stoodDownAt + PROBE_RETRY_AFTER_MS - 1, [
|
||||
[contact(), behindWall],
|
||||
]),
|
||||
[false],
|
||||
);
|
||||
// The cause has passed: the probe picks up where it left off.
|
||||
gl.state.drawsLand = true;
|
||||
const entry = contact();
|
||||
const retryAt = stoodDownAt + PROBE_RETRY_AFTER_MS;
|
||||
assert.deepEqual(frame(probe, scene, retryAt, [[entry, behindWall]]), [true]);
|
||||
assert.equal(probe.mode, 'async');
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, retryAt + 16);
|
||||
assert.equal(entry.lastHiddenAt, retryAt);
|
||||
assert.equal(entry.answered, true);
|
||||
|
||||
// One good batch in between resets the count.
|
||||
const healthy = new SceneOcclusionProbe();
|
||||
const other = wallScene();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
other.context._gl.state.drawsLand = i % 2 === 1;
|
||||
frame(healthy, other, i * 32, [[contact(), onStreet]]);
|
||||
other.context._gl.finishGpu();
|
||||
frame(healthy, other, i * 32 + 16);
|
||||
}
|
||||
assert.equal(healthy.mode, 'async');
|
||||
});
|
||||
|
||||
test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => {
|
||||
let scene = wallScene();
|
||||
// Only the fragment shader fails: the vertex shader that did compile must
|
||||
// not be left behind.
|
||||
scene.context._gl.state.compiles = (type) =>
|
||||
type !== scene.context._gl.FRAGMENT_SHADER;
|
||||
let probe = new SceneOcclusionProbe();
|
||||
let entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(entry.pending, false);
|
||||
// Nothing half-made is kept, and the same context is never asked again:
|
||||
// compiling is a wait on the GPU process and would fail the same way.
|
||||
let gl = scene.context._gl;
|
||||
assert.equal(gl.liveShaders.size, 0, 'no shader left behind');
|
||||
let shaderCalls = 0;
|
||||
const createShader = gl.createShader;
|
||||
gl.createShader = (...args) => {
|
||||
shaderCalls++;
|
||||
return createShader(...args);
|
||||
};
|
||||
for (const now of [PROBE_RETRY_AFTER_MS + 16, 5 * PROBE_RETRY_AFTER_MS])
|
||||
assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [false]);
|
||||
assert.equal(shaderCalls, 0);
|
||||
assert.equal(probe.mode, 'off');
|
||||
|
||||
// A draw that throws part-way: bindings restored, no query left behind.
|
||||
scene = wallScene();
|
||||
gl = scene.context._gl;
|
||||
const found = {
|
||||
draw: { image: null },
|
||||
program: { uniforms: {} },
|
||||
viewport: [0, 0, 800, 600],
|
||||
};
|
||||
Object.assign(gl.state, found, { scissor: true });
|
||||
let draws = 0;
|
||||
const drawArrays = gl.drawArrays;
|
||||
gl.drawArrays = (...args) => {
|
||||
if (++draws === 2) throw new Error('driver');
|
||||
return drawArrays(...args);
|
||||
};
|
||||
probe = new SceneOcclusionProbe();
|
||||
entry = contact();
|
||||
const other = contact();
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, 0, [
|
||||
[entry, behindWall],
|
||||
[other, onStreet],
|
||||
]),
|
||||
[true, true],
|
||||
);
|
||||
assert.equal(entry.pending, false, 'released when the draw failed');
|
||||
assert.equal(other.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(gl.state.draw, found.draw);
|
||||
assert.equal(gl.state.program, found.program);
|
||||
assert.deepEqual(gl.state.viewport, found.viewport);
|
||||
assert.equal(gl.state.scissor, true);
|
||||
assert.equal(gl.liveQueries.size, 0);
|
||||
assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [false]);
|
||||
|
||||
scene = wallScene();
|
||||
gl = scene.context._gl;
|
||||
probe = new SceneOcclusionProbe();
|
||||
entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
gl.getQueryParameter = () => {
|
||||
throw new Error('driver');
|
||||
};
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
});
|
||||
|
||||
test('a frame that never reaches its end leaves no contact waiting for an answer', () => {
|
||||
const scene = wallScene();
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
probe.beginFrame(scene, 0);
|
||||
assert.equal(probe.add(entry, behindWall), true);
|
||||
assert.equal(entry.pending, true);
|
||||
// The painter threw before endFrame; the next frame starts clean.
|
||||
probe.beginFrame(scene, 16);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.add(entry, behindWall), true);
|
||||
probe.endFrame();
|
||||
assert.equal(
|
||||
scene.context._gl.counts.queries,
|
||||
2,
|
||||
'only the second frame drew',
|
||||
);
|
||||
});
|
||||
|
||||
test('an answer collected late is dated to its test, not to its arrival', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 1000, [[entry, behindWall]]);
|
||||
gl.finishGpu();
|
||||
// The overlay paints nothing for 20 seconds, then resumes.
|
||||
frame(probe, scene, 21_000);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt, 1000);
|
||||
// An older answer never overwrites a newer one.
|
||||
entry.lastHiddenAt = 30_000;
|
||||
frame(probe, scene, 22_000, [[entry, behindWall]]);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 22_016);
|
||||
assert.equal(entry.lastHiddenAt, 30_000);
|
||||
});
|
||||
|
||||
test('samplers for depth ranges not in use point at a texture that is bound', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
// Whatever the renderer left on the other units is not the probe's to read.
|
||||
gl.state.units = [null, null, null, null, null, null, null, null];
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(
|
||||
entry.lastHiddenAt,
|
||||
0,
|
||||
'the draw was valid, so the answer counts',
|
||||
);
|
||||
assert.deepEqual(gl.state.units, [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('reset drops tests in flight but keeps the GPU objects for next time', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
probe.reset();
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(gl.counts.deleted, 0);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(
|
||||
entry.lastHiddenAt,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
'the dropped answer is not applied',
|
||||
);
|
||||
frame(probe, scene, 32, [[entry, behindWall]]);
|
||||
assert.equal(probe.mode, 'async');
|
||||
assert.equal(gl.liveQueries.size, 2, 'queries are reused, not recreated');
|
||||
});
|
||||
|
||||
test('a lost context drops what was in flight and starts over when it returns', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
gl.state.lost = true;
|
||||
assert.deepEqual(frame(probe, scene, 16, [[contact(), behindWall]]), [false]);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
gl.state.lost = false;
|
||||
assert.deepEqual(frame(probe, scene, 32, [[entry, behindWall]]), [true]);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 48);
|
||||
assert.equal(entry.lastHiddenAt, 32);
|
||||
});
|
||||
|
||||
test('queries that are never answered are abandoned', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
for (let i = 1; i <= PROBE_MAX_PENDING_FRAMES; i++)
|
||||
frame(probe, scene, i * 16);
|
||||
assert.equal(entry.pending, true);
|
||||
frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + 1) * 16);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
assert.equal(gl.liveQueries.size, 0);
|
||||
assert.equal(probe.mode, 'async', 'and the next contact is tested as usual');
|
||||
});
|
||||
|
||||
test('every batch times out on its own, however many are queued ahead of it', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entries = [];
|
||||
// Ten frames each start a batch; the GPU answers none of them.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const entry = contact();
|
||||
entries.push(entry);
|
||||
frame(probe, scene, i * 16, [[entry, behindWall]]);
|
||||
}
|
||||
for (let i = 10; i <= PROBE_MAX_PENDING_FRAMES; i++)
|
||||
frame(probe, scene, i * 16);
|
||||
assert.ok(entries.every((entry) => entry.pending));
|
||||
// Ten more frames and all ten have been dropped, not one every 120 frames.
|
||||
for (let i = 1; i <= 10; i++)
|
||||
frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + i) * 16);
|
||||
assert.ok(entries.every((entry) => !entry.pending));
|
||||
assert.equal(gl.liveQueries.size, 0);
|
||||
});
|
||||
|
||||
test('a target that cannot be made leaves nothing behind, and the next try starts from scratch', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const complete = gl.checkFramebufferStatus;
|
||||
gl.checkFramebufferStatus = () => 0;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(gl.counts.deleted, 5, 'program, array, buffer, target');
|
||||
// Memory pressure passes; after the stand-down the probe builds it all again.
|
||||
gl.checkFramebufferStatus = complete;
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, PROBE_RETRY_AFTER_MS, [[entry, behindWall]]),
|
||||
[true],
|
||||
);
|
||||
assert.equal(probe.mode, 'async');
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, PROBE_RETRY_AFTER_MS + 16);
|
||||
assert.equal(entry.lastHiddenAt, PROBE_RETRY_AFTER_MS);
|
||||
});
|
||||
|
||||
test('a new WebGL context, or destroy, releases everything', () => {
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const first = wallScene();
|
||||
const entry = contact();
|
||||
frame(probe, first, 0, [[entry, behindWall]]);
|
||||
const second = wallScene();
|
||||
const again = contact();
|
||||
assert.deepEqual(frame(probe, second, 16, [[again, behindWall]]), [true]);
|
||||
assert.equal(entry.pending, false, 'the first context will never answer');
|
||||
assert.equal(first.context._gl.liveQueries.size, 0);
|
||||
assert.equal(
|
||||
first.context._gl.counts.deleted,
|
||||
5,
|
||||
'program, array, buffer, target',
|
||||
);
|
||||
probe.destroy();
|
||||
assert.equal(again.pending, false);
|
||||
assert.equal(second.context._gl.liveQueries.size, 0);
|
||||
assert.equal(probe.mode, 'off');
|
||||
});
|
||||
|
||||
test('without queries (WebGL 1) depth is read back synchronously, at most every interval, and the mode says so', () => {
|
||||
const gl = createFakeGl({ webgl2: false });
|
||||
const scene = wallScene({ gl });
|
||||
assert.equal(scene.context.webgl2, false);
|
||||
const sceneFramebuffer = { image: null };
|
||||
gl.state.read = sceneFramebuffer;
|
||||
gl.state.draw = sceneFramebuffer;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
const shown = contact();
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, 1000, [
|
||||
[hidden, behindWall],
|
||||
[shown, onStreet],
|
||||
[contact(), { x: -1100, y: 0, z: -500 }],
|
||||
]),
|
||||
[true, true, false],
|
||||
);
|
||||
assert.equal(probe.mode, 'sync');
|
||||
assert.equal(gl.counts.readPixels, 1);
|
||||
assert.equal(gl.state.read, sceneFramebuffer);
|
||||
assert.equal(gl.state.draw, sceneFramebuffer);
|
||||
assert.equal(hidden.lastHiddenAt, 1000, 'answered on the spot');
|
||||
assert.equal(hidden.pending, false);
|
||||
assert.equal(shown.answered, true);
|
||||
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
// Frames inside the interval answer from the read already made.
|
||||
frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS - 1, [[hidden, behindWall]]);
|
||||
assert.equal(gl.counts.readPixels, 1);
|
||||
assert.equal(hidden.lastHiddenAt, 1000 + SYNC_READ_INTERVAL_MS - 1);
|
||||
frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS, [[hidden, behindWall]]);
|
||||
assert.equal(gl.counts.readPixels, 2);
|
||||
});
|
||||
|
||||
test('the shader decodes depth the way the CPU decode does', () => {
|
||||
// The fake context stands in for the shader, so pin the parts of the source
|
||||
// the two must agree on: the unpack weights, both depth formulas, the
|
||||
// nearest-range-first order and the comparison.
|
||||
const source = PROBE_VERTEX_SHADER.replace(/\s+/g, ' ');
|
||||
assert.match(
|
||||
source,
|
||||
/vec4\(1\.0, 1\.0 \/ 255\.0, 1\.0 \/ 65025\.0, 1\.0 \/ 16581375\.0\)/,
|
||||
);
|
||||
assert.match(source, /if \(depth <= 0\.0 \|\| depth >= 1\.0\) return -1\.0;/);
|
||||
assert.match(
|
||||
source,
|
||||
/near \+ exp2\(depth \* log2\(far - near \+ 1\.0\)\) - 1\.0/,
|
||||
);
|
||||
assert.match(source, /near \* far \/ \(far - depth \* \(far - near\)\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/bool hidden = surface > 0\.0 && surface < a_contact\.z;/,
|
||||
);
|
||||
assert.ok(
|
||||
source.indexOf('u_depth0, u_near.x') < source.indexOf('u_depth1, u_near.y'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shipwrecksProxy } from '../../server/providers/shipwrecks.js';
|
||||
|
||||
const EMODNET = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'heritageshipwrecks.1',
|
||||
geometry: { type: 'Point', coordinates: [-0.92, 50.74] },
|
||||
properties: { name: 'HMS/m A1', sink_yr: '1911', country: 'UK' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const WIKIDATA = {
|
||||
results: {
|
||||
bindings: [
|
||||
{
|
||||
item: { value: 'http://www.wikidata.org/entity/Q25173' },
|
||||
itemLabel: { value: 'Titanic' },
|
||||
coord: { value: 'Point(-49.9469 41.7325)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Upstream fake. Each source answers 'ok', 'down' (HTTP 503) or 'malformed'
|
||||
* (HTTP 200 with a body that is not that source's data); change `state` to
|
||||
* switch between requests.
|
||||
*/
|
||||
function fakeFetch({ wikidataFails = false, emodnetFails = false } = {}) {
|
||||
const calls = [];
|
||||
const state = {
|
||||
emodnet: emodnetFails ? 'down' : 'ok',
|
||||
wikidata: wikidataFails ? 'down' : 'ok',
|
||||
};
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
const href = String(url);
|
||||
const name = href.includes('query.wikidata.org')
|
||||
? 'wikidata'
|
||||
: href.includes('ows.emodnet-humanactivities.eu')
|
||||
? 'emodnet'
|
||||
: null;
|
||||
if (!name) return new Response('', { status: 404 });
|
||||
if (state[name] === 'down') return new Response('', { status: 503 });
|
||||
if (state[name] === 'malformed') return Response.json({});
|
||||
return Response.json(name === 'wikidata' ? WIKIDATA : EMODNET);
|
||||
};
|
||||
const callsTo = (name) =>
|
||||
calls.filter(({ url }) =>
|
||||
url.includes(
|
||||
name === 'wikidata'
|
||||
? 'query.wikidata.org'
|
||||
: 'ows.emodnet-humanactivities',
|
||||
),
|
||||
).length;
|
||||
return { fetchImpl, calls, state, callsTo };
|
||||
}
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function handlerOf(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (_path, fn) => (handler = fn) },
|
||||
});
|
||||
return handler;
|
||||
}
|
||||
|
||||
function request(handler, { method = 'GET', url = '/' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const res = {
|
||||
destroyed: false,
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
resolve({
|
||||
status: this.status,
|
||||
headers: this.headers,
|
||||
body: JSON.parse(body),
|
||||
});
|
||||
},
|
||||
};
|
||||
handler(
|
||||
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
|
||||
res,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('serves one cached snapshot from fixed upstreams', async () => {
|
||||
const { fetchImpl, calls } = fakeFetch();
|
||||
const handler = handlerOf(shipwrecksProxy({ fetchImpl }));
|
||||
const first = await request(handler);
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.records.length, 2);
|
||||
const a1 = first.body.records.find((record) => record.name === 'HMS/m A1');
|
||||
assert.equal(a1.year, 1911);
|
||||
assert.deepEqual(first.body.sources, { emodnet: true, wikidata: true });
|
||||
for (const { url } of calls)
|
||||
assert.match(
|
||||
url,
|
||||
/^https:\/\/(ows\.emodnet-humanactivities\.eu\/wfs\?|query\.wikidata\.org\/sparql\?)/,
|
||||
);
|
||||
const count = calls.length;
|
||||
await request(handler);
|
||||
assert.equal(calls.length, count, 'second request is served from cache');
|
||||
});
|
||||
|
||||
test('one failing source still serves the other; both failing is a 502', async () => {
|
||||
const partial = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: fakeFetch({ wikidataFails: true }).fetchImpl,
|
||||
}),
|
||||
);
|
||||
const ok = await request(partial);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body.sources, { emodnet: true, wikidata: false });
|
||||
|
||||
const down = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: fakeFetch({ wikidataFails: true, emodnetFails: true })
|
||||
.fetchImpl,
|
||||
}),
|
||||
);
|
||||
assert.equal((await request(down)).status, 502);
|
||||
});
|
||||
|
||||
test('a failed refresh serves the last snapshot as stale', async () => {
|
||||
let clock = 0;
|
||||
let fail = false;
|
||||
const { fetchImpl: good } = fakeFetch();
|
||||
const { fetchImpl: bad } = fakeFetch({
|
||||
wikidataFails: true,
|
||||
emodnetFails: true,
|
||||
});
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: (...args) => (fail ? bad : good)(...args),
|
||||
now: () => clock,
|
||||
}),
|
||||
);
|
||||
await request(handler);
|
||||
fail = true;
|
||||
clock = 25 * 60 * 60 * 1000;
|
||||
const stale = await request(handler);
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||
});
|
||||
|
||||
test('rejects other methods and routes', async () => {
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: fakeFetch().fetchImpl }),
|
||||
);
|
||||
assert.equal((await request(handler, { method: 'POST' })).status, 405);
|
||||
assert.equal((await request(handler, { url: '/x' })).status, 404);
|
||||
});
|
||||
|
||||
test("a refresh that gets nothing usable keeps every source's last good wrecks", async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
const first = await request(handler);
|
||||
assert.equal(first.body.records.length, 2);
|
||||
// A day later EMODnet is down and Wikidata answers 200 with no results.
|
||||
upstream.state.emodnet = 'down';
|
||||
upstream.state.wikidata = 'malformed';
|
||||
clock = 25 * HOUR;
|
||||
const later = await request(handler);
|
||||
assert.equal(later.status, 200);
|
||||
assert.equal(later.body.records.length, 2, 'no wreck is lost');
|
||||
assert.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
|
||||
assert.equal(later.body.stale, true);
|
||||
assert.equal(later.body.fetchedAt, 0, 'the age of the oldest data served');
|
||||
});
|
||||
|
||||
test('one source failing keeps its own last records while the other refreshes', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
const first = await request(handler);
|
||||
assert.equal((await request(handler)).body.version, first.body.version);
|
||||
upstream.state.wikidata = 'down';
|
||||
clock = 25 * HOUR;
|
||||
const later = await request(handler);
|
||||
assert.notEqual(later.body.version, first.body.version, 'new EMODnet data');
|
||||
assert.equal(later.body.records.length, 2);
|
||||
assert.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
|
||||
assert.equal(later.body.stale, true);
|
||||
assert.equal(upstream.callsTo('emodnet'), 2, 'EMODnet was refreshed');
|
||||
assert.deepEqual(later.body.ages, { emodnet: 0, wikidata: 25 * HOUR });
|
||||
assert.equal(later.body.maxAgeMs, 7 * 24 * HOUR);
|
||||
});
|
||||
|
||||
test('a failed source waits before it is asked again', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
await request(handler);
|
||||
upstream.state.emodnet = 'down';
|
||||
upstream.state.wikidata = 'down';
|
||||
clock = 25 * HOUR;
|
||||
await request(handler);
|
||||
const asked = upstream.calls.length;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
clock += 60 * 1000;
|
||||
const res = await request(handler);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.stale, true);
|
||||
}
|
||||
assert.equal(upstream.calls.length, asked, 'no upstream call while waiting');
|
||||
clock = 25 * HOUR + 16 * 60 * 1000;
|
||||
upstream.state.emodnet = 'ok';
|
||||
upstream.state.wikidata = 'ok';
|
||||
const back = await request(handler);
|
||||
assert.equal(
|
||||
upstream.calls.length,
|
||||
asked + 2,
|
||||
'both are retried after the wait',
|
||||
);
|
||||
assert.equal(back.body.stale, undefined);
|
||||
});
|
||||
|
||||
test('with no data yet, failures are retried only after the wait', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch({ emodnetFails: true, wikidataFails: true });
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
assert.equal((await request(handler)).status, 502);
|
||||
const asked = upstream.calls.length;
|
||||
clock += 5 * 60 * 1000;
|
||||
assert.equal((await request(handler)).status, 502);
|
||||
assert.equal(upstream.calls.length, asked);
|
||||
clock += 11 * 60 * 1000;
|
||||
assert.equal((await request(handler)).status, 502);
|
||||
assert.equal(upstream.calls.length, asked * 2);
|
||||
});
|
||||
|
||||
test('a source older than seven days is no longer served', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
await request(handler);
|
||||
upstream.state.emodnet = 'down';
|
||||
upstream.state.wikidata = 'down';
|
||||
clock = 7 * 24 * HOUR;
|
||||
assert.equal((await request(handler)).status, 200, 'seven days is served');
|
||||
clock = 7 * 24 * HOUR + 16 * 60 * 1000;
|
||||
assert.equal((await request(handler)).status, 502, 'older is dropped');
|
||||
});
|
||||
|
||||
test('an upstream body over its cap is a failed source, read only to the cap', async () => {
|
||||
const MIB = 1024 * 1024;
|
||||
const chunk = new Uint8Array(MIB).fill(0x20);
|
||||
let sent = 0;
|
||||
let cancelled = false;
|
||||
const endless = () =>
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
sent += chunk.byteLength;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: async (url) =>
|
||||
String(url).includes('query.wikidata.org')
|
||||
? Response.json(WIKIDATA)
|
||||
: new Response(endless()),
|
||||
}),
|
||||
);
|
||||
const res = await request(handler);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body.sources, { emodnet: false, wikidata: true });
|
||||
assert.ok(cancelled, 'the oversized body is cancelled');
|
||||
assert.ok(sent <= 34 * MIB, `read ${sent / MIB} MiB of a 32 MiB cap`);
|
||||
});
|
||||
|
||||
test('a refresh far smaller than the last good set is refused until it expires', async () => {
|
||||
let clock = 0;
|
||||
const many = {
|
||||
results: {
|
||||
bindings: Array.from({ length: 10 }, (_, i) => ({
|
||||
item: { value: `http://www.wikidata.org/entity/Q${100 + i}` },
|
||||
itemLabel: { value: `Wreck ${i}` },
|
||||
coord: { value: `Point(${i} 40)` },
|
||||
})),
|
||||
},
|
||||
};
|
||||
let wikidata = many;
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: async (url) =>
|
||||
Response.json(
|
||||
String(url).includes('query.wikidata.org') ? wikidata : EMODNET,
|
||||
),
|
||||
now: () => clock,
|
||||
}),
|
||||
);
|
||||
assert.equal((await request(handler)).body.records.length, 11);
|
||||
wikidata = WIKIDATA;
|
||||
clock = 25 * HOUR;
|
||||
const refused = await request(handler);
|
||||
assert.equal(refused.body.records.length, 11, 'one wreck is not ten');
|
||||
assert.equal(refused.body.stale, true);
|
||||
clock = 8 * 24 * HOUR;
|
||||
const accepted = await request(handler);
|
||||
assert.equal(
|
||||
accepted.body.records.length,
|
||||
2,
|
||||
'taken once the old set expired',
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Bottom-to-top order for near-plane-clamped contact sprite collections. */
|
||||
export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'shipwrecks',
|
||||
'cctv',
|
||||
'firms',
|
||||
'wildlife-sightings',
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* @module trailContinuity
|
||||
* @description Where a tracked aircraft's trail lifts the pen.
|
||||
*
|
||||
* A trail is a chronological list of fixes `{position, timeMs, nullAltitude,
|
||||
* breakBefore, drawn}`: reports, and (`drawn`) points where the tracked icon
|
||||
* was drawn between them. Between two consecutive fixes that are not one path, a new
|
||||
* segment starts and nothing is drawn between them: no dashes, no arcs.
|
||||
* Nothing is dropped except the oldest fixes past the layer's point limit.
|
||||
*
|
||||
* Two points are not one path when time does not advance, when the gap is
|
||||
* longer than 48 minutes (a coverage hole or a separate flight), when the
|
||||
* implied great-circle speed is over 1,500 kt, or when either point has no
|
||||
* reported altitude. A parked aircraft is the exception: a pair under three
|
||||
* hours apart within 0.1 nm stays one path.
|
||||
*
|
||||
* Civil and military flight tracking share these rules.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** @constant {number} A gap longer than this is a coverage hole or a separate flight. */
|
||||
export const TRAIL_CONTINUITY_MAX_GAP_MS = 48 * 60_000;
|
||||
|
||||
/** @constant {number} A pair implying more than this great-circle speed is not one path. */
|
||||
export const TRAIL_CONTINUITY_MAX_SPEED_KT = 1_500;
|
||||
|
||||
/** @constant {number} A parked aircraft may stop reporting this long and stay one path. */
|
||||
export const TRAIL_STATIONARY_HOLD_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||
|
||||
/** @constant {number} How close (nautical miles) two points must be to count as parked. */
|
||||
export const TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM = 0.1;
|
||||
|
||||
/** @constant {number} The head never joins a body point farther than this from the aircraft. */
|
||||
export const TRAIL_HEAD_JOIN_MAX_M = 150_000;
|
||||
|
||||
const METRES_PER_NM = 1852;
|
||||
|
||||
/** @constant {number} Mean Earth radius (m) for great-circle trail distances. */
|
||||
const EARTH_MEAN_RADIUS_M = 6_371_008.8;
|
||||
|
||||
/**
|
||||
* Whether two consecutive trail points belong to different paths. Time that
|
||||
* does not advance, a gap over TRAIL_CONTINUITY_MAX_GAP_MS or an implied
|
||||
* speed over TRAIL_CONTINUITY_MAX_SPEED_KT breaks the line, except for a
|
||||
* parked aircraft: a pair under three hours apart within 0.1 nm.
|
||||
* @param {number} deltaMs - Later point's time minus the earlier one's.
|
||||
* @param {number} distanceM - Great-circle distance between the points.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailPairBreaks(deltaMs, distanceM) {
|
||||
if (!(deltaMs > 0) || !Number.isFinite(distanceM)) return true;
|
||||
const distanceNm = distanceM / METRES_PER_NM;
|
||||
if (
|
||||
deltaMs <= TRAIL_STATIONARY_HOLD_MAX_GAP_MS &&
|
||||
distanceNm <= TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
deltaMs > TRAIL_CONTINUITY_MAX_GAP_MS ||
|
||||
distanceNm / (deltaMs / 3_600_000) > TRAIL_CONTINUITY_MAX_SPEED_KT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the head segment may join the last body point to the aircraft:
|
||||
* within TRAIL_HEAD_JOIN_MAX_M and not broken under `trailPairBreaks`.
|
||||
* @param {number} deltaMs - Current time minus the body point's time.
|
||||
* @param {number} distanceM - Distance from the body point to the aircraft.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailHeadJoins(deltaMs, distanceM) {
|
||||
return (
|
||||
distanceM <= TRAIL_HEAD_JOIN_MAX_M && !trailPairBreaks(deltaMs, distanceM)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance between two trail positions, in metres.
|
||||
* @param {Cesium.Cartesian3} a
|
||||
* @param {Cesium.Cartesian3} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trailDistanceM(a, b) {
|
||||
return Cesium.Cartesian3.angleBetween(a, b) * EARTH_MEAN_RADIUS_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pen lifts between two consecutive fixes.
|
||||
* @param {object|undefined} prev - Earlier fix, if any.
|
||||
* @param {object} next - Later fix.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailFixBreaks(prev, next) {
|
||||
if (!prev || prev.nullAltitude || next.nullAltitude) return true;
|
||||
// A recorded point is where the icon was drawn; the speed between it and a
|
||||
// report it was eased onto is not a flight speed, so only the gap counts.
|
||||
if (prev.drawn || next.drawn)
|
||||
return Math.abs(next.timeMs - prev.timeMs) > TRAIL_CONTINUITY_MAX_GAP_MS;
|
||||
return trailPairBreaks(
|
||||
next.timeMs - prev.timeMs,
|
||||
trailDistanceM(prev.position, next.position),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build backfill fixes from chronological waypoints. Every waypoint with a
|
||||
* position is kept. A waypoint without one (no altitude and no known ground)
|
||||
* is skipped, and the line still breaks there.
|
||||
* @param {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean}[]} waypoints
|
||||
* @returns {{fixes: object[], breakAfter: boolean}} The fixes, and whether the
|
||||
* line breaks after the last one because trailing waypoints were skipped.
|
||||
*/
|
||||
export function trailBackfillFixes(waypoints) {
|
||||
const fixes = [];
|
||||
let skipped = false;
|
||||
for (const { position, timeMs, nullAltitude } of waypoints) {
|
||||
if (!position) {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
const fix = { position, timeMs, nullAltitude: !!nullAltitude };
|
||||
fix.breakBefore = skipped || trailFixBreaks(fixes[fixes.length - 1], fix);
|
||||
skipped = false;
|
||||
fixes.push(fix);
|
||||
}
|
||||
return { fixes, breakAfter: skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Put backfill fixes ahead of the live trail. The join follows the same
|
||||
* rule, so a distant history tail never connects to the first live fix.
|
||||
* Past `maxPoints` the oldest recorded points go first, then the oldest
|
||||
* reports.
|
||||
* @param {{fixes: object[], breakAfter: boolean}} backfill
|
||||
* @param {object[]} live - Live fixes; the first one's break is recomputed.
|
||||
* @param {number} maxPoints - Point limit across all segments.
|
||||
* @returns {object[]} The joined trail.
|
||||
*/
|
||||
export function joinTrailBackfill({ fixes, breakAfter }, live, maxPoints) {
|
||||
if (!fixes.length) return live;
|
||||
if (live.length)
|
||||
live[0].breakBefore =
|
||||
breakAfter || trailFixBreaks(fixes[fixes.length - 1], live[0]);
|
||||
return keepTrailLimit(fixes.concat(live), maxPoints);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many fixes the drawn body holds: every fix at or before the display
|
||||
* time, where the aircraft is drawn. Fixes ahead of it (reports newer than
|
||||
* the delayed display) are reached by the head segment instead, so the trail
|
||||
* never runs past the aircraft, and once the display passes the newest
|
||||
* report the body ends there.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} displayMs - Time the aircraft is drawn at.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trailBodyCount(fixes, displayMs) {
|
||||
let count = fixes.length;
|
||||
while (count > 0 && fixes[count - 1].timeMs > displayMs) count -= 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave out the newest recorded points that lie inside the aircraft's 3D
|
||||
* model, so the body never runs through the fuselage; the head then runs
|
||||
* from the newest point outside the model to the plane. Reports are kept.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} count - From `trailBodyCount`.
|
||||
* @param {Cesium.Cartesian3|null} modelCenter - The model's centre, or null
|
||||
* when no model is the visual.
|
||||
* @param {number|null} envelopeM - The model's radius (m).
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trailBodyCountOutsideModel(
|
||||
fixes,
|
||||
count,
|
||||
modelCenter,
|
||||
envelopeM,
|
||||
) {
|
||||
if (!modelCenter || !(envelopeM > 0)) return count;
|
||||
while (
|
||||
count > 0 &&
|
||||
fixes[count - 1].drawn &&
|
||||
Cesium.Cartesian3.distance(fixes[count - 1].position, modelCenter) <
|
||||
envelopeM
|
||||
)
|
||||
count -= 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn trail body, split at breaks: the first `count` fixes.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} count - From `trailBodyCount`.
|
||||
* @returns {Cesium.Cartesian3[][]}
|
||||
*/
|
||||
export function trailBodySegments(fixes, count) {
|
||||
const segments = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const fix = fixes[i];
|
||||
// A live report with no altitude keeps no position; it only breaks the line.
|
||||
if (!fix.position) continue;
|
||||
if (fix.breakBefore || !segments.length) segments.push([]);
|
||||
segments[segments.length - 1].push(fix.position);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw body segments. A trail without breaks support draws only the newest
|
||||
* segment, never a line across a break.
|
||||
* @param {{setSegments?: Function, setPositions: Function}} trail
|
||||
* @param {Cesium.Cartesian3[][]} segments
|
||||
*/
|
||||
export function drawTrailSegments(trail, segments) {
|
||||
if (typeof trail.setSegments === 'function') trail.setSegments(segments);
|
||||
else trail.setPositions(segments[segments.length - 1] ?? []);
|
||||
}
|
||||
|
||||
/** @constant {number} A head shorter than this (m) is not drawn: the aircraft is holding at its last report. */
|
||||
const TRAIL_HEAD_MIN_M = 0.5;
|
||||
|
||||
/**
|
||||
* The body fix the head segment starts from, or null when the head is not
|
||||
* drawn: no body yet, the aircraft holding at that fix, a body point more
|
||||
* than 150 km from the aircraft, or one too long ago or too far away to be
|
||||
* one path.
|
||||
* @param {object[]} fixes
|
||||
* @param {Cesium.Cartesian3} head - Displayed aircraft position.
|
||||
* @param {number} nowMs - Current time.
|
||||
* @param {number} count - Body fixes, from `trailBodyCount`.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function trailHeadStartFix(fixes, head, nowMs, count) {
|
||||
if (count < 1 || !head) return null;
|
||||
const last = fixes[count - 1];
|
||||
if (!last.position) return null;
|
||||
if (Cesium.Cartesian3.distance(last.position, head) < TRAIL_HEAD_MIN_M)
|
||||
return null;
|
||||
return trailHeadJoins(
|
||||
nowMs - last.timeMs,
|
||||
trailDistanceM(last.position, head),
|
||||
)
|
||||
? last
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms of a position-history fix (`{epochMs?, time}`).
|
||||
* @param {object|undefined} fix
|
||||
* @returns {number} NaN when unknown.
|
||||
*/
|
||||
export function historyFixEpochMs(fix) {
|
||||
if (Number.isFinite(fix?.epochMs)) return fix.epochMs;
|
||||
return fix?.time ? Cesium.JulianDate.toDate(fix.time).getTime() : NaN;
|
||||
}
|
||||
|
||||
/** @constant {number} The tracked icon's drawn position is recorded at least this often (ms of display time)... */
|
||||
export const TRAIL_RECORD_STEP_MS = 1_000;
|
||||
|
||||
/** @constant {number} ...or whenever it has moved this far (m), whichever comes first. */
|
||||
export const TRAIL_RECORD_STEP_M = 150;
|
||||
|
||||
/** @constant {number} A recorded point is kept only where dropping it would move the line by more than this (m) across the track or in height. */
|
||||
export const TRAIL_THIN_TOLERANCE_M = 30;
|
||||
|
||||
/** @constant {number} A recorded point is kept where dropping it would leave a straight piece longer than this (m), so a long leg still has vertices. */
|
||||
export const TRAIL_THIN_MAX_SPAN_M = 10_000;
|
||||
|
||||
/** @constant {number} A kept point stands in for at most this many dropped positions; past it the point is kept. */
|
||||
const TRAIL_THIN_MAX_ABSORBED = 512;
|
||||
|
||||
/** @constant {number} A recorded point is dropped where the line turns back sharper than this (degrees)... */
|
||||
export const TRAIL_SPUR_TURN_DEG = 120;
|
||||
|
||||
/** @constant {number} ...over a step shorter than this (m). */
|
||||
export const TRAIL_SPUR_MAX_STEP_M = 500;
|
||||
|
||||
/** @constant {number} A recorded point closer than this (m) to the previous one is not kept: the aircraft is holding. */
|
||||
const TRAIL_RECORD_MIN_STEP_M = 1;
|
||||
|
||||
const _u = new Cesium.Cartesian3();
|
||||
const _v = new Cesium.Cartesian3();
|
||||
|
||||
/** Cosine of the turn at `b` on the way a → b → c, or NaN for a zero step. */
|
||||
function turnCos(a, b, c) {
|
||||
Cesium.Cartesian3.subtract(b, a, _u);
|
||||
Cesium.Cartesian3.subtract(c, b, _v);
|
||||
const lu = Cesium.Cartesian3.magnitude(_u);
|
||||
const lv = Cesium.Cartesian3.magnitude(_v);
|
||||
if (!(lu > 0 && lv > 0)) return NaN;
|
||||
return Cesium.Cartesian3.dot(_u, _v) / (lu * lv);
|
||||
}
|
||||
|
||||
const SPUR_COS = Math.cos(Cesium.Math.toRadians(TRAIL_SPUR_TURN_DEG));
|
||||
|
||||
/** Whether entry `i` and its neighbours are one drawn piece of line. */
|
||||
function joinedAt(fixes, i) {
|
||||
const a = fixes[i - 1];
|
||||
const b = fixes[i];
|
||||
const c = fixes[i + 1];
|
||||
return (
|
||||
a?.position &&
|
||||
b?.position &&
|
||||
c?.position &&
|
||||
!b.breakBefore &&
|
||||
!c.breakBefore
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the line turns back on itself at entry `i` over a short step. */
|
||||
function spurAt(fixes, i) {
|
||||
if (!joinedAt(fixes, i)) return false;
|
||||
const a = fixes[i - 1].position;
|
||||
const b = fixes[i].position;
|
||||
const c = fixes[i + 1].position;
|
||||
if (
|
||||
Math.min(
|
||||
Cesium.Cartesian3.distance(a, b),
|
||||
Cesium.Cartesian3.distance(b, c),
|
||||
) > TRAIL_SPUR_MAX_STEP_M
|
||||
)
|
||||
return false;
|
||||
return turnCos(a, b, c) < SPUR_COS;
|
||||
}
|
||||
|
||||
/** Remove entry `i` and re-judge the break before the entry that takes its place. */
|
||||
function removeAt(fixes, i) {
|
||||
fixes.splice(i, 1);
|
||||
if (i < fixes.length)
|
||||
fixes[i].breakBefore = trailFixBreaks(fixes[i - 1], fixes[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the point limit: drop the oldest recorded point first, and the oldest
|
||||
* report only when no recorded point is left.
|
||||
* @param {object[]} fixes - The trail, mutated.
|
||||
* @param {number} maxPoints
|
||||
* @returns {object[]} `fixes`.
|
||||
*/
|
||||
function keepTrailLimit(fixes, maxPoints) {
|
||||
let excess = fixes.length - maxPoints;
|
||||
if (excess <= 0) return fixes;
|
||||
for (let i = 0; i < fixes.length && excess > 0;) {
|
||||
if (fixes[i].drawn) {
|
||||
removeAt(fixes, i);
|
||||
excess -= 1;
|
||||
} else i += 1;
|
||||
}
|
||||
if (excess > 0) fixes.splice(0, excess);
|
||||
return fixes;
|
||||
}
|
||||
|
||||
const _ab = new Cesium.Cartesian3();
|
||||
const _ap = new Cesium.Cartesian3();
|
||||
const _off = new Cesium.Cartesian3();
|
||||
const _up = new Cesium.Cartesian3();
|
||||
|
||||
/** Whether `p` lies within the thinning tolerance of segment a → b, across the track and in height. */
|
||||
function nearSegment(p, a, b) {
|
||||
Cesium.Cartesian3.subtract(b, a, _ab);
|
||||
Cesium.Cartesian3.subtract(p, a, _ap);
|
||||
const len2 = Cesium.Cartesian3.magnitudeSquared(_ab);
|
||||
const t =
|
||||
len2 > 0
|
||||
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(_ap, _ab) / len2))
|
||||
: 0;
|
||||
Cesium.Cartesian3.multiplyByScalar(_ab, t, _off);
|
||||
Cesium.Cartesian3.subtract(_ap, _off, _off);
|
||||
Cesium.Cartesian3.normalize(p, _up);
|
||||
const vertical = Cesium.Cartesian3.dot(_off, _up);
|
||||
const across = Math.sqrt(
|
||||
Math.max(0, Cesium.Cartesian3.magnitudeSquared(_off) - vertical * vertical),
|
||||
);
|
||||
return (
|
||||
Math.abs(vertical) <= TRAIL_THIN_TOLERANCE_M &&
|
||||
across <= TRAIL_THIN_TOLERANCE_M
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop recorded point `i` when the line without it stays within the
|
||||
* tolerance of every position it stood for, and the straight piece left is
|
||||
* no longer than TRAIL_THIN_MAX_SPAN_M. The positions it stood for go on to
|
||||
* the next entry. Reports are never dropped.
|
||||
* @returns {boolean} Whether it was dropped.
|
||||
*/
|
||||
function thinAt(fixes, i) {
|
||||
const entry = fixes[i];
|
||||
if (!entry?.drawn || entry.sealed || !joinedAt(fixes, i)) return false;
|
||||
const a = fixes[i - 1].position;
|
||||
const next = fixes[i + 1];
|
||||
if (next.sealed) return false;
|
||||
const b = next.position;
|
||||
if (Cesium.Cartesian3.distance(a, b) > TRAIL_THIN_MAX_SPAN_M) return false;
|
||||
const stood = entry.absorbed ?? [];
|
||||
const nextStood = next.absorbed ?? [];
|
||||
if (stood.length + nextStood.length + 1 > TRAIL_THIN_MAX_ABSORBED)
|
||||
return false;
|
||||
if (!nearSegment(entry.position, a, b)) return false;
|
||||
for (const p of stood) if (!nearSegment(p.position, a, b)) return false;
|
||||
for (const p of nextStood) if (!nearSegment(p.position, a, b)) return false;
|
||||
next.absorbed = stood.concat(
|
||||
[{ position: entry.position, timeMs: entry.timeMs }],
|
||||
nextStood,
|
||||
);
|
||||
removeAt(fixes, i);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin the recorded points either side of entry `i`, reaching past a
|
||||
* neighbouring report: the point recorded just before a report is only
|
||||
* judged once the line goes on beyond it.
|
||||
*/
|
||||
function thinAround(fixes, i) {
|
||||
if (!thinAt(fixes, i + 1) && fixes[i + 1] && !fixes[i + 1].drawn)
|
||||
thinAt(fixes, i + 2);
|
||||
if (thinAt(fixes, i - 1)) i -= 1;
|
||||
else if (fixes[i - 1] && !fixes[i - 1].drawn) thinAt(fixes, i - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop recorded points that make a spur next to entry `i`. Reports are never
|
||||
* dropped; at a report the recorded neighbour is dropped instead.
|
||||
* @returns {number} The index entry `i` ends up at.
|
||||
*/
|
||||
function removeSpursAround(fixes, i) {
|
||||
for (let guard = 0; guard < 64; guard++) {
|
||||
let changed = false;
|
||||
for (const j of [i - 1, i, i + 1]) {
|
||||
if (!spurAt(fixes, j)) continue;
|
||||
let drop = -1;
|
||||
if (fixes[j].drawn) drop = j;
|
||||
else if (fixes[j + 1]?.drawn) drop = j + 1;
|
||||
else if (fixes[j - 1]?.drawn) drop = j - 1;
|
||||
if (drop < 0) continue;
|
||||
removeAt(fixes, drop);
|
||||
if (drop < i) i -= 1;
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Index a fix at `timeMs` goes in at: after every entry at or before it. */
|
||||
function insertionIndex(fixes, timeMs) {
|
||||
let i = fixes.length;
|
||||
while (i > 0 && fixes[i - 1].timeMs > timeMs) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an entry at `i`, judge both breaks, remove spurs, thin the recorded
|
||||
* points either side and keep the limit.
|
||||
*/
|
||||
function insertAt(fixes, i, entry, maxPoints) {
|
||||
fixes.splice(i, 0, entry);
|
||||
entry.breakBefore = trailFixBreaks(fixes[i - 1], entry);
|
||||
const after = fixes[i + 1];
|
||||
if (after) {
|
||||
after.breakBefore = trailFixBreaks(entry, after);
|
||||
// Positions the next entry stood for that come before this one are now
|
||||
// on this entry's side.
|
||||
if (after.absorbed?.length) {
|
||||
const mine = after.absorbed.filter((p) => p.timeMs < entry.timeMs);
|
||||
if (mine.length) {
|
||||
entry.absorbed = mine;
|
||||
after.absorbed = after.absorbed.filter((p) => p.timeMs >= entry.timeMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
i = removeSpursAround(fixes, i);
|
||||
thinAround(fixes, i);
|
||||
keepTrailLimit(fixes, maxPoints);
|
||||
}
|
||||
|
||||
/** Index of the newest report before entry `i`, or -1. */
|
||||
function reportBefore(fixes, i) {
|
||||
let j = i - 1;
|
||||
while (j >= 0 && fixes[j].drawn) j -= 1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/** Index of the oldest report at or after `i`, or `fixes.length`. */
|
||||
function reportFrom(fixes, i) {
|
||||
let j = i;
|
||||
while (j < fixes.length && fixes[j].drawn) j += 1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a report into the trail in time order. Reports are the record: they
|
||||
* are kept as anchors among the recorded points.
|
||||
*
|
||||
* - A report the display has already passed replaces the guess drawn after
|
||||
* it: the recorded points timed after it go, and nothing is recorded until
|
||||
* the icon has eased onto the correction (`displayMs` + `settleMs`), so the
|
||||
* line reads arc, report, corrected path.
|
||||
* - Recorded points never join two reports that break under
|
||||
* `trailPairBreaks` (too long or too fast apart to be one flight): the
|
||||
* points between them go and the line breaks.
|
||||
* - A live report with no altitude keeps no position and only breaks the
|
||||
* line.
|
||||
* @param {object[]} fixes - The trail, mutated.
|
||||
* @param {Cesium.Cartesian3} position - Owned position.
|
||||
* @param {number} timeMs - The report's epoch ms.
|
||||
* @param {number} maxPoints - Point limit across all segments.
|
||||
* @param {{nullAltitude?: boolean, displayMs?: number, settleMs?: number}} [options]
|
||||
* `displayMs`: the time the aircraft is drawn at as the report arrives;
|
||||
* `settleMs`: how long the icon takes to ease onto a correction.
|
||||
* @returns {object[]} `fixes`.
|
||||
*/
|
||||
export function insertTrailFix(
|
||||
fixes,
|
||||
position,
|
||||
timeMs,
|
||||
maxPoints,
|
||||
{ nullAltitude = false, displayMs = NaN, settleMs = 0 } = {},
|
||||
) {
|
||||
const entry = {
|
||||
position: nullAltitude ? null : position,
|
||||
timeMs,
|
||||
nullAltitude,
|
||||
drawn: false,
|
||||
breakBefore: false,
|
||||
};
|
||||
let i = insertionIndex(fixes, timeMs);
|
||||
// The guess drawn after a report the display already passed.
|
||||
fixes.splice(i, reportFrom(fixes, i) - i);
|
||||
if (displayMs > timeMs) entry.settleUntilMs = displayMs + settleMs;
|
||||
// Recorded points across a pair of reports that are not one flight.
|
||||
const before = reportBefore(fixes, i);
|
||||
if (before >= 0 && trailFixBreaks(fixes[before], entry)) {
|
||||
fixes.splice(before + 1, i - before - 1);
|
||||
i = before + 1;
|
||||
}
|
||||
insertAt(fixes, i, entry, maxPoints);
|
||||
return fixes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record where the tracked aircraft was drawn at a display time. Between
|
||||
* reports this recorded path is the trail, so the line follows every turn
|
||||
* the icon made instead of a straight chord between two reports. Points are
|
||||
* thinned by shape: one stays only where dropping it would move the line by
|
||||
* more than TRAIL_THIN_TOLERANCE_M across the track or in height, or leave a
|
||||
* straight piece longer than TRAIL_THIN_MAX_SPAN_M. Nothing is recorded
|
||||
* while the aircraft holds.
|
||||
* @param {object[]} fixes - The trail, mutated.
|
||||
* @param {Cesium.Cartesian3} position - Owned drawn position.
|
||||
* @param {number} timeMs - Display time it was drawn for.
|
||||
* @param {number} maxPoints - Point limit across all segments.
|
||||
* @returns {boolean} Whether the trail changed.
|
||||
*/
|
||||
export function recordTrailPoint(fixes, position, timeMs, maxPoints) {
|
||||
const i = insertionIndex(fixes, timeMs);
|
||||
const prev = fixes[i - 1];
|
||||
if (
|
||||
prev?.position &&
|
||||
Cesium.Cartesian3.distance(prev.position, position) <
|
||||
TRAIL_RECORD_MIN_STEP_M
|
||||
)
|
||||
return false;
|
||||
const entry = {
|
||||
position,
|
||||
timeMs,
|
||||
nullAltitude: false,
|
||||
drawn: true,
|
||||
breakBefore: false,
|
||||
};
|
||||
insertAt(fixes, i, entry, maxPoints);
|
||||
// Entries before the second report behind this point can never be thinned
|
||||
// again, so they let go of the positions they stood for.
|
||||
const passed = reportBefore(fixes, fixes.lastIndexOf(entry));
|
||||
if (passed >= 0) sealThrough(fixes, reportBefore(fixes, passed));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release the absorbed positions of entry `i` and everything before it. */
|
||||
function sealThrough(fixes, i) {
|
||||
for (let j = i; j >= 0 && !fixes[j].sealed; j--) {
|
||||
fixes[j].sealed = true;
|
||||
delete fixes[j].absorbed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the icon's drawn position must not be recorded at a display time:
|
||||
* a report on either side of it has no altitude (the icon is drawn at an
|
||||
* invented height), the reports on either side are not one flight
|
||||
* (`trailPairBreaks`), or the icon is still easing onto a report that
|
||||
* arrived after the display had passed it.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} displayMs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailRecordingBlocked(fixes, displayMs) {
|
||||
const i = insertionIndex(fixes, displayMs);
|
||||
const before = fixes[reportBefore(fixes, i)];
|
||||
const after = fixes[reportFrom(fixes, i)];
|
||||
if (before?.nullAltitude || after?.nullAltitude) return true;
|
||||
if (before?.settleUntilMs > displayMs) return true;
|
||||
return !!(before && after && trailFixBreaks(before, after));
|
||||
}
|
||||
+269
-58
@@ -14,6 +14,18 @@
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* A trail with breaks draws each segment separately and nothing between
|
||||
* them. Every segment is cut into chunks of up to CHUNK_POINTS points;
|
||||
* adjacent chunks share their boundary point, so the line has no seam. A
|
||||
* chunk that can no longer change (every chunk except the newest one of the
|
||||
* newest segment) is FROZEN: an entity with constant positions, built once as
|
||||
* static geometry, so it costs nothing per frame. Only the newest chunk stays
|
||||
* dynamic. A freshly frozen chunk is also drawn dynamically until its static
|
||||
* geometry is in the scene, so freezing never shows a blank frame. A chunk is
|
||||
* identified by its position objects, so a chunk survives every update that
|
||||
* keeps those points: appends, a backfill prepended ahead of it, or the
|
||||
* oldest points dropping off the front (which rebuilds only the first chunk).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
@@ -42,6 +54,13 @@ const TRAIL_ALPHA = 0.85;
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
/** @constant {Cesium.Cartesian3[]} What a spare dynamic polyline draws. */
|
||||
const NO_POSITIONS = Object.freeze([]);
|
||||
/** @constant {number} Points per chunk; adjacent chunks share one point. */
|
||||
export const CHUNK_POINTS = 128;
|
||||
/** @constant {number} Visible frames a frozen chunk's stand-in waits for its
|
||||
* static geometry before it is dropped anyway. */
|
||||
const STAND_IN_MAX_FRAMES = 600;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
@@ -49,93 +68,285 @@ const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setSegments: function(Cesium.Cartesian3[][]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry with one line,
|
||||
* setSegments with one line per segment, setVisible temporarily hides it
|
||||
* without discarding history, clear empties it, and destroy removes the
|
||||
* entities permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
const material = baseColor.withAlpha(TRAIL_ALPHA);
|
||||
const depthFailMaterial = baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA);
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
/** @type {{positions: Cesium.Cartesian3[], entity: Cesium.Entity, standIn: boolean, frames: number}[]} */
|
||||
let frozen = [];
|
||||
/** Newest chunk of the newest segment: the only part that changes. */
|
||||
let tail = NO_POSITIONS;
|
||||
/** What the dynamic polylines draw: the tail, then frozen chunks whose
|
||||
* static geometry is not in the scene yet. */
|
||||
let dynamicRuns = [];
|
||||
/** @type {Cesium.Entity[]} Pooled dynamic polylines; spares draw nothing. */
|
||||
const dynamicEntities = [];
|
||||
let removeReadyListener = null;
|
||||
const scratchSphere = new Cesium.BoundingSphere();
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
const usable = () => !destroyed && viewer && !viewer.isDestroyed();
|
||||
|
||||
function polyline(positions) {
|
||||
return {
|
||||
positions,
|
||||
width,
|
||||
material,
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial,
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
};
|
||||
}
|
||||
|
||||
function addEntity(positions) {
|
||||
return viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
polyline: polyline(positions),
|
||||
});
|
||||
}
|
||||
|
||||
function removeEntity(entity) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
|
||||
function syncDynamic() {
|
||||
dynamicRuns = [];
|
||||
if (tail.length >= 2) dynamicRuns.push(tail);
|
||||
for (const chunk of frozen)
|
||||
if (chunk.standIn) dynamicRuns.push(chunk.positions);
|
||||
while (dynamicEntities.length < Math.max(1, dynamicRuns.length)) {
|
||||
const index = dynamicEntities.length;
|
||||
dynamicEntities.push(
|
||||
addEntity(
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
new Cesium.CallbackProperty(
|
||||
() => dynamicRuns[index] ?? NO_POSITIONS,
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a frozen chunk's static geometry is drawn in the scene. */
|
||||
function staticReady(chunk) {
|
||||
const display = viewer.dataSourceDisplay;
|
||||
if (typeof display?.getBoundingSphere !== 'function') return true;
|
||||
if (!visible) return false;
|
||||
chunk.frames += 1;
|
||||
if (chunk.frames > STAND_IN_MAX_FRAMES) return true;
|
||||
try {
|
||||
return (
|
||||
display.getBoundingSphere(chunk.entity, false, scratchSphere) ===
|
||||
Cesium.BoundingSphereState.DONE
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop stand-ins whose static geometry is in; stop listening when none are left. */
|
||||
function resolveStandIns() {
|
||||
if (!usable()) return;
|
||||
let changed = false;
|
||||
for (const chunk of frozen) {
|
||||
if (chunk.standIn && staticReady(chunk)) {
|
||||
chunk.standIn = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) syncDynamic();
|
||||
if (!frozen.some((chunk) => chunk.standIn)) stopListening();
|
||||
}
|
||||
|
||||
function stopListening() {
|
||||
if (removeReadyListener) removeReadyListener();
|
||||
removeReadyListener = null;
|
||||
}
|
||||
|
||||
function listenForStandIns() {
|
||||
if (removeReadyListener) return;
|
||||
const event = viewer.scene?.postRender;
|
||||
if (typeof event?.addEventListener !== 'function') {
|
||||
resolveStandIns();
|
||||
return;
|
||||
}
|
||||
removeReadyListener = event.addEventListener(resolveStandIns);
|
||||
}
|
||||
|
||||
/** Drop consecutive near-duplicates: zero-length segments add nothing. */
|
||||
function dedupe(cartesians) {
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** Whether `chunk` holds exactly the same position objects as `seg` from `start`. */
|
||||
function sameRun(chunk, seg, start) {
|
||||
const positions = chunk.positions;
|
||||
if (start + positions.length > seg.length) return false;
|
||||
for (let k = 0; k < positions.length; k++)
|
||||
if (positions[k] !== seg[start + k]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut segments into chunks, reusing every frozen chunk whose points are
|
||||
* still drawn in the same order. New chunks end where a reusable one begins,
|
||||
* so dropping the oldest point rebuilds only the first chunk.
|
||||
*/
|
||||
function reconcile(segments) {
|
||||
const byStart = new Map();
|
||||
for (const chunk of frozen) byStart.set(chunk.positions[0], chunk);
|
||||
const kept = new Set();
|
||||
const reusable = (seg, j) => {
|
||||
const chunk = byStart.get(seg[j]);
|
||||
return chunk && !kept.has(chunk) && sameRun(chunk, seg, j) ? chunk : null;
|
||||
};
|
||||
const next = [];
|
||||
let nextTail = NO_POSITIONS;
|
||||
segments.forEach((seg, s) => {
|
||||
const newest = s === segments.length - 1;
|
||||
let i = 0;
|
||||
while (i < seg.length - 1) {
|
||||
const existing = reusable(seg, i);
|
||||
if (existing) {
|
||||
kept.add(existing);
|
||||
next.push(existing);
|
||||
i += existing.positions.length - 1;
|
||||
continue;
|
||||
}
|
||||
if (newest && seg.length - i <= CHUNK_POINTS) {
|
||||
nextTail = seg.slice(i);
|
||||
break;
|
||||
}
|
||||
let end = Math.min(seg.length - 1, i + CHUNK_POINTS - 1);
|
||||
for (let j = i + 1; j < end; j++) {
|
||||
if (reusable(seg, j)) {
|
||||
end = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const positions = seg.slice(i, end + 1);
|
||||
next.push({
|
||||
positions,
|
||||
entity: addEntity(positions),
|
||||
standIn: true,
|
||||
frames: 0,
|
||||
});
|
||||
i = end;
|
||||
}
|
||||
});
|
||||
for (const chunk of frozen)
|
||||
if (!kept.has(chunk)) removeEntity(chunk.entity);
|
||||
frozen = next;
|
||||
tail = nextTail;
|
||||
syncDynamic();
|
||||
if (frozen.some((chunk) => chunk.standIn)) listenForStandIns();
|
||||
else stopListening();
|
||||
}
|
||||
|
||||
function release() {
|
||||
stopListening();
|
||||
for (const chunk of frozen) removeEntity(chunk.entity);
|
||||
frozen = [];
|
||||
tail = NO_POSITIONS;
|
||||
dynamicRuns = [];
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* Replace the trail geometry with one chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
this.setSegments([cartesians]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the trail geometry with chronological segments (oldest first).
|
||||
* Nothing is drawn between segments. A segment with fewer than 2 distinct
|
||||
* positions draws nothing. Pass the same position objects on every
|
||||
* update: a chunk whose points are unchanged is never rebuilt.
|
||||
* @param {Cesium.Cartesian3[][]} list - Segments, each oldest -> newest.
|
||||
*/
|
||||
setSegments(list) {
|
||||
if (!usable()) return;
|
||||
reconcile(
|
||||
(Array.isArray(list) ? list : [])
|
||||
.map(dedupe)
|
||||
.filter((positions) => positions.length >= 2),
|
||||
);
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
for (const entity of dynamicEntities) entity.show = visible;
|
||||
for (const chunk of frozen) chunk.entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
/** Empty the trail and release its frozen geometry; dynamic polylines stay for re-arm. */
|
||||
clear() {
|
||||
current = [];
|
||||
if (!usable()) return;
|
||||
release();
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
/** Remove every trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
if (usable()) {
|
||||
release();
|
||||
for (const entity of dynamicEntities) removeEntity(entity);
|
||||
}
|
||||
entity = null;
|
||||
destroyed = true;
|
||||
frozen = [];
|
||||
tail = NO_POSITIONS;
|
||||
dynamicRuns = [];
|
||||
dynamicEntities.length = 0;
|
||||
stopListening();
|
||||
},
|
||||
|
||||
/** Test seam: frozen chunks (positions, stand-in state) and the dynamic runs. */
|
||||
_chunksForTest() {
|
||||
return {
|
||||
frozen: frozen.map((chunk) => ({
|
||||
positions: chunk.positions,
|
||||
standIn: chunk.standIn,
|
||||
entity: chunk.entity,
|
||||
})),
|
||||
dynamic: dynamicRuns.slice(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createTrail } from './trailRenderer.js';
|
||||
import { CHUNK_POINTS, createTrail } from './trailRenderer.js';
|
||||
|
||||
test('trail visibility can change without discarding its accumulated geometry', () => {
|
||||
const added = [];
|
||||
@@ -37,3 +37,253 @@ test('trail visibility can change without discarding its accumulated geometry',
|
||||
trail.destroy();
|
||||
assert.deepEqual(removed, [added[0]]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A fake viewer. `ready` holds the frozen-chunk entities whose static
|
||||
* geometry is in the scene; `frame()` runs one post-render pass.
|
||||
*/
|
||||
function fakeViewer({ scene = true } = {}) {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const ready = new Set();
|
||||
let listeners = [];
|
||||
const viewer = {
|
||||
added,
|
||||
removed,
|
||||
ready,
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add(definition) {
|
||||
added.push(definition);
|
||||
return definition;
|
||||
},
|
||||
remove(entity) {
|
||||
removed.push(entity);
|
||||
},
|
||||
},
|
||||
listenerCount: () => listeners.length,
|
||||
frame() {
|
||||
for (const listener of listeners.slice()) listener();
|
||||
},
|
||||
};
|
||||
if (scene) {
|
||||
viewer.scene = {
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
viewer.dataSourceDisplay = {
|
||||
getBoundingSphere: (entity) =>
|
||||
ready.has(entity)
|
||||
? Cesium.BoundingSphereState.DONE
|
||||
: Cesium.BoundingSphereState.PENDING,
|
||||
};
|
||||
}
|
||||
return viewer;
|
||||
}
|
||||
|
||||
const valueOf = (positions) =>
|
||||
typeof positions?.getValue === 'function' ? positions.getValue() : positions;
|
||||
|
||||
/** Every line the scene can draw now: live entities with two or more points. */
|
||||
function drawnRuns(viewer) {
|
||||
return viewer.added
|
||||
.filter((entity) => !viewer.removed.includes(entity))
|
||||
.map((entity) => valueOf(entity.polyline.positions))
|
||||
.filter((positions) => positions.length >= 2);
|
||||
}
|
||||
|
||||
/** Distinct position objects across all drawn lines. */
|
||||
function drawnPoints(viewer) {
|
||||
return new Set(drawnRuns(viewer).flat());
|
||||
}
|
||||
|
||||
function line(count, offset = 0) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => new Cesium.Cartesian3(6_378_137 + offset, i * 1000, 0),
|
||||
);
|
||||
}
|
||||
|
||||
test('a trail with breaks draws each segment and nothing between them', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const first = line(2, 0);
|
||||
const lone = line(1, 50_000);
|
||||
const second = line(3, 100_000);
|
||||
|
||||
trail.setSegments([first, lone, second]);
|
||||
assert.deepEqual(
|
||||
drawnRuns(viewer).sort((a, b) => a[0].x - b[0].x),
|
||||
[first, second],
|
||||
);
|
||||
for (const entity of viewer.added) {
|
||||
assert.match(String(entity.id), /^gev-trail:/);
|
||||
assert.equal(entity.polyline.width, 2.5);
|
||||
}
|
||||
|
||||
// The first segment is gone later: its geometry is released.
|
||||
trail.setSegments([second]);
|
||||
assert.deepEqual(drawnRuns(viewer), [second]);
|
||||
|
||||
trail.setVisible(false);
|
||||
assert.ok(
|
||||
viewer.added
|
||||
.filter((entity) => !viewer.removed.includes(entity))
|
||||
.every((entity) => entity.show === false),
|
||||
);
|
||||
trail.destroy();
|
||||
assert.equal(new Set(viewer.removed).size, viewer.added.length);
|
||||
});
|
||||
|
||||
test('chunks share their boundary points and only the newest chunk is dynamic', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(300);
|
||||
trail.setPositions(points);
|
||||
const { frozen, dynamic } = trail._chunksForTest();
|
||||
assert.deepEqual(
|
||||
frozen.map((chunk) => chunk.positions.length),
|
||||
[CHUNK_POINTS, CHUNK_POINTS],
|
||||
);
|
||||
assert.equal(frozen[0].positions.at(-1), frozen[1].positions[0]);
|
||||
assert.equal(dynamic.length, 1);
|
||||
assert.equal(frozen[1].positions.at(-1), dynamic[0][0]);
|
||||
assert.equal(dynamic[0].at(-1), points.at(-1));
|
||||
assert.equal(drawnPoints(viewer).size, points.length);
|
||||
// A frozen chunk is constant geometry, not a per-frame callback.
|
||||
for (const chunk of frozen)
|
||||
assert.ok(Array.isArray(chunk.entity.polyline.positions));
|
||||
});
|
||||
|
||||
test('freezing a chunk keeps every point drawn, and the dynamic copy goes only once the static geometry is in', () => {
|
||||
const viewer = fakeViewer();
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(CHUNK_POINTS + 20);
|
||||
for (let n = 2; n <= points.length; n++) {
|
||||
trail.setPositions(points.slice(0, n));
|
||||
assert.equal(drawnPoints(viewer).size, n, `points drawn at ${n}`);
|
||||
}
|
||||
const { frozen } = trail._chunksForTest();
|
||||
assert.equal(frozen.length, 1);
|
||||
// Not in the scene yet: the chunk is still drawn by a dynamic polyline.
|
||||
viewer.frame();
|
||||
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
|
||||
assert.ok(
|
||||
trail._chunksForTest().dynamic.includes(frozen[0].positions),
|
||||
'the dynamic copy stays until the static geometry is ready',
|
||||
);
|
||||
viewer.ready.add(frozen[0].entity);
|
||||
viewer.frame();
|
||||
const after = trail._chunksForTest();
|
||||
assert.equal(after.frozen[0].standIn, false);
|
||||
assert.ok(!after.dynamic.includes(frozen[0].positions));
|
||||
assert.equal(after.dynamic.length, 1);
|
||||
assert.equal(viewer.listenerCount(), 0, 'no listener once nothing waits');
|
||||
// The frozen entity is the same object: freezing built it once.
|
||||
assert.equal(after.frozen[0].entity, frozen[0].entity);
|
||||
});
|
||||
|
||||
test('a hidden trail keeps its dynamic copy until it is shown and the static geometry is in', () => {
|
||||
const viewer = fakeViewer();
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
trail.setVisible(false);
|
||||
trail.setPositions(line(CHUNK_POINTS + 5));
|
||||
const [chunk] = trail._chunksForTest().frozen;
|
||||
viewer.ready.add(chunk.entity);
|
||||
viewer.frame();
|
||||
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
|
||||
trail.setVisible(true);
|
||||
viewer.frame();
|
||||
assert.equal(trail._chunksForTest().frozen[0].standIn, false);
|
||||
});
|
||||
|
||||
test('dropping the oldest point rebuilds only the first chunk', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(400);
|
||||
trail.setPositions(points);
|
||||
const before = trail._chunksForTest().frozen;
|
||||
trail.setPositions(points.slice(1));
|
||||
const after = trail._chunksForTest().frozen;
|
||||
assert.equal(after.length, before.length);
|
||||
assert.notEqual(after[0].entity, before[0].entity);
|
||||
assert.ok(viewer.removed.includes(before[0].entity));
|
||||
for (let k = 1; k < before.length; k++)
|
||||
assert.equal(after[k].entity, before[k].entity);
|
||||
assert.equal(after[0].positions.at(-1), after[1].positions[0]);
|
||||
assert.equal(drawnPoints(viewer).size, 399);
|
||||
});
|
||||
|
||||
test('a backfill prepended ahead of frozen chunks keeps them and joins at a shared point', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const live = line(300, 0);
|
||||
trail.setPositions(live);
|
||||
const before = trail._chunksForTest().frozen;
|
||||
// History older than every live point, joined as one path.
|
||||
const history = Array.from(
|
||||
{ length: 200 },
|
||||
(_, i) => new Cesium.Cartesian3(6_378_137, -(200 - i) * 1000, 0),
|
||||
);
|
||||
trail.setPositions(history.concat(live));
|
||||
const after = trail._chunksForTest().frozen;
|
||||
for (const chunk of before)
|
||||
assert.ok(after.some((c) => c.entity === chunk.entity));
|
||||
for (let k = 0; k + 1 < after.length; k++)
|
||||
assert.equal(after[k].positions.at(-1), after[k + 1].positions[0]);
|
||||
assert.equal(drawnPoints(viewer).size, 500);
|
||||
|
||||
// A backfill that breaks from the live trail is its own segment.
|
||||
trail.setSegments([history, live]);
|
||||
assert.equal(drawnPoints(viewer).size, 500);
|
||||
const runs = drawnRuns(viewer);
|
||||
assert.ok(
|
||||
!runs.some((run) => run.includes(history.at(-1)) && run.includes(live[0])),
|
||||
'no line across the break',
|
||||
);
|
||||
});
|
||||
|
||||
test('clearing and re-tracking release every frozen chunk', () => {
|
||||
const viewer = fakeViewer();
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
trail.setPositions(line(600));
|
||||
const frozen = trail._chunksForTest().frozen.map((chunk) => chunk.entity);
|
||||
assert.ok(frozen.length >= 4);
|
||||
assert.equal(viewer.listenerCount(), 1);
|
||||
trail.clear();
|
||||
for (const entity of frozen) assert.ok(viewer.removed.includes(entity));
|
||||
assert.equal(viewer.listenerCount(), 0);
|
||||
assert.equal(drawnRuns(viewer).length, 0);
|
||||
|
||||
// Another aircraft: nothing of the first one survives.
|
||||
const other = line(200, 900_000);
|
||||
trail.setPositions(other);
|
||||
assert.equal(drawnPoints(viewer).size, 200);
|
||||
trail.destroy();
|
||||
assert.equal(new Set(viewer.removed).size, viewer.added.length);
|
||||
assert.equal(viewer.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('replacing the newest point and removing points inside the trail draws exactly the new trail', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(400);
|
||||
trail.setPositions(points.slice());
|
||||
// A recorded point replaces the newest one; a spur point inside a frozen
|
||||
// chunk and one at a chunk boundary go.
|
||||
points[points.length - 1] = new Cesium.Cartesian3(6_378_137, 399_500, 10);
|
||||
points.splice(200, 1);
|
||||
points.splice(CHUNK_POINTS - 1, 1);
|
||||
trail.setPositions(points.slice());
|
||||
assert.deepEqual(drawnPoints(viewer), new Set(points));
|
||||
// Every drawn line runs through consecutive trail points only.
|
||||
const next = new Map(points.slice(0, -1).map((p, i) => [p, points[i + 1]]));
|
||||
for (const run of drawnRuns(viewer))
|
||||
for (let i = 1; i < run.length; i++) assert.equal(next.get(run[i - 1]), run[i]);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
insertTrailFix,
|
||||
recordTrailPoint,
|
||||
trailBodySegments,
|
||||
trailPairBreaks,
|
||||
trailDistanceM,
|
||||
trailRecordingBlocked,
|
||||
} from './trailContinuity.js';
|
||||
|
||||
// Reports decide where the line may run: recorded points never join two
|
||||
// reports too far apart to be one flight, a report the display has already
|
||||
// passed replaces the guess drawn after it, and old entries let go of the
|
||||
// positions they stood for.
|
||||
|
||||
const SEC = 1000;
|
||||
const LIMIT = 2_000;
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
Cesium.Cartesian3.fromDegrees(-100, 40, 0),
|
||||
);
|
||||
const at = (e, n, u = 10) =>
|
||||
Cesium.Matrix4.multiplyByPoint(
|
||||
enu,
|
||||
new Cesium.Cartesian3(e, n, u),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
|
||||
/** No two neighbours in one drawn segment are too far apart to be one flight. */
|
||||
function assertNoBrokenJoin(fixes) {
|
||||
const reports = fixes.filter((fix) => !fix.drawn && fix.position);
|
||||
for (const segment of trailBodySegments(fixes, fixes.length)) {
|
||||
const inSegment = reports.filter((r) =>
|
||||
segment.some((p) => Cesium.Cartesian3.distance(p, r.position) < 0.01),
|
||||
);
|
||||
for (let i = 1; i < inSegment.length; i++)
|
||||
assert.equal(
|
||||
trailPairBreaks(
|
||||
inSegment[i].timeMs - inSegment[i - 1].timeMs,
|
||||
trailDistanceM(inSegment[i - 1].position, inSegment[i].position),
|
||||
),
|
||||
false,
|
||||
`one segment joins reports at ${inSegment[i - 1].timeMs / SEC} s and ${
|
||||
inSegment[i].timeMs / SEC
|
||||
} s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('two reports 60 s and 51 km apart are not joined, even by recorded points', () => {
|
||||
const A = Cesium.Cartesian3.fromDegrees(-100, 40, 9_000);
|
||||
const B = Cesium.Cartesian3.fromDegrees(-99.4, 40, 9_000);
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, A, 0, LIMIT);
|
||||
insertTrailFix(fixes, B, 60 * SEC, LIMIT); // ahead of the display
|
||||
for (let t = 1; t < 60; t++) {
|
||||
// Nothing is recorded across the pair.
|
||||
assert.equal(trailRecordingBlocked(fixes, t * SEC), true);
|
||||
}
|
||||
assert.equal(trailBodySegments(fixes, fixes.length).length, 2);
|
||||
// Points recorded before the far report was known are dropped.
|
||||
const late = [];
|
||||
insertTrailFix(late, A, 0, LIMIT);
|
||||
for (let t = 1; t < 60; t++)
|
||||
recordTrailPoint(
|
||||
late,
|
||||
Cesium.Cartesian3.lerp(A, B, t / 60, new Cesium.Cartesian3()),
|
||||
t * SEC,
|
||||
LIMIT,
|
||||
);
|
||||
insertTrailFix(late, B, 60 * SEC, LIMIT);
|
||||
assert.equal(late.filter((fix) => fix.drawn).length, 0);
|
||||
assert.equal(trailBodySegments(late, late.length).length, 2);
|
||||
assertNoBrokenJoin(late);
|
||||
});
|
||||
|
||||
test('a coast, a hold and then a far report: nothing is drawn across', () => {
|
||||
const A = at(0, 0, 9_000);
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, A, 0, LIMIT);
|
||||
// The icon coasts east for 90 s at 230 m/s, then holds.
|
||||
for (let t = 1; t <= 200; t++)
|
||||
recordTrailPoint(
|
||||
fixes,
|
||||
at(230 * Math.min(t, 90), 0, 9_000),
|
||||
t * SEC,
|
||||
LIMIT,
|
||||
);
|
||||
assert.ok(fixes.filter((fix) => fix.drawn).length > 0);
|
||||
// The next report is 150 km away two minutes later: over 1,500 kt.
|
||||
const B = at(0, 150_000, 9_000);
|
||||
insertTrailFix(fixes, B, 120 * SEC, LIMIT);
|
||||
assertNoBrokenJoin(fixes);
|
||||
const segments = trailBodySegments(fixes, fixes.length);
|
||||
const segmentOf = (p) =>
|
||||
segments.findIndex((s) =>
|
||||
s.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
|
||||
);
|
||||
assert.notEqual(segmentOf(A), segmentOf(B));
|
||||
// No recorded point between the two reports remains.
|
||||
assert.equal(
|
||||
fixes.filter((fix) => fix.drawn && fix.timeMs < 120 * SEC).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
for (const off of [200, 400, 600, 1_000]) {
|
||||
test(`a report the display already passed, ${off} m off a 2 km turn, leaves no spike`, () => {
|
||||
const R = 2_000;
|
||||
const w = 100 / R;
|
||||
const pos = (t, r = R) =>
|
||||
at(r * Math.sin(w * t), R - r * Math.cos(w * t), 3_000);
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, pos(0), 0, LIMIT);
|
||||
for (let t = 1; t <= 60; t++)
|
||||
recordTrailPoint(fixes, pos(t), t * SEC, LIMIT);
|
||||
const report = pos(50, R - off);
|
||||
// It arrives with the display at 60 s; the ease takes 900 ms.
|
||||
insertTrailFix(fixes, report, 50 * SEC, LIMIT, {
|
||||
displayMs: 60 * SEC,
|
||||
settleMs: 900,
|
||||
});
|
||||
// The guess drawn after the report is gone...
|
||||
assert.deepEqual(
|
||||
fixes.filter((fix) => fix.drawn && fix.timeMs > 50 * SEC),
|
||||
[],
|
||||
);
|
||||
// ...nothing is recorded while the icon eases onto the correction...
|
||||
assert.equal(trailRecordingBlocked(fixes, 60.5 * SEC), true);
|
||||
assert.equal(trailRecordingBlocked(fixes, 61 * SEC), false);
|
||||
// ...and the line reads arc, report, corrected path.
|
||||
for (let t = 61; t <= 70; t++)
|
||||
recordTrailPoint(fixes, pos(t, R - off), t * SEC, LIMIT);
|
||||
const line = trailBodySegments(fixes, fixes.length).flat();
|
||||
const r = line.findIndex(
|
||||
(p) => Cesium.Cartesian3.distance(p, report) < 0.01,
|
||||
);
|
||||
assert.ok(r > 0);
|
||||
for (const p of line.slice(0, r))
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - R) < 31,
|
||||
'a point before the report is off the arc',
|
||||
);
|
||||
for (const p of line.slice(r + 1))
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - (R - off)) <
|
||||
31,
|
||||
'a point after the report is off the corrected path',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('entries behind the report before the display let go of the positions they stood for', () => {
|
||||
const fixes = [];
|
||||
for (let s = 0; s <= 2 * 3_600; s++) {
|
||||
const p = at(230 * s, 0, 10_000);
|
||||
if (s % 15 === 0) insertTrailFix(fixes, p, s * SEC, LIMIT);
|
||||
else recordTrailPoint(fixes, p, s * SEC, LIMIT);
|
||||
}
|
||||
let held = 0;
|
||||
for (const fix of fixes) held += fix.absorbed?.length ?? 0;
|
||||
// Only the newest stretch, up to two reports back, keeps what it stood for.
|
||||
assert.ok(held <= 30, `${held} positions still held`);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
insertTrailFix,
|
||||
joinTrailBackfill,
|
||||
recordTrailPoint,
|
||||
TRAIL_THIN_MAX_SPAN_M,
|
||||
TRAIL_THIN_TOLERANCE_M,
|
||||
} from './trailContinuity.js';
|
||||
|
||||
// Recorded points are thinned by shape: a point stays only where the line
|
||||
// would move without it. Reports always stay.
|
||||
|
||||
const SEC = 1000;
|
||||
const LIMIT = 2_000;
|
||||
const LAT0 = 40;
|
||||
const LON0 = -100;
|
||||
const M_PER_DEG_LAT = 111_320;
|
||||
const mPerDegLon = (lat) => M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180);
|
||||
|
||||
/** Straight and level east at `speed` m/s, `tSec` after the start. */
|
||||
const eastbound = (speed, tSec, heightM = 10_000) =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
LON0 + (speed * tSec) / mPerDegLon(LAT0),
|
||||
LAT0,
|
||||
heightM,
|
||||
);
|
||||
|
||||
/** A standard-rate (3°/s) left turn from north, radius v / ω, `tSec` in. */
|
||||
function turning(speed, tSec, heightM = 10_000) {
|
||||
const omega = (3 * Math.PI) / 180;
|
||||
const radius = speed / omega;
|
||||
// Centre west of the start; heading north, turning left (towards west).
|
||||
const angle = omega * tSec;
|
||||
const east = -radius + radius * Math.cos(angle);
|
||||
const north = radius * Math.sin(angle);
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
LON0 + east / mPerDegLon(LAT0),
|
||||
LAT0 + north / M_PER_DEG_LAT,
|
||||
heightM,
|
||||
);
|
||||
}
|
||||
|
||||
/** Distance from p to the nearest point of a polyline. */
|
||||
function distanceToPath(p, path) {
|
||||
let best = Infinity;
|
||||
const ab = new Cesium.Cartesian3();
|
||||
const ap = new Cesium.Cartesian3();
|
||||
const closest = new Cesium.Cartesian3();
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
|
||||
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
|
||||
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
|
||||
const t =
|
||||
len2 > 0
|
||||
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
|
||||
: 0;
|
||||
Cesium.Cartesian3.add(
|
||||
path[i - 1],
|
||||
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
|
||||
closest,
|
||||
);
|
||||
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const drawnCount = (fixes) => fixes.filter((fix) => fix.drawn).length;
|
||||
|
||||
test('one hour straight and level with a report every 15 s adds under 60 recorded points', () => {
|
||||
const speed = 230;
|
||||
const fixes = [];
|
||||
for (const t of [0, 15, 30])
|
||||
insertTrailFix(fixes, eastbound(speed, t), t * SEC, LIMIT);
|
||||
// Live order: a report arrives 30 s before the display reaches it, and the
|
||||
// drawn icon is recorded once a second.
|
||||
for (let t = 1; t <= 3_600; t++) {
|
||||
const reportAt = t + 30;
|
||||
if (reportAt % 15 === 0 && reportAt <= 3_600)
|
||||
insertTrailFix(fixes, eastbound(speed, reportAt), reportAt * SEC, LIMIT);
|
||||
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
|
||||
}
|
||||
assert.ok(
|
||||
drawnCount(fixes) < 60,
|
||||
`${drawnCount(fixes)} recorded points kept`,
|
||||
);
|
||||
assert.equal(fixes.filter((fix) => !fix.drawn).length, 241);
|
||||
});
|
||||
|
||||
test('with no report at all, a straight leg keeps one point per 10 km and no more', () => {
|
||||
const speed = 230;
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, eastbound(speed, 0), 0, LIMIT);
|
||||
for (let t = 1; t <= 3_600; t++)
|
||||
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
|
||||
const legKm = (speed * 3_600) / 1_000;
|
||||
assert.equal(TRAIL_THIN_MAX_SPAN_M, 10_000);
|
||||
assert.ok(
|
||||
drawnCount(fixes) <= Math.ceil(legKm / 10) + 1,
|
||||
`${drawnCount(fixes)} points over ${legKm} km`,
|
||||
);
|
||||
for (let i = 1; i < fixes.length; i++)
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(fixes[i - 1].position, fixes[i].position) <=
|
||||
TRAIL_THIN_MAX_SPAN_M + speed,
|
||||
);
|
||||
});
|
||||
|
||||
test('a standard-rate 180° turn keeps its curve: every recorded position stays within 30 m of the kept line', () => {
|
||||
const speed = 230;
|
||||
const fixes = [];
|
||||
const recorded = [];
|
||||
insertTrailFix(fixes, turning(speed, 0), 0, LIMIT);
|
||||
for (let t = 1; t <= 60; t++) {
|
||||
const position = turning(speed, t);
|
||||
recorded.push(position);
|
||||
recordTrailPoint(fixes, position, t * SEC, LIMIT);
|
||||
}
|
||||
const line = fixes.map((fix) => fix.position);
|
||||
assert.equal(TRAIL_THIN_TOLERANCE_M, 30);
|
||||
for (const position of recorded) {
|
||||
const off = distanceToPath(position, line);
|
||||
assert.ok(off <= TRAIL_THIN_TOLERANCE_M + 1, `${off.toFixed(1)} m off`);
|
||||
}
|
||||
assert.ok(drawnCount(fixes) < recorded.length, 'nothing was thinned');
|
||||
assert.ok(drawnCount(fixes) >= 6, 'the curve was flattened');
|
||||
});
|
||||
|
||||
test('a climb off the straight line keeps its vertices', () => {
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, eastbound(200, 0, 1_000), 0, LIMIT);
|
||||
// 30 s level, then a 60 s climb of 20 m/s, then level again.
|
||||
const height = (t) => 1_000 + 20 * Math.min(60, Math.max(0, t - 30));
|
||||
const recorded = [];
|
||||
for (let t = 1; t <= 150; t++) {
|
||||
const position = eastbound(200, t, height(t));
|
||||
recorded.push(position);
|
||||
recordTrailPoint(fixes, position, t * SEC, LIMIT);
|
||||
}
|
||||
const line = fixes.map((fix) => fix.position);
|
||||
for (const position of recorded)
|
||||
assert.ok(distanceToPath(position, line) <= TRAIL_THIN_TOLERANCE_M + 1);
|
||||
});
|
||||
|
||||
test('at the limit, old recorded points go before any report from the backfill', () => {
|
||||
const max = 300;
|
||||
const backfill = [];
|
||||
for (let i = 0; i < 100; i++)
|
||||
backfill.push({
|
||||
position: eastbound(200, i * 60 - 20_000),
|
||||
timeMs: (i * 60 - 20_000) * SEC,
|
||||
nullAltitude: false,
|
||||
});
|
||||
let fixes = [];
|
||||
insertTrailFix(fixes, turning(150, 0), 0, max);
|
||||
// A long series of turns keeps many recorded points.
|
||||
for (let t = 1; t <= 2_000; t++)
|
||||
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
|
||||
assert.ok(drawnCount(fixes) > 150);
|
||||
fixes = joinTrailBackfill(
|
||||
{
|
||||
fixes: backfill.map((b) => ({ ...b, breakBefore: false })),
|
||||
breakAfter: false,
|
||||
},
|
||||
fixes,
|
||||
max,
|
||||
);
|
||||
assert.ok(fixes.length <= max);
|
||||
const reports = fixes.filter((fix) => !fix.drawn);
|
||||
assert.equal(reports.length, 101, 'a report was dropped');
|
||||
// More recording at the limit drops recorded points, never reports.
|
||||
for (let t = 2_001; t <= 2_400; t++)
|
||||
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
|
||||
assert.ok(fixes.length <= max);
|
||||
assert.equal(fixes.filter((fix) => !fix.drawn).length, 101);
|
||||
// The recorded points that remain are the newest.
|
||||
const drawnTimes = fixes.filter((fix) => fix.drawn).map((fix) => fix.timeMs);
|
||||
assert.ok(Math.min(...drawnTimes) > 1_000 * SEC);
|
||||
});
|
||||
+83
-24
@@ -41,33 +41,46 @@ const DETAIL =
|
||||
/**
|
||||
* Each body is drawn in a centred frame (origin 0,0 = glyph centre), front
|
||||
* toward -Y, in the 96-unit space (half-extent up to ~45).
|
||||
*
|
||||
* Two sets. `HEADING_BODIES` is what a vehicle with a known course wears: every
|
||||
* outline has a front that cannot be mistaken for its back at fleet size — a
|
||||
* nose, a bow or a chamfered cab — because at twenty pixels a bus drawn as a
|
||||
* near-symmetric box gives no hint which way it is going, and a hairline
|
||||
* windscreen is far below one pixel. The front is part of the OUTLINE, not
|
||||
* interior ink, so it survives the tint, the halo (which strokes the outline)
|
||||
* and the sensor profiles (which keep only the outline).
|
||||
*
|
||||
* `NEUTRAL_BODIES` is what a vehicle with NO known course wears. Its billboard
|
||||
* sits at rotation 0, so a pointed glyph would claim it is heading north; these
|
||||
* outlines are the same footprint, symmetric end to end, and claim nothing.
|
||||
*/
|
||||
const BODIES = {
|
||||
// ── Bus: short, blunt, square-shouldered. The shape everything else reads
|
||||
// against, and the one most people will see most often.
|
||||
const HEADING_BODIES = {
|
||||
// ── Bus: short, broad, square-shouldered behind an arrow-like nose. The
|
||||
// shape everything else reads against, and the one most people will see
|
||||
// most often, so it carries the boldest front.
|
||||
bus: `
|
||||
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
|
||||
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
|
||||
<path d="M0,-41 L17,-25 L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28
|
||||
L-17,-25 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-24 L11,-24" ${DETAIL}/>
|
||||
<path d="M-11,-21 L11,-21" ${DETAIL}/>
|
||||
<path d="M-11,20 L11,20" ${DETAIL}/>`,
|
||||
|
||||
// ── Tram / light rail: longer and slimmer than a bus, with the two
|
||||
// articulation joints that make a modern low-floor tram unmistakable.
|
||||
// ── Tram / light rail: longer and slimmer than a bus, a short pointed cab
|
||||
// and the two articulation joints of a modern low-floor tram.
|
||||
tram: `
|
||||
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
|
||||
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
|
||||
<path d="M0,-44 L10,-33 L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38
|
||||
L-10,-33 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-10,-13 L10,-13" ${DETAIL}/>
|
||||
<path d="M-10,13 L10,13" ${DETAIL}/>
|
||||
<path d="M-8,-34 L8,-34" ${DETAIL}/>`,
|
||||
<path d="M-8,-29 L8,-29" ${DETAIL}/>`,
|
||||
|
||||
// ── Metro / subway: squared off at both ends, wider than a tram, a single
|
||||
// cab line at the front. Reads as rolling stock, not a road vehicle.
|
||||
// ── Metro / subway: squared off and wider than a tram, the cab end
|
||||
// chamfered to a narrow flat face; the rear stays square.
|
||||
subway: `
|
||||
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
|
||||
<path d="M-4,-40 L4,-40 L11.5,-30 L11.5,40 L-11.5,40 L-11.5,-30 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
|
||||
<path d="M-11.5,-26 L11.5,-26" ${DETAIL}/>
|
||||
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
|
||||
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
|
||||
|
||||
@@ -90,8 +103,49 @@ const BODIES = {
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
|
||||
|
||||
// ── Unknown: a plain rounded marker. Deliberately featureless, so an
|
||||
// ── Unknown: a plain marker with a nose and nothing else, so an
|
||||
// unclassified vehicle never pretends to be a mode it is not.
|
||||
unknown: `
|
||||
<path d="M0,-38 L10,-24 L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26
|
||||
L-10,-24 Z"
|
||||
fill="white" ${STROKE}/>`,
|
||||
};
|
||||
|
||||
const NEUTRAL_BODIES = {
|
||||
bus: `
|
||||
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
|
||||
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-20 L11,-20" ${DETAIL}/>
|
||||
<path d="M-11,20 L11,20" ${DETAIL}/>`,
|
||||
|
||||
tram: `
|
||||
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
|
||||
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-10,-13 L10,-13" ${DETAIL}/>
|
||||
<path d="M-10,13 L10,13" ${DETAIL}/>`,
|
||||
|
||||
subway: `
|
||||
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
|
||||
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
|
||||
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
|
||||
|
||||
rail: `
|
||||
<path d="M-10.5,-40 C-10.5,-43 -8.5,-44.5 0,-44.5 C8.5,-44.5 10.5,-43 10.5,-40
|
||||
L10.5,40 C10.5,43 8.5,44.5 0,44.5 C-8.5,44.5 -10.5,43 -10.5,40 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-10.5,-6 L10.5,-6" ${DETAIL}/>
|
||||
<path d="M-10.5,6 L10.5,6" ${DETAIL}/>`,
|
||||
|
||||
ferry: `
|
||||
<path d="M-12.5,-30 C-12.5,-36 -8,-38 0,-38 C8,-38 12.5,-36 12.5,-30
|
||||
L12.5,30 C12.5,36 8,38 0,38 C-8,38 -12.5,36 -12.5,30 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`,
|
||||
|
||||
unknown: `
|
||||
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
|
||||
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
|
||||
@@ -99,7 +153,7 @@ const BODIES = {
|
||||
};
|
||||
|
||||
/** The modes this module can draw. */
|
||||
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(BODIES));
|
||||
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(HEADING_BODIES));
|
||||
|
||||
const _iconCache = new Map();
|
||||
|
||||
@@ -155,8 +209,8 @@ export function transitIconCacheSize() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Data URI for one mode's silhouette, lazily built and cached per kind, size
|
||||
* and halo.
|
||||
* Data URI for one mode's silhouette, lazily built and cached per kind, size,
|
||||
* halo and whether the vehicle has a known course.
|
||||
*
|
||||
* With a halo, the body is drawn twice: first as a wide dark stroke, then as
|
||||
* the shipped white fill with its hairline edge on top, so the ring sits
|
||||
@@ -166,11 +220,14 @@ export function transitIconCacheSize() {
|
||||
*
|
||||
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
|
||||
* @param {number} [px] Raster size.
|
||||
* @param {{haloScreenPx?: number, screenPx?: number}} [options]
|
||||
* @param {{haloScreenPx?: number, screenPx?: number, style?: string,
|
||||
* heading?: boolean}} [options] `heading: false` draws the symmetric outline
|
||||
* for a vehicle whose course is unknown (its billboard is not rotated, so a
|
||||
* front would point north for no reason).
|
||||
* @returns {string} `data:image/svg+xml;base64,...`
|
||||
*/
|
||||
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
|
||||
const k = BODIES[kind] ? kind : 'unknown';
|
||||
const k = HEADING_BODIES[kind] ? kind : 'unknown';
|
||||
const selected = px > FLEET_RASTER_PX;
|
||||
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
|
||||
const profile = transitStyleProfile(
|
||||
@@ -185,16 +242,18 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
|
||||
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
|
||||
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
|
||||
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx);
|
||||
const key = `${k}:${selected ? 1 : 0}:${profile}`;
|
||||
const heading = options.heading !== false;
|
||||
const bodies = heading ? HEADING_BODIES : NEUTRAL_BODIES;
|
||||
const key = `${k}:${selected ? 1 : 0}:${profile}:${heading ? 'h' : 'n'}`;
|
||||
let uri = _iconCache.get(key);
|
||||
if (!uri) {
|
||||
const size = VIEW + 2 * frame.pad;
|
||||
// Reuse the exact outer path. Sensors fill its interior without panel ink.
|
||||
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1];
|
||||
const silhouette = bodies[k].match(/<path d="([^"]+)"/)[1];
|
||||
const body =
|
||||
profile === 'mono'
|
||||
? `<path d="${silhouette}" fill="white" />`
|
||||
: BODIES[k];
|
||||
: bodies[k];
|
||||
const halo =
|
||||
frame.units > 0
|
||||
? profile === 'mono'
|
||||
|
||||
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user