diff --git a/.env.example b/.env.example index d176acf40..44cfb5c60 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe # keep them while debugging; delete the log when you are done. GEV_VOICE_LOG_CONTENT= OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano +# Optional: Gemini Live voice, selected in the GEV MIC provider chooser. +# Server-only key. Do not prefix with VITE_. Provider Settings can save it. +GEMINI_API_KEY= +GEMINI_LIVE_MODEL=gemini-3.8-live +# Token starts per IP per minute; 0 disables the throttle, not provider quotas. +# GEV_RATELIMIT_GEMINI_PER_MIN=30 # Per-IP rate limit for the OpenAI cost endpoints # (/api/realtime/token + /api/openai/hud-summary), in requests per minute. # DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary @@ -109,7 +115,8 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -118,11 +125,13 @@ OPENSKY_AUTH_MODE=oauth OPENSKY_CLIENT_ID= OPENSKY_CLIENT_SECRET= -# Optional: higher Launch Library 2 request allowance. Public access works -# without a token. +# Launch Library 2: leave blank for standard public access (no key needed). +# Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config @@ -201,6 +210,7 @@ AISSTREAM_API_KEY= # CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams # CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL # CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras +# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc # CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511 # CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack # CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog @@ -221,6 +231,9 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras # CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today) +# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras +# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200 +# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL # CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras # CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog # CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras @@ -228,6 +241,7 @@ AISSTREAM_API_KEY= # CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras +# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead) # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL # CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement (tmcdot@dot.nyc.gov) for the camera feed # CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) @@ -237,6 +251,11 @@ AISSTREAM_API_KEY= # CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required +# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md) +# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first) +# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras +# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required +# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL # CCTV_HOUSTON_ENABLED= # set to 1 to opt in; off by default (TranStar terms) # CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first) # CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e8c9f8bdf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ +## What this changes + + + +## How I verified it + +- [ ] `npm run build` +- [ ] `npm test` +- [ ] `npm run test:track` (dev server running) +- [ ] Feature gate for the area I touched: + + + +## New layer checklist + + + +**Screenshots** + + + +**Recording** + + + +**Proposed category** + + + +Category: + +**Data source and licence** + +- Source (provider and endpoint): +- Licence or terms (link): +- Attribution shown in the app: +- Needs a key? (none / optional / required): +- Fetched at runtime, or bundled in the repo: + + + +- [ ] Added a row to `DATA_SOURCES.md` +- [ ] Credit is registered in `src/data/dataCredits.js` +- [ ] Share-link token assigned with `npm run layer-token:next`, if the layer is shareable + +## Docs + +- [ ] `docs/CURRENT-STATE.md` updated and a `changelog.d/-.md` fragment added, or this PR doesn't change runtime behaviour diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 491f2468d..ce3563685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,6 @@ jobs: timeout-minutes: 30 env: GEV_REQUIRE_ALLOCATION_GATE: '0' - # Fail on a missed header press unless the panel itself moved. - QA_FAIL_ON_RETRY: '1' steps: - name: Check out source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -104,10 +102,6 @@ jobs: - name: Street Level gate with Mapillary fixtures run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173 - - name: Portable panel gate - if: success() || failure() - run: npm run qa:panel-resize -- --url http://localhost:4173 - - name: Preview server log if: failure() run: cat preview.log diff --git a/.gitignore b/.gitignore index d2f0107c0..11e081e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ output/ .gev-cache/ # Browser QA gate evidence (screenshots, console and network dumps). qa-artifacts/ +# Playwright MCP session logs: browser consoles can carry tokens in URLs. +.playwright-mcp/ +# Local Claude Code settings and preview configs. +.claude/ .DS_Store .gstack/ 3d-models/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d32365f..f5d3a4842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ never derived: an aircraft that has not transmitted a rate reads as `null` rather than a level-flight `0`. +- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted + where it could go — https, an allowlisted host, a station feed path, no + redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted + how often, so the dev server would make unlimited requests at a third-party + bikeshare operator on any caller's behalf. The route now admits 120 + requests/minute per client address, refusing the rest with 429 and + `Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a + minute with every catalogued system in range, so ordinary use is unchanged. + - Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own licence quote and attribution. A feed can now name its own modes, so Toronto's @@ -317,9 +326,8 @@ [NotProniss](https://github.com/NotProniss) (#1084). - Street Level: Panoramax is the fourth provider, keyless. Light its chip - (orange; off by default, one provider at a time): from orbit dots show - where the federated commons has pictures, and below about 60 km its - sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a + (orange; off by default, one provider at a time): below about 60 km the + federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it and ‹ › / ← → following the picture's own links. The 360° and since filters apply, @@ -377,10 +385,10 @@ Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to open Google's own panorama in the panel, with its arrows, the address and month in the caption, a link - to Google Maps and FOLLOW. Share links carry it in the provider switches + to Google Maps. Share links carry it in the provider switches (`r.google-1`). One provider is lit at a time (lighting one switches the other off), Mapillary by default since it is free. Street View's panoramas - are all 360°, so the IMAGERY and SINCE filters and the legend apply to + are all 360°, so the IMAGERY and SINCE filters apply to Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker is a Google-blue view cone. @@ -419,21 +427,16 @@ proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding. -- Street Level: a street-level imagery layer modelled on the iD editor's photo - overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0 - imagery). One right-rail panel holds a chip per provider, shared 360°/flat and - captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and - FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit - down to a single street; on Google 3D they sit on the bare earth, refined - against the rendered mesh near the camera, so trees and buildings hide them. - Share links carry the provider switches, the filters and the panel's collapsed - state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk), - refuses cross-site requests, rate-limits per IP and backs off when Mapillary - rejects the token or rate-limits. - -- The CCTV and Street Level panels are portable: drag the header to float the - panel, resize it from any edge or corner, and double-click the header to dock - it again. The position persists across reloads. +- Street Level: street-level imagery from providers, Mapillary first (free + client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider, + 360°/flat and captured-since filters and an embedded viewer with EXPAND and + FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and + Google 3D tiles; opening a photo frames it with one camera flight. Share + links carry the provider switches, the filters and the panel's collapsed + state. A server proxy adds the token to Mapillary coverage tiles, trims and + caches them for 24 h, refuses cross-site requests and redirects, and limits + load per IP and upstream. The panel reads KEY REQUIRED without a token and + KEY REJECTED when Mapillary refuses it. - Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d6b968db..a7fe8ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,8 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i Runnable gate entrypoints document what they assert and how to invoke them. Most need a dev server; some also need a specific provider key or port. Follow the entrypoint's header rather than assuming every matching file accepts the -same arguments. Only a couple (`qa:map-source-tray`, `qa:transit`) have an -`npm run` alias. +same arguments. Four have an `npm run` alias: `qa:map-source-tray`, +`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`. If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md` for the feature: it names the gate for many of them, and it's the authoritative @@ -63,12 +63,12 @@ runtime reference either way. `scripts/qa-l9-matrix.mjs` aggregates the broader release-candidate checks and selected harnesses; it does not replace the focused gate for the feature you changed. -> **CI does not cover this for you.** The workflow runs the setup policy +> **CI covers only two of these for you.** The workflow runs the setup policy > checks, the formatting and package-boundary checks, the unit suite and the > production build, plus a Windows onboarding job. It runs neither -> `npm run test:track` nor any `qa-*.mjs` gate — both need a live dev server -> and a browser. Include the applicable local run in your PR's validation -> evidence. +> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture +> gate (`street-level-browser` job) — they need a live dev server and a +> browser. Include the applicable local run in your PR's validation evidence. ## Share-link layer tokens @@ -108,7 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. -- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). @@ -116,7 +116,7 @@ The highest-leverage places to jump in: ## Architecture in one minute - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). -- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/.js` entries retain compatibility; new code belongs with its focused owner. +- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/.js` entries retain compatibility; new code belongs with its focused owner. - Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. @@ -153,6 +153,33 @@ ownership and adoption process. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). +6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). + +## Adding a new layer + +A new layer changes what everyone sees on the globe, so maintainers need to see +it working and know where its data comes from before reviewing the code. PRs +that add a layer must include all four of these in the description. The PR +template has a section for them. + +1. **Screenshots.** At least one of the layer on the globe, plus its info card + or panel if it has one. +2. **A screen recording**, preferably 15 to 30 seconds: turn the layer on, move + around, and open a record. Drag an `.mp4` or `.mov` into the PR description. + GitHub accepts videos up to 10 MB on free accounts, so link to a larger file + instead of attaching it. +3. **The proposed category.** Say which layer-panel group the layer belongs in. + The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today: + Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer + that isn't listed there falls under "Other layers". If none of the groups + fits, propose a new one and explain why. +4. **The data source and licence.** Name the provider and endpoint, link its + licence or terms, give the attribution shown in the app, and say whether it + needs a key and whether the data is fetched at runtime or bundled. Add the + same details to [DATA_SOURCES.md](DATA_SOURCES.md) and register the credit + in `src/data/dataCredits.js`. + +Maintainers may hold a layer PR until all four are there. ## Maintainers diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 92f623deb..a106954df 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -20,10 +20,10 @@ How to read this: | **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" | | **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" | | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | -| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | | **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | -| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence | +| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | @@ -38,6 +38,7 @@ How to read this: | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **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 | | **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)" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | @@ -48,6 +49,7 @@ How to read this: | **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain | | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) | +| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) | | **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) | | **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) | | **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) | @@ -62,7 +64,9 @@ How to read this: | **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | +| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | +| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | | **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | No licence. The [TranStar disclaimer](https://traffic.houstontranstar.org/disclaimer.aspx) (Content Usage) prohibits copying or distribution, and external framing or linking to specific images, without TxDOT's prior written permission. Off unless `CCTV_HOUSTON_ENABLED=1` | "Houston TranStar" (courtesy) | @@ -70,10 +74,12 @@ How to read this: | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | | **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" | +| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` | +| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" | | **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | -| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | +| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | @@ -109,6 +115,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent. - **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400. +- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000). - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. @@ -151,7 +158,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, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. -- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; 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. +- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does. - **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms. - **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. - **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. @@ -161,16 +168,19 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. +- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. +- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. +- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover. - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. Dedicated `dir` values (North/South/East/West) become high-confidence headings; `None` uses the shared id-hash fallback at low confidence. TranStar's disclaimer is not a licence to ship the stills on by default (its FAQ grants permission-free use only for the RSS feeds), so the pack stays off unless `CCTV_HOUSTON_ENABLED=1`. Opting in loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched at request time and never stored or redistributed. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. -- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. +- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. @@ -198,6 +208,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **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) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | +| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) | +| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | | **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | @@ -270,6 +282,11 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver wiring (see below). +### Sky background (`sky/`) + +Sources, licences, the changes CC BY asks to state, hashes and build commands +are in [`sky/README.md`](src/data/local_data/sky/README.md). + ### DataSF Analysis Neighborhoods (`neighborhoods/`) `neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official diff --git a/Dockerfile b/Dockerfile index d34f4887a..433d170a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,11 @@ COPY --chown=node:node . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 +# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is +# always on the Host allowlist, and the generous start period covers Vite's +# first-run dependency optimisation. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. diff --git a/README.md b/README.md index 6c2baec79..374259333 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,15 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports cross-platform testing of the fixed installer. The launcher installs the -locked dependencies, finds a free local port, and opens the app. +locked dependencies, finds a free local port, and opens the app in your system +browser. The Server menu item keeps the launcher console available. **Tried before and installation failed?** Update Pinokio and try again. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). -**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +**Using an older launcher or opening the embedded view manually?** If the globe +is very choppy, before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by Pinokio and open the same URL in your normal hardware-accelerated browser. One Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went @@ -177,6 +179,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. +```bash +GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up +``` + +Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your +shell or the project's `.env`; an exported shell value takes precedence. You can +also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting. +Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly +disables it. The startup log confirms that no additional peers are trusted. +
Running on Linux, a NAS, or behind a reverse proxy @@ -197,6 +209,8 @@ env UID=1026 GID=100 docker compose up -d Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. +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)).
@@ -309,6 +323,10 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an ## 🎙️ Talk to It +The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential. + +Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary. + > Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms. @@ -329,6 +347,10 @@ Thirty tools, four jobs — the commands below come straight from the product's **🎥 Direct it** — drone-operator camera verbs: +“Rotate the globe” spins Earth to the right continuously; ask for left, a speed, +or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance; +camera input stops rotation. + > 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_ **🖊️ Annotate it** — a whiteboard over the real world: @@ -377,15 +399,15 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Houston (TranStar, off unless CCTV_HOUSTON_ENABLED=1). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, off unless CCTV_HOUSTON_ENABLED=1). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | -| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | +| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | -| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | @@ -421,9 +443,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. +Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. ## 🎖️ Field Missions @@ -474,7 +496,7 @@ How the globe handles live data: ``` src/ ├── main.js # Bootstrap: Google 3D tiles, layer registration -├── ui.js # Runtime UI — panels, HUD, styles, control facade +├── ui/ # Runtime UI — panels, HUD, styles, controls ├── hud.js # Intelligence HUD + AI scene summary ├── keySetup.js # POWER UP panel — in-app provider keys (dev server only) ├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks @@ -527,9 +549,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di | | Key | Why | Get it | | --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- | | 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) | -| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) | +| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) | Add these if you need higher polling allowances. +Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional +paid Patreon upgrades for higher limits; there is no free API-key signup. `npm run doctor` reports Node/npm readiness, the primary provider routes, and where each configured provider was found without printing credential values. diff --git a/SECURITY.md b/SECURITY.md index e53883548..4423eead3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,11 @@ God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. +Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and +the package checkout's root `dist/` directory. Dependency `dist/` directories +remain available. This filesystem deny list does not filter `public/` assets or +preview output; never put private artifacts in a public asset directory. + ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for anything exploitable. @@ -29,7 +34,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke 1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363). 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. -3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use. +3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use. > The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side. @@ -69,11 +74,13 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. - **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. +- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured. +- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one. - **Response-size caps and timeouts** on proxied responses. - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, 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 ``/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. +- **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 ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. 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 `One\n two'), 'One two'); +}); + +test('KML aabbggrr colors convert to rgba', () => { + assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]); + assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]); + assert.equal(kmlColor('red'), null); + assert.equal(kmlColor(''), null); +}); + +test('card text wraps on words, cuts long words and marks truncation', () => { + assert.deepEqual(wrapCardText('one two three four', 9, 4), [ + 'one two', + 'three', + 'four', + ]); + assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [ + 'abcde', + 'fghij', + 'kl', + ]); + assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']); + assert.deepEqual(wrapCardText('', 5, 2), []); + for (const line of wrapCardText('word '.repeat(200), 20, 3)) + assert.ok(line.length <= 20); +}); + +test('CamelCase field labels read as words', () => { + assert.equal(fieldLabel('OperationalStatus'), 'Operational Status'); + assert.equal(fieldLabel('Research1'), 'Research 1'); + assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity'); +}); diff --git a/src/layers/ghostMaps/source.js b/src/layers/ghostMaps/source.js new file mode 100644 index 000000000..c52ebda64 --- /dev/null +++ b/src/layers/ghostMaps/source.js @@ -0,0 +1,81 @@ +import { + readResponseBytesCapped, + readResponseJsonCapped, +} from '../../sources/httpBody.js'; +import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js'; + +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + +/** + * Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a + * push subscription that fires whenever the watcher sees a new commit. + */ +export function createGhostMapsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + EventSourceImpl = globalThis.EventSource, +} = {}) { + return { + async getCatalog({ signal } = {}) { + const response = await fetchImpl('/api/ghostmaps/catalog', { signal }); + if (!response.ok) + throw new Error( + `GhostMaps catalog unavailable (HTTP ${response.status})`, + ); + const catalog = await readResponseJsonCapped( + response, + 1024 * 1024, + signal, + ); + if (!Array.isArray(catalog?.products)) + throw new Error(msg('Malformed GhostMaps catalog')); + return catalog; + }, + + async getFile(id, version, { signal } = {}) { + const response = await fetchImpl( + `/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`, + { signal }, + ); + if (!response.ok) + throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`); + return readResponseBytesCapped( + response, + GHOSTMAPS_MAX_FILE_BYTES, + signal, + ); + }, + + /** + * Call `onCatalog(catalog)` for every catalog the server pushes, and + * `onState('open' | 'connecting' | 'closed')` as the stream changes. The + * browser's EventSource reconnects by itself. Returns an unsubscribe. + */ + subscribe(onCatalog, onState = () => {}) { + if (typeof EventSourceImpl !== 'function') { + onState('closed'); + return () => {}; + } + const events = new EventSourceImpl('/api/ghostmaps/events'); + const states = ['connecting', 'open', 'closed']; + const report = () => onState(states[events.readyState] ?? 'connecting'); + events.onopen = report; + events.onerror = report; + const listener = (event) => { + try { + const catalog = JSON.parse(event.data); + if (Array.isArray(catalog?.products)) onCatalog(catalog); + } catch { + /* ignore a malformed frame; the next one replaces it */ + } + }; + events.addEventListener('catalog', listener); + report(); + return () => { + events.removeEventListener('catalog', listener); + events.close(); + }; + }, + }; +} diff --git a/src/layers/installations/policy.js b/src/layers/installations/policy.js index fae021cf3..92da9dd7b 100644 --- a/src/layers/installations/policy.js +++ b/src/layers/installations/policy.js @@ -2,8 +2,6 @@ export const LAYER_ID = 'military-installations'; export const REQUEST_DEBOUNCE_MS = 180; -export const MAX_VIEWPORT_DEGREES = 10; - export const MAX_RENDERED = 700; export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index d55394bcc..b0dc035cb 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) { orbitMatches: layerState._orbitMatches, lastUpdate: layerState._lastUpdate, error: layerState._lastError, + stale: layerState._stale, }; }, diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs new file mode 100644 index 000000000..d09e5168b --- /dev/null +++ b/src/layers/launches/hover.test.mjs @@ -0,0 +1,232 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Event } from 'cesium'; +import { createLaunchHover } from './lifecycle.js'; + +function fakeTimers() { + const timers = new Map(); + let nextId = 1; + const api = { + setTimer(fn, ms) { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimer(id) { + timers.delete(id); + }, + pending: () => [...timers.values()], + flush() { + const due = [...timers.entries()]; + timers.clear(); + for (const [, timer] of due) timer.fn(); + }, + }; + return api; +} + +function hoverRig({ overMarker }) { + let at = 0, + picks = 0, + enabled = true, + free = true; + const timers = fakeTimers(); + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick(position) { + picks++; + return overMarker(position) ? [{ id: 'launch' }] : []; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: { hidden: false }, + pointerFree: () => free, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + return { + hover, + timers, + viewer, + cursor: () => viewer.scene.canvas.style.cursor, + picks: () => picks, + setTime: (value) => (at = value), + setEnabled: (value) => (enabled = value), + setFree: (value) => (free = value), + }; +} + +const onMarker = (position) => position?.x === 1; +const marker = () => ({ endPosition: { x: 1, y: 1 } }); +const empty = () => ({ endPosition: { x: 9, y: 9 } }); + +test('mission hover runs one trailing check for a pointer that stops on empty space', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + assert.equal(rig.cursor(), 'pointer'); + rig.setTime(50); + const movement = empty(); + rig.hover.move(movement); + // Cesium reuses its movement object; the trailing check must use a copy. + movement.endPosition.x = 1; + assert.equal(rig.picks(), 1); + assert.deepEqual( + rig.timers.pending().map((timer) => timer.ms), + [70], + ); + rig.setTime(80); + rig.hover.move(empty()); + assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time'); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), ''); + assert.equal(rig.timers.pending().length, 0); + rig.hover.destroy(); +}); + +test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + assert.equal(rig.cursor(), ''); + rig.setTime(50); + rig.hover.move(marker()); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), 'pointer'); + rig.hover.destroy(); + assert.equal(rig.cursor(), ''); +}); + +test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => { + for (const cancel of [ + (rig) => rig.hover.reset(), + (rig) => rig.hover.destroy(), + (rig) => rig.viewer.camera.moveStart.raiseEvent(), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + rig.setTime(50); + rig.hover.move(marker()); + assert.equal(rig.timers.pending().length, 1); + cancel(rig); + assert.equal(rig.timers.pending().length, 0); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + +test('mission hover trailing check rechecks eligibility before picking', () => { + for (const block of [ + (rig) => rig.setEnabled(false), + (rig) => rig.setFree(false), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + rig.setTime(50); + rig.hover.move(marker()); + block(rig); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + +test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { + let at = 0, + picks = 0, + enabled = true; + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_n, fn) => listeners.add(fn), + removeEventListener: (_n, fn) => listeners.delete(fn), + }; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: {} }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: doc, + pointerFree: () => true, + ...fakeTimers(), + }); + for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + viewer.camera.moveStart.raiseEvent(); + at = 500; + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + assert.equal(viewer.scene.canvas.style.cursor, ''); + viewer.camera.moveEnd.raiseEvent(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + doc.hidden = true; + for (const fn of listeners) fn(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + assert.equal(viewer.scene.canvas.style.cursor, ''); + doc.hidden = false; + enabled = false; + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + hover.destroy(); + assert.equal(listeners.size, 0); + assert.equal(viewer.camera.moveStart.numberOfListeners, 0); + assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); +}); + +test('mission hover preserves the cursor of a pointer-owning tool', () => { + let free = true, + picks = 0; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => true, + entityLaunchId: () => true, + documentRef: null, + pointerFree: () => free, + }); + hover.move({ endPosition: {} }); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + free = false; + viewer.scene.canvas.style.cursor = 'crosshair'; + hover.move({ endPosition: {} }); + viewer.camera.moveStart.raiseEvent(); + hover.destroy(); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'crosshair'); +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index e7eab0565..bd1529c0b 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -168,9 +168,16 @@ export function createIngestion({ async function performMissionUpdate(token) { try { ensureActiveTleLookup(token); - const payload = await source.getLaunches({ - signal: layerState._sourceController.signal, - }); + const { payload, stale } = source.getLaunchSnapshot + ? await source.getLaunchSnapshot({ + signal: layerState._sourceController.signal, + }) + : { + payload: await source.getLaunches({ + signal: layerState._sourceController.signal, + }), + stale: false, + }; const launches = parts.model.normalizeRocketLaunches(payload); if ( !layerState._enabled || @@ -214,6 +221,7 @@ export function createIngestion({ parts.panel.renderMissionPanel(); layerState._lastUpdate = Date.now(); layerState._lastError = null; + layerState._stale = stale === true; } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; diff --git a/src/layers/launches/ingestion.test.mjs b/src/layers/launches/ingestion.test.mjs new file mode 100644 index 000000000..c19cb40e4 --- /dev/null +++ b/src/layers/launches/ingestion.test.mjs @@ -0,0 +1,315 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import * as Cesium from 'cesium'; +import { createLaunchSource } from './source.js'; +import { createState } from './state.js'; +import { createModel } from './model.js'; +import { createIngestion } from './ingestion.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { layerFeedState } from '../../data/feedState.js'; + +const noop = () => {}; + +async function fixture({ legacySource = false } = {}) { + const launch = { + id: 'owned-launch', + name: 'Owned mission fixture', + net: new Date(Date.now() - 3600000).toISOString(), + status: { name: 'Launch Successful' }, + pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 }, + rocket: { payloads: [] }, + }; + const mode = { cache: 'HIT', results: [launch], hold: false }; + let arrival; + let release; + const server = http.createServer((req, res) => { + if (req.url === '/api/celestrak/active') { + res.end(''); + return; + } + assert.equal(req.url, '/api/launches'); + const send = () => { + if (res.destroyed) return; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'X-GEV-Cache': mode.cache, + }); + res.end(JSON.stringify({ results: mode.results })); + }; + if (mode.hold) { + release = send; + arrival?.(); + } else send(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const launchSource = createLaunchSource({ + fetchImpl: (url, options) => { + assert.ok(['/api/launches', '/api/celestrak/active'].includes(url)); + return fetch(new URL(url, origin), options); + }, + }); + const source = legacySource + ? { + getLaunches: launchSource.getLaunches, + getActiveTle: launchSource.getActiveTle, + } + : launchSource; + const services = { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + render: { holdContinuousRender: noop, releaseContinuousRender: noop }, + }; + const state = createState({ services }); + state._enabled = true; + state._lifecycleToken = 1; + state._dataSource = new Cesium.CustomDataSource('owned-qualification'); + const canvas = Object.assign(new EventTarget(), { + disableRootEvents: true, + onwheel: null, + style: {}, + }); + const viewer = { + dataSources: new Cesium.DataSourceCollection(), + scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, + }; + await viewer.dataSources.add(state._dataSource); + // Only presentation boundaries are inert. Source, normalization, state, + // ingestion, lifecycle, stats and feed-state policy execute unchanged. + const parts = { + panel: { + createMissionPanel: noop, + renderMissionRoster: noop, + renderMissionPanel: noop, + clearMissionRosterHover: noop, + }, + replay: { stopMissionReplay: noop }, + selection: { + setSelectedMission: noop, + focusMission: noop, + stopMissionZoomAnchor: noop, + }, + orbitRendering: { + removeMissionOrbitPrimitives: noop, + syncMissionOrbitPrimitiveVisibility: noop, + }, + overlays: { + createReplayVehicleOverlay: noop, + clearMissionOverlaySources: noop, + hideReplayVehicleOverlay: noop, + destroyReplayVehicleOverlay: noop, + }, + launchPad: { + initLaunchPadZonePrimitive: noop, + hideLaunchPadZone: noop, + destroyLaunchPadZonePrimitive: noop, + }, + rendering: { + updateMissionFrame: noop, + addLaunchEntity(record) { + state._dataSource.entities.add({ + id: record.id, + name: record.name, + position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat), + }); + }, + }, + }; + const context = { state, services, parts, source }; + parts.model = createModel(context); + parts.ingestion = createIngestion(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + const current = () => { + const stats = parts.controls.methods.getStats(); + return { + stats, + feed: layerFeedState(stats), + rendered: state._dataSource?.entities.values.map((x) => x.id) || [], + enabled: state._enabled, + }; + }; + return { + mode, + state, + source, + current, + viewer, + init: () => parts.lifecycle.methods.init(viewer), + update: () => parts.ingestion.methods.update(), + disable: () => parts.lifecycle.methods.disable(), + destroy: () => parts.lifecycle.methods.destroy(viewer), + holdArrival: () => + new Promise((resolve) => { + arrival = resolve; + }), + release: () => release?.(), + async close() { + await parts.lifecycle.methods.destroy(viewer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +test('fresh launches remain nominal through ingestion and the layer stats', async () => { + const f = await fixture(); + try { + await f.update(); + const value = f.current(); + assert.equal(value.feed, 'nominal'); + assert.equal(value.stats.count, 1); + assert.deepEqual(value.rendered, ['owned-launch']); + assert.equal(value.stats.error, null); + assert.ok(value.stats.lastUpdate); + } finally { + await f.close(); + } +}); + +test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + assert.equal((await f.source.getLaunchSnapshot()).stale, true); + await f.update(); + const value = f.current(); + assert.equal(value.stats.count, 1); + assert.deepEqual(value.rendered, ['owned-launch']); + assert.equal(value.feed, 'stale'); + } finally { + await f.close(); + } +}); + +test('a fresh recovery clears stale launch provenance', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + const stale = f.current(); + f.mode.cache = 'MISS'; + await f.update(); + const recovered = f.current(); + assert.equal(recovered.feed, 'nominal'); + assert.equal(recovered.stats.count, 1); + assert.equal(recovered.stats.error, null); + assert.equal(stale.feed, 'stale'); + } finally { + await f.close(); + } +}); + +test('an empty stale snapshot still identifies its fallback provenance', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + f.mode.results = []; + await f.update(); + assert.equal(f.current().stats.count, 0); + assert.equal(f.current().feed, 'stale'); + } finally { + await f.close(); + } +}); + +test('getLaunches-only injected sources remain supported', async () => { + const f = await fixture({ legacySource: true }); + try { + await f.update(); + assert.equal(f.current().feed, 'nominal'); + assert.deepEqual(f.current().rendered, ['owned-launch']); + } finally { + await f.close(); + } +}); + +test('disable cancels a pending stale response without committing it', async () => { + const f = await fixture(); + try { + await f.update(); + const healthy = f.current(); + f.mode.cache = 'STALE-ERROR'; + f.mode.hold = true; + const arrived = f.holdArrival(); + const pending = f.update(); + await arrived; + await f.disable(); + f.release(); + await pending; + const value = f.current(); + assert.equal(value.enabled, false); + assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate); + assert.equal(value.feed, 'nominal'); + assert.equal(f.state._sourceController.signal.aborted, true); + } finally { + await f.close(); + } +}); + +test('hiding retained stale records does not relabel them as fresh', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + await f.disable(); + assert.equal(f.current().feed, 'stale'); + assert.equal(f.current().stats.count, 1); + } finally { + await f.close(); + } +}); + +test('destroy clears the previous feed state and records', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + await f.destroy(); + const value = f.current(); + assert.equal(value.stats.count, 0); + assert.equal(value.stats.lastUpdate, null); + assert.equal(value.feed, 'nominal'); + assert.equal(value.enabled, false); + } finally { + await f.close(); + } +}); + +test('destroy followed by actual init starts a clean instance with owned handlers', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + await f.destroy(); + await f.init(); + const value = f.current(); + assert.equal(value.feed, 'nominal'); + assert.equal(value.stats.count, 0); + assert.equal(value.stats.lastUpdate, null); + assert.equal(value.stats.error, null); + assert.equal(value.enabled, false); + assert.equal(f.viewer.dataSources.length, 1); + assert.equal(f.viewer.scene.preRender.numberOfListeners, 1); + const click = f.state._clickHandler; + const move = f.state._moveHandler; + assert.ok(click instanceof Cesium.ScreenSpaceEventHandler); + assert.ok(move instanceof Cesium.ScreenSpaceEventHandler); + assert.equal( + typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK), + 'function', + ); + await f.destroy(); + assert.equal(click.isDestroyed(), true); + assert.equal(move.isDestroyed(), true); + assert.equal(f.viewer.dataSources.length, 0); + assert.equal(f.viewer.scene.preRender.numberOfListeners, 0); + } finally { + await f.close(); + } +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 58d70d34b..6ac777da1 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,6 +1,116 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +const HOVER_PICK_INTERVAL_MS = 120; + +/** + * Bound hover picking independently of precise click selection. A move inside + * the throttle window is not dropped: its position is kept and one trailing + * check runs when the window ends, so a pointer that stops is still resolved. + */ +export function createLaunchHover({ + viewer, + enabled, + entityLaunchId, + now = () => performance.now(), + documentRef = globalThis.document, + pointerFree = isPointerFree, + setTimer = globalThis.setTimeout, + clearTimer = globalThis.clearTimeout, +}) { + let moving = false; + let lastPick = -Infinity; + let ownsCursor = false; + let pendingPosition = null; + let trailingTimer = null; + const clearCursor = () => { + if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') + viewer.scene.canvas.style.cursor = ''; + ownsCursor = false; + }; + const cancelTrailing = () => { + if (trailingTimer !== null) clearTimer(trailingTimer); + trailingTimer = null; + pendingPosition = null; + }; + const reset = () => { + cancelTrailing(); + clearCursor(); + lastPick = -Infinity; + }; + const start = () => { + moving = true; + reset(); + }; + const end = () => { + moving = false; + reset(); + }; + const visibility = () => { + if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') + reset(); + }; + const eligible = () => + enabled() && + !moving && + !documentRef?.hidden && + documentRef?.visibilityState !== 'hidden' && + pointerFree(); + const pick = (position, at) => { + lastPick = at; + const found = viewer.scene + .drillPick(position, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }; + const trailing = () => { + trailingTimer = null; + const position = pendingPosition; + pendingPosition = null; + if (!position) return; + // Eligibility may have changed while the check waited. + if (!eligible()) { + reset(); + return; + } + pick(position, now()); + }; + const removeStart = viewer.camera?.moveStart?.addEventListener(start); + const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); + documentRef?.addEventListener?.('visibilitychange', visibility); + return { + move(movement) { + if (!eligible()) { + reset(); + return; + } + const at = now(); + const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick); + if (wait > 0) { + // Cesium reuses the movement object, so keep a copy of the position. + pendingPosition = Cesium.Cartesian2.clone( + movement.endPosition, + pendingPosition ?? undefined, + ); + if (trailingTimer === null) trailingTimer = setTimer(trailing, wait); + return; + } + cancelTrailing(); + pick(movement.endPosition, at); + }, + reset, + destroy() { + reset(); + removeStart?.(); + removeEnd?.(); + documentRef?.removeEventListener?.('visibilitychange', visibility); + }, + }; +} + export function createLifecycle({ state: layerState, services, @@ -8,6 +118,7 @@ export function createLifecycle({ source, }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + let hover = null; const methods = { init(viewer) { @@ -20,6 +131,7 @@ export function createLifecycle({ viewer.dataSources.add(layerState._dataSource); layerState._count = 0; layerState._lastUpdate = null; + layerState._stale = false; layerState._lastError = null; layerState._orbitMatches = 0; parts.overlays.clearMissionOverlaySources(); @@ -44,14 +156,15 @@ export function createLifecycle({ layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( viewer.scene.canvas, ); - layerState._moveHandler.setInputAction((movement) => { - if (!layerState._enabled || !layerState._dataSource?.show) return; - const missionEntity = viewer.scene - .drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => parts.selection.entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + hover = createLaunchHover({ + viewer, + enabled: () => layerState._enabled && layerState._dataSource?.show, + entityLaunchId: parts.selection.entityLaunchId, + }); + layerState._moveHandler.setInputAction( + hover.move, + Cesium.ScreenSpaceEventType.MOUSE_MOVE, + ); // Apply horizon visibility before Cesium traverses and picks the scene. // A postRender write is one frame late and can remain visibly stale when // request-on-demand rendering stops after a globe camera move. @@ -99,6 +212,7 @@ export function createLifecycle({ }, async disable() { + hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; releaseContinuousRender('rocket-launches'); @@ -121,6 +235,8 @@ export function createLifecycle({ }, async destroy(viewer) { + hover?.destroy(); + hover = null; layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; @@ -160,6 +276,7 @@ export function createLifecycle({ layerState._count = 0; layerState._orbitMatches = 0; layerState._lastUpdate = null; + layerState._stale = false; layerState._activeTleText = null; layerState._activeTlePromise = null; layerState._activeTlePromiseToken = 0; diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 0a9dd67ea..5697e07e8 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -10,6 +10,9 @@ export const UNKNOWN_LAUNCH_STATUS = msg('Unknown'); export function createModel({ state: layerState, services, parts, source }) { function finiteCoordinate(value) { + // Number(null), Number('') and Number(false) are 0: absent is not zero. + if (value == null || typeof value === 'boolean') return null; + if (typeof value === 'string' && value.trim() === '') return null; const number = Number(value); return Number.isFinite(number) ? number : null; } @@ -184,15 +187,11 @@ export function createModel({ state: layerState, services, parts, source }) { const pad = launch.pad || {}; const location = pad.location || {}; const coordinates = location.coordinates || ''; - const [coordinateLon, coordinateLat] = String(coordinates) - .split(',') - .map(Number); - const lat = Number.isFinite(Number(pad.latitude)) - ? Number(pad.latitude) - : coordinateLat; - const lon = Number.isFinite(Number(pad.longitude)) - ? Number(pad.longitude) - : coordinateLon; + const [coordinateLon, coordinateLat] = String(coordinates).split(','); + const lat = + finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat); + const lon = + finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon); const payloads = normalizePayloadFlights(launch); return { id: String( diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 60e6e0070..6379e78e1 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -109,7 +109,7 @@ export function createRendering({ ); }); if (satelliteTrack) layerState._orbitMatches++; - const entity = layerState._dataSource.entities.add({ + layerState._dataSource.entities.add({ id: `rocket-launch:${launch.id}`, position, point: { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 0f0194221..d70be5b54 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,8 @@ export function createState({ services }) { state._lastError = null; + state._stale = false; + state._orbitMatches = 0; state._clickHandler = null; diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index 1b0122925..c1c923a8c 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -152,7 +152,7 @@ export function localAdsbCardModel( t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { altitude: grouped(altitude), speed: speed === null ? DASH : speed, - track: track === null ? DASH : `${track}°`, + track: track === null ? DASH : `${track % 360}°`, }), vertical === null ? t('V/S {rate}', { rate: DASH }) diff --git a/src/layers/localAdsb/card.test.mjs b/src/layers/localAdsb/card.test.mjs new file mode 100644 index 000000000..ec53024da --- /dev/null +++ b/src/layers/localAdsb/card.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../../i18n/index.js'; +import { localAdsbCardModel } from './card.js'; + +test('a track of 359.7° reads TRK 0°, not TRK 360°', () => { + setLocale('en'); + const { details } = localAdsbCardModel( + { + icao: 'abc123', + altitudeFt: 12500, + groundSpeedKt: 240, + trackDeg: 359.7, + lastPositionAt: 1000, + }, + 5000, + ); + assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°'); +}); diff --git a/src/layers/localAdsb/feeds.js b/src/layers/localAdsb/feeds.js index 4bb4bced7..5899729af 100644 --- a/src/layers/localAdsb/feeds.js +++ b/src/layers/localAdsb/feeds.js @@ -5,12 +5,12 @@ import { /** Same-origin route served by `server/providers/local-receivers.js`. */ export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft'; -export const LOCAL_RECEIVER_POLL_MS = 1_000; +const LOCAL_RECEIVER_POLL_MS = 1_000; /** * Browser-side deadline for one route request, body included. The server * bounds each upstream read at 2 s; this bounds the browser-to-server leg. */ -export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; +const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; function abortError() { const error = new Error('Local receiver request aborted'); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..4db1e2852 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -36,18 +36,8 @@ import { } from './policy.js'; import { localAdsbStatus } from './status.js'; -export { - LAYER_ID as LOCAL_ADSB_LAYER_ID, - HEARD_BY_RECEIVER, -} from './policy.js'; -export { - localAdsbCardModel, - localAdsbClassLine, - localAdsbIsUat, - localAdsbReceiverLine, - localAdsbTitle, -} from './card.js'; -export { localAdsbClass } from './enrichment.js'; +export { HEARD_BY_RECEIVER } from './policy.js'; +export { localAdsbCardModel, localAdsbReceiverLine } from './card.js'; export { localAdsbStatus } from './status.js'; const FEET_TO_METERS = 0.3048; diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 50cebe07e..55fc2d11a 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -14,6 +14,7 @@ import { MODEL_PROX_ADD_M, MODEL_PROX_KEEP_M, } from '../flights/policy.js'; +import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; const specKey = (spec) => `${spec.url}@${spec.scale}`; @@ -87,6 +88,7 @@ export function createLocalAdsbModels({ model = await loadModel({ url: resolveAsset(spec.url), asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, color, diff --git a/src/layers/localAdsb/motion.js b/src/layers/localAdsb/motion.js index 279bef6d3..084f3aa7e 100644 --- a/src/layers/localAdsb/motion.js +++ b/src/layers/localAdsb/motion.js @@ -26,7 +26,7 @@ import { export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000; export const LOCAL_ADSB_HISTORY_POINTS = 600; /** A display correction larger than this snaps instead of sliding. */ -export const LOCAL_ADSB_SNAP_M = 3_000; +const LOCAL_ADSB_SNAP_M = 3_000; /** Chord course uses the newest fix at least this much older than the anchor. */ const CHORD_BASELINE_MS = 2_000; /** Turn rate is estimated over this window of heard fixes. */ diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index d264c5e15..2e084cffb 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -24,7 +24,7 @@ function plural(count, one, many) { * @param {object[]} feeds Feed statuses from the route. * @returns {string} */ -export function describeFeedProblems(feeds) { +function describeFeedProblems(feeds) { const byStatus = new Map(); for (const feed of feeds) { if (feed?.status === 'live') continue; @@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) { } /** Whether the server reported at least one configured feed. */ -export function feedsConfigured(feedState) { +function feedsConfigured(feedState) { return Boolean( feedState?.configured === true && Array.isArray(feedState.feeds) && diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index f375e5419..bb1d89127 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -1,10 +1,3 @@ -export { - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './recordPolicy.js'; import * as Cesium from 'cesium'; import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 9c106e927..d23ad621a 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -366,7 +366,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 089ae9ec8..504d79c80 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -53,6 +53,7 @@ import { ROTATION_REFRESH_MS, COURSE_MAX_DPS, } from './policy.js'; +import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; export function createRendering({ flightState, @@ -473,6 +474,7 @@ export function createRendering({ model = await Cesium.Model.fromGltfAsync({ url: resolveAsset(spec.url), asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), @@ -676,6 +678,7 @@ export function createRendering({ Cesium.Model.fromGltfAsync({ url: resolveAsset(trackedSpec.url), asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: TRACKED_MODEL_MIN_PX, scale: trackedSpec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 6d619bf72..9de25db1d 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({ // If this is the tracked aircraft, update label text // (position updates automatically via dead-reckoning CallbackProperty) if (isTracked && flightState._trackedEntity) { - const info = flightState.records.data.get(icao24); tracking._updateTrackedLabelModel(icao24); } } diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8a440a54f..8c9723176 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; function _emitAwarenessEvent(type, detail) { if ( @@ -593,7 +593,7 @@ export function createTracking({ } /** - * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are + * Fire-and-forget optional source history backfill (PRD F2). Trace points are * stride-thinned to the remaining vertex budget, then spliced strictly older * than the oldest seeded fix AHEAD of the locally accumulated fine segment, * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ @@ -1079,7 +1079,9 @@ export function createTracking({ // first-person reference. Empty globe clicks are inert until the user // exits with C, Escape, or the dedicated button. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself -- ignore (don't deselect) diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 869b8d403..79f348469 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js'; import { RADIO_DIRECTORY_STALE_MS, RADIO_DIRECTORY_FUTURE_SKEW_MS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './policy.js'; // Directory errors stay English in layer state and stats; the layer row and @@ -64,17 +65,24 @@ export function createIngestion({ typeof body.degraded !== 'boolean' ) throw new Error('Radio directory freshness metadata was malformed'); + // Rows the server skipped as structurally malformed. Older envelopes + // omit the field; a present value must be a bounded count. + const serverRejectedStationCount = + body.rejectedStationCount === undefined + ? 0 + : body.rejectedStationCount; + if ( + !Number.isSafeInteger(serverRejectedStationCount) || + serverRejectedStationCount < 0 || + serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX + ) + throw new Error('Radio directory diagnostic metadata was malformed'); const rows = body.stations; const acceptedRows = rows.filter( parts.catalogModel.isValidRadioDirectoryStation, ); if (!acceptedRows.length) throw new Error('Radio directory returned no usable stations'); - if (acceptedRows.length !== rows.length) { - throw new Error( - 'Radio directory response contained malformed stations', - ); - } const acceptedGeneration = body.acceptedGeneration; if ( acceptedGeneration !== null && @@ -147,7 +155,15 @@ export function createIngestion({ } if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; } - layerState._degraded = body.degraded; + // Combine server-side structural rejections with rows this client + // refused. The count is diagnostic: it marks coverage degraded but does + // not undo the generation admitted above. + layerState._rejectedStationCount = Math.min( + RADIO_REJECTED_STATION_COUNT_MAX, + serverRejectedStationCount + rows.length - acceptedRows.length, + ); + layerState._degraded = + body.degraded || layerState._rejectedStationCount > 0; layerState._stale = body.stale; layerState._error = preservingWarmCatalog ? ERROR_REFRESH_DEGRADED diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index 414cfa5c0..45d66892e 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -108,6 +108,7 @@ export function createLifecycle({ layerState._loading = false; layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; layerState._updatedAt = null; layerState._managerLifecyclePresentation = null; diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 85777ed74..e0438c780 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -10,7 +10,7 @@ export const MARKER_LIFT_M = 2.5; export const SELECTED_LIFT_M = 5; -export const RADIO_PICK_TOLERANCE_PX = 8; +const RADIO_PICK_TOLERANCE_PX = 8; export const RADIO_TUNER_DIRECTORY_LIMIT = 750; @@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; +// Bound for the server's `rejectedStationCount` envelope field and the +// combined client counter; matches server/providers/radio/constants.js. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; + export const RADIO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js index 5d6763ca4..c30889018 100644 --- a/src/layers/radio/presentation.js +++ b/src/layers/radio/presentation.js @@ -20,6 +20,7 @@ export function createPresentation({ loading: layerState._loading, stale: layerState._stale, degraded: layerState._degraded, + rejectedStationCount: layerState._rejectedStationCount, error: layerState._error, updatedAt: layerState._updatedAt, filter: layerState._filter, diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 36cbc5050..9b4e6b387 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -259,7 +259,7 @@ export function createRendering({ layerState._horizonScanCount += 1; const occluder = horizonOccluder(layerState._viewer.camera); let visibilityChanged = false; - for (const [id, record] of layerState._renderById) { + for (const record of layerState._renderById.values()) { const matches = parts.categories.stationMatchesRadioCategory( record.station, layerState._filter, diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index dca461e03..918ebb989 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -25,6 +25,7 @@ export function createState({ services }) { layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 5b2b5ce39..5c18d8b3f 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -3,7 +3,12 @@ * day's readout. The model (`model.js`) stays English and i18n-free because AI * tool services import it outside the browser; this module is page-only. */ -import { formatDate, formatRelativeTime, t } from '../../i18n/index.js'; +import { + formatDate, + formatRelativeTime, + getLocale, + t, +} from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { dayTime } from './model.js'; @@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm'); /** The readout's words in the page's language; the model keeps them English. */ export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), - daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'), + daysAgo: (delta) => { + const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); + return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text; + }, // "Sentinel-2" never breaks at its hyphen: a word joiner follows it // (JetBrains Mono has no U+2011 non-breaking hyphen). sensor: (sensor) => diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 406dc3e19..3fbcf1123 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -300,7 +300,7 @@ export function hhmm(iso) { } /** Candidate key: `PRODUCT:YYYY-MM-DD`. */ -export function candidateKey(product, day) { +function candidateKey(product, day) { return `${product}:${day}`; } diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 25eaf9b2c..097d9e3be 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { twoline2satrec } from 'satellite.js'; +import { noradIdFromSatnum } from '../../sources/tle.js'; import { DENSE_REFRESH_FRAMES, DENSE_GROUP_PATH, @@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entry = entries[i]; const satrec = twoline2satrec(entry.line1, entry.line2); if (!satrec || satrec.error !== 0) continue; - const noradId = Number(satrec.satnum); + const noradId = noradIdFromSatnum(satrec.satnum); if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority const pos = parts.orbits.propagatePosition(satrec, now); if (!pos) continue; @@ -138,6 +139,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { }); layerState._points.set(noradId, point); layerState._denseIds.push(noradId); + layerState._denseStale ||= Boolean(res.stale); added++; } // Yield to the event loop between chunks. @@ -225,6 +227,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._catalog.delete(noradId); } layerState._denseIds = []; + layerState._denseStale = false; layerState._denseCursor = 0; layerState._count = layerState._points.size; layerState._catalogRevision++; diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 9a56daac3..f3327b332 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -545,16 +545,19 @@ export function createControls({ state: layerState, services, parts, source }) { }, getStats() { + const stale = Boolean(layerState._coreStale || layerState._denseStale); return { count: layerState._count, lastUpdate: layerState._lastUpdate, - stale: false, + stale, status: layerState._lastError === 'CelesTrak unreachable' ? 'unavailable' : layerState._lastError ? 'degraded' - : 'nominal', + : stale + ? 'stale' + : 'nominal', error: layerState._lastError, }; }, diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7284cdd8c..01653611c 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -1,6 +1,7 @@ import { twoline2satrec } from 'satellite.js'; import * as Cesium from 'cesium'; import { msg } from '../../i18n/index.js'; +import { noradIdFromSatnum } from '../../sources/tle.js'; import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; export function createIngestion({ @@ -35,7 +36,12 @@ export function createIngestion({ if (!res.ok) return { ...groupDef, entries: [], ok: false }; const entries = parts.orbits.parseTLE(res.text); updateSignal.throwIfAborted(); - return { ...groupDef, entries, ok: entries.length > 0 }; + return { + ...groupDef, + entries, + ok: entries.length > 0, + stale: Boolean(res.stale), + }; } catch (error) { if (updateSignal.aborted || error?.name === 'AbortError') throw error; @@ -90,6 +96,7 @@ export function createIngestion({ // catalog or those labels stay stale for the life of the session. layerState._detectionObjects.clear(); layerState._denseIds = []; + layerState._denseStale = false; layerState._denseCursor = 0; layerState._denseLoadController?.abort(); layerState._denseLoadController = null; @@ -101,17 +108,19 @@ export function createIngestion({ // Process all TLE entries in CATALOG_GROUPS order const allEntries = []; for (const r of results) { - for (const e of r.entries) allEntries.push({ ...e, group: r.tag }); + for (const e of r.entries) + allEntries.push({ ...e, group: r.tag, stale: r.stale }); } // Deduplicate by NORAD ID — first (most specific) group tag wins const seen = new Set(); + let coreStale = false; for (const entry of allEntries) { const satrec = twoline2satrec(entry.line1, entry.line2); if (!satrec || satrec.error !== 0) continue; - const noradId = Number(satrec.satnum); + const noradId = noradIdFromSatnum(satrec.satnum); if (seen.has(noradId)) continue; seen.add(noradId); @@ -122,6 +131,8 @@ export function createIngestion({ group: entry.group, }); + coreStale ||= entry.stale; + // Propagate initial position const pos = parts.orbits.propagatePosition(satrec, now); if (!pos) continue; @@ -156,6 +167,7 @@ export function createIngestion({ parts.labels._syncIssOverlay(); } + layerState._coreStale = coreStale; layerState._count = layerState._points.size; layerState._catalogRevision++; layerState._lastUpdate = Date.now(); diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs new file mode 100644 index 000000000..313d999ee --- /dev/null +++ b/src/layers/satellites/ingestion.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseTleText } from '../../sources/tle.js'; +import { createIngestion } from './ingestion.js'; + +// The ISS set (1 January 2024) with its catalog field swapped per satellite. +const ISS_LINE1 = + '1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993'; +const ISS_LINE2 = + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323'; + +function tleSet(name, field) { + return [ + name, + ISS_LINE1.replace('25544', field), + ISS_LINE2.replace('25544', field), + ].join('\n'); +} + +function ingestionFor(catalogText) { + const state = { + _trackingRefreshEpoch: 0, + _activeUpdateControllers: new Set(), + _pointCollection: { + removeAll() {}, + add: (point) => point, + }, + _points: new Map(), + _orbitPaths: new Map(), + _catalog: new Map(), + _detectionObjects: new Map(), + _denseLoadToken: 0, + _catalogRevision: 0, + _params: { catalog: 'core', showOrbits: false }, + }; + const parts = { + orbits: { + parseTLE: parseTleText, + propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }), + }, + labels: { _syncIssOverlay() {} }, + controls: { _pointStyleFor: () => ({}) }, + rendering: { _showOrbitPath() {} }, + tracking: { + async _reconcileTrackedSubjectContext() {}, + _applyPendingTrackingRestore() {}, + }, + catalog: {}, + }; + const source = { + async readGroup() { + return { ok: true, text: catalogText }; + }, + }; + const { methods } = createIngestion({ state, services: {}, parts, source }); + return { state, update: () => methods.update({ scene: {} }) }; +} + +test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const { state, update } = ingestionFor( + [ + tleSet('FIRST', 'A0000'), + tleSet('SECOND', 'B0001'), + tleSet('THIRD', 'Z9999'), + ].join('\n'), + ); + await update(); + assert.deepEqual( + [...state._catalog].map(([id, entry]) => [id, entry.name]), + [ + [100000, 'FIRST'], + [110001, 'SECOND'], + [339999, 'THIRD'], + ], + ); + assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]); + assert.equal(state._count, 3); +}); diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 7c0d36670..642a6879f 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -24,6 +24,8 @@ export function createLifecycle({ layerState._orbitPaths = new Map(); layerState._count = 0; layerState._lastUpdate = null; + layerState._coreStale = false; + layerState._denseStale = false; layerState._trackedNorad = null; parts.tracking._cancelPendingTrackingRestore(); layerState._trackedEntity = null; @@ -162,6 +164,8 @@ export function createLifecycle({ layerState._rowControlsListener = null; layerState._count = 0; layerState._lastUpdate = null; + layerState._coreStale = false; + layerState._denseStale = false; layerState._lastError = null; layerState._lastFocusUpdate = 0; layerState._activeFocusCount = 0; diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 32bb57cf9..0e4461e85 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -8,7 +8,7 @@ import { twoline2satrec, } from 'satellite.js'; import { findNextSatellitePass } from '../../data/satellitePass.js'; -import { parseTleText } from '../../sources/tle.js'; +import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js'; import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js'; export function createOrbits({ state: layerState, services, parts, source }) { @@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { - noradId: Number(satrec.satnum), + noradId: noradIdFromSatnum(satrec.satnum), name: String(name || '').trim(), current, periodSec: orbitalPeriodSeconds(satrec), diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 656374fc6..e7ea5456d 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -94,9 +94,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4; * attribute, so classification costs nothing per frame. */ -export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); +const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); -export const _classColor = (group) => +const _classColor = (group) => Cesium.Color.fromCssColorString(satelliteClassColor(group)); export const POINT_STYLES = { diff --git a/src/layers/satellites/stale.test.mjs b/src/layers/satellites/stale.test.mjs new file mode 100644 index 000000000..89b4d2dbf --- /dev/null +++ b/src/layers/satellites/stale.test.mjs @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { EventEmitter, once } from 'node:events'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createSatelliteSource } from './source.js'; +import { createState } from './state.js'; +import { createControls } from './controls.js'; +import { createOrbits } from './orbits.js'; +import { createLabels } from './labels.js'; +import { createTracking } from './tracking.js'; +import { createRendering } from './rendering.js'; +import { createCatalog } from './catalog.js'; +import { createLifecycle } from './lifecycle.js'; +import { createInteraction } from './interaction.js'; +import { createIngestion } from './ingestion.js'; +import { layerFeedState } from '../../data/feedState.js'; + +const GROUPS = [ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', +]; + +function tle(id, invalid = false) { + // Owned synthetic catalog IDs over one valid element set; never live requests. + const motion = invalid ? ' 0.00000000' : '15.48899044'; + return `OWNED ORBIT FIXTURE ${id}\n1 ${id}U 98067A 26289.78971908 .00007803 00000+0 15012-3 0 9995\n2 ${id} 51.6326 58.3278 0006748 271.9059 88.1156 ${motion}590607`; +} + +async function fixture(t) { + const policy = { + stale: new Set(), + invalid: new Set(), + ids: {}, + status: 200, + hold: false, + }; + const held = new EventEmitter(); + let heldCount = 0; + const server = http.createServer((req, res) => { + const group = req.url.split('/').at(-1); + if (policy.hold) { + held.emit('request', ++heldCount); + return; + } + res.writeHead(policy.status, { + 'Content-Type': 'text/plain', + 'X-TLE-Cache': policy.stale.has(group) ? 'STALE-ERROR' : 'MISS', + }); + res.end( + tle( + policy.ids[group] ?? 25545 + GROUPS.indexOf(group), + policy.invalid.has(group), + ), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const source = createSatelliteSource({ + fetchImpl: (path, options) => + fetch(`http://127.0.0.1:${server.address().port}${path}`, options), + }); + // Application host services and native EventTargets; actual Cesium collections, + // input handler, events, SGP4, ingestion and lifecycle run without a WebGL scene. + const noop = () => {}; + const services = { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + layerState: { isExplicitLayerStateOrigin: () => false }, + focus: { + clearFocusTarget: noop, + publishFocusTargetFromCachedPosition: noop, + getFocusTarget: () => null, + }, + context: { + clearTrackedSubjectContext: noop, + getContextStore: () => ({ entities: new Map() }), + refreshTrackedSubjectContext: noop, + selectTrackedSubjectContext: noop, + }, + readout: { refreshTrackedReadout: noop }, + render: { holdContinuousRender: noop, releaseContinuousRender: noop }, + picking: { registerPickOwner: noop, unregisterPickOwner: noop }, + }; + const previousDocument = globalThis.document; + globalThis.document = new EventTarget(); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + for (const [name, factory] of Object.entries({ + controls: createControls, + orbits: createOrbits, + labels: createLabels, + tracking: createTracking, + rendering: createRendering, + catalog: createCatalog, + interaction: createInteraction, + lifecycle: createLifecycle, + ingestion: createIngestion, + })) { + parts[name] = factory(context); + } + const viewer = { + scene: { + canvas: new EventTarget(), + primitives: new Cesium.PrimitiveCollection(), + preRender: new Cesium.Event(), + }, + trackedEntityChanged: new Cesium.Event(), + }; + await parts.lifecycle.methods.init(viewer); + t.after(async () => { + if (state._pointCollection) parts.lifecycle.methods.destroy(viewer); + viewer.scene.primitives.destroy(); + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + return { + state, + parts, + viewer, + policy, + update: (options) => parts.ingestion.methods.update(viewer, options), + stats: () => parts.controls.methods.getStats(), + feed: () => layerFeedState(parts.controls.methods.getStats()), + async waitHeld(count) { + while (heldCount < count) + await once(held, 'request', { signal: AbortSignal.timeout(3000) }); + }, + }; +} + +test('stale core records surface through actual HTTP, ingestion and feed state, then recover', async (t) => { + const f = await fixture(t); + await f.update(); + assert.equal(f.feed(), 'nominal'); + assert.equal(f.stats().count, 6); + f.policy.stale = new Set(GROUPS.slice(0, 6)); + await f.update(); + assert.equal(f.stats().count, 6); + assert.equal(f.stats().stale, true); + assert.equal(f.feed(), 'stale'); + f.policy.stale = new Set(['visual']); + await f.update(); + assert.equal(f.feed(), 'stale'); + f.policy.stale.clear(); + await f.update(); + assert.equal(f.stats().stale, false); + assert.equal(f.feed(), 'nominal'); +}); + +test('a stale duplicate cannot taint its earlier fresh winning record', async (t) => { + const f = await fixture(t); + f.policy.stale.add('visual'); + f.policy.ids.visual = 25545; + await f.update(); + assert.equal(f.stats().count, 5); + assert.equal(f.state._catalog.get(25545).group, 'stations'); + assert.equal(f.stats().stale, false); + assert.equal(f.feed(), 'nominal'); + f.policy.stale = new Set(['stations']); + await f.update(); + assert.equal(f.stats().count, 5); + assert.equal( + f.stats().stale, + true, + 'a later fresh duplicate does not replace the stale winner', + ); +}); + +test('invalid stale orbital elements are not admitted as provenance', async (t) => { + const f = await fixture(t); + f.policy.stale.add('visual'); + f.policy.invalid.add('visual'); + await f.update(); + assert.equal(f.stats().count, 5); + assert.equal(f.state._catalog.has(25546), false); + assert.equal(f.stats().stale, false); +}); + +test('failed and aborted core refreshes keep provenance of retained records', async (t) => { + const f = await fixture(t); + f.policy.stale.add('stations'); + await f.update(); + const priorTime = f.stats().lastUpdate; + f.policy.status = 503; + await f.update(); + assert.equal(f.stats().count, 6); + assert.equal(f.stats().stale, true); + assert.equal(f.stats().status, 'unavailable'); + assert.equal(f.stats().lastUpdate, priorTime); + f.policy.hold = true; + const controller = new AbortController(); + const work = f.update({ signal: controller.signal }); + await f.waitHeld(6); + controller.abort(); + await assert.rejects(work, { name: 'AbortError' }); + assert.equal(f.stats().count, 6); + assert.equal(f.stats().stale, true); + assert.equal(f.stats().lastUpdate, priorTime); + assert.equal(f.state._activeUpdateControllers.size, 0); +}); + +test('dense provenance belongs to its admitted records and clears on removal', async (t) => { + const f = await fixture(t); + await f.update(); + f.policy.stale.add('starlink'); + f.state._params.catalog = 'dense'; + assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready'); + assert.equal(f.stats().count, 7); + assert.equal(f.feed(), 'stale'); + f.state._params.catalog = 'core'; + f.parts.catalog._removeDenseCatalog(); + assert.equal(f.stats().count, 6); + assert.equal(f.feed(), 'nominal'); + f.policy.stale = new Set(['stations']); + await f.update(); + f.state._params.catalog = 'dense'; + await f.parts.catalog._loadDenseCatalog(); + f.state._params.catalog = 'core'; + f.parts.catalog._removeDenseCatalog(); + assert.equal( + f.feed(), + 'stale', + 'removing dense data must not clear core provenance', + ); +}); + +test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => { + const f = await fixture(t); + await f.update(); + f.policy.stale.add('starlink'); + f.state._params.catalog = 'dense'; + await f.parts.catalog._loadDenseCatalog(); + f.policy.hold = true; + const controller = new AbortController(); + const work = f.parts.catalog._loadDenseCatalog({ signal: controller.signal }); + await f.waitHeld(1); + controller.abort(); + assert.equal((await work).status, 'cancelled'); + assert.equal(f.stats().count, 7); + assert.equal(f.feed(), 'stale'); + f.policy.hold = false; + f.policy.stale.clear(); + f.state._params.catalog = 'core'; + await f.update(); + assert.equal(f.stats().count, 6); + assert.equal(f.feed(), 'nominal'); +}); + +test('destroy aborts an in-flight response and initialization starts with new ownership', async (t) => { + const f = await fixture(t); + f.policy.stale.add('stations'); + await f.update(); + f.policy.hold = true; + const work = f.update(); + await f.waitHeld(6); + const rejected = assert.rejects(work, { name: 'AbortError' }); + f.parts.lifecycle.methods.destroy(f.viewer); + assert.equal(f.stats().count, 0); + assert.equal(f.stats().stale, false); + await f.parts.lifecycle.methods.init(f.viewer); + await rejected; + assert.equal(f.stats().stale, false); + f.policy.hold = false; + f.policy.stale.clear(); + await f.update(); + assert.equal(f.stats().count, 6); + assert.equal(f.feed(), 'nominal'); +}); diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 106802ba6..50a127b54 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -40,6 +40,10 @@ export function createState({ services }) { state._lastError = null; + // Cache provenance belongs to admitted core and dense records separately. + state._coreStale = false; + state._denseStale = false; + state._activeUpdateControllers = new Set(); state._denseLoadController = null; diff --git a/src/layers/streetLevel/cameraFollow.js b/src/layers/streetLevel/cameraFollow.js deleted file mode 100644 index 09733e172..000000000 --- a/src/layers/streetLevel/cameraFollow.js +++ /dev/null @@ -1,209 +0,0 @@ -import * as Cesium from 'cesium'; -import { FOLLOW_EYE_HEIGHT_M } from './policy.js'; - -/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */ -const SURFACE_MIN_M = -500; -const SURFACE_MAX_M = 9000; - -/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */ -export function createCameraFollow({ state, parts }) { - const { render } = state.services; - - function requestRender() { - render?.governorRequestRender?.('street-level-follow'); - } - - /** - * The application's camera authority (`attachNavigation`): `run(noun, move)` - * releases tracking and other owners before `move`, or refuses (cockpit); - * `begin(noun)` takes a deferred ticket (a generation, or false) that - * `reassert(generation)` honours only if nothing newer took the camera; - * `subscribeHandoff` reports a newer owner. Without it, moves run directly. - */ - let navigation = null; - let unsubscribeHandoff = null; - /** Set while our own claim stamps a handoff, so it does not end FOLLOW. */ - let claiming = false; - - /** Take the camera for `move`; false when the application refuses. */ - function claim(move) { - if (!navigation?.run) { - move(); - return true; - } - claiming = true; - try { - return ( - navigation.run('photo', () => { - move(); - return true; - }) === true - ); - } finally { - claiming = false; - } - } - - /** - * Ask for the camera as a photo starts opening. The ticket goes to - * `lookAtPosition`; null when the application refuses (cockpit). - */ - function beginFraming() { - if (!navigation?.begin || !navigation.reassert) return { generation: null }; - claiming = true; - try { - const generation = navigation.begin('photo'); - return generation === false ? null : { generation }; - } finally { - claiming = false; - } - } - - /** Another feature took the camera: stop following it. */ - function onHandoff() { - if (claiming || !state.street.follow) return; - state.street.follow = false; - state.notify?.(); - } - - function attachNavigation(next) { - unsubscribeHandoff?.(); - navigation = next || null; - unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null; - } - - /** - * Ground under a photo. `sampleHeight` can return kilometres below the - * ellipsoid before tiles load, so a sample counts only when plausible and - * within the mesh window around bare earth (once that is known). - */ - function groundHeightAt(lon, lat, fallback) { - const scene = state.viewer?.scene; - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const caster = parts?.groundCaster; - const dem = caster?.groundAt(lon, lat) ?? null; - if (dem === null) caster?.prepare([[lon, lat]]); - // The application's mesh window around bare earth, once that is known. - const withinPrior = state.services.ground?.meshFloorSampleWithinPrior; - const plausible = (height) => - Number.isFinite(height) && - height >= SURFACE_MIN_M && - height <= SURFACE_MAX_M && - (dem === null || !withinPrior || withinPrior(height, dem)); - let height = null; - try { - if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); - } catch { - /* not sampleable yet */ - } - if (!plausible(height)) height = dem; - if (!plausible(height)) height = scene?.globe?.getHeight?.(carto); - if (!plausible(height)) height = fallback; - return plausible(height) ? height : 0; - } - - /** Put the Cesium camera where the street-level camera is. */ - function followCamera() { - const { position, bearing, tilt, follow, altitude } = state.street; - if (!follow || !position || !state.viewer) return; - const ground = groundHeightAt(position.lon, position.lat, altitude); - state.viewer.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees( - position.lon, - position.lat, - ground + FOLLOW_EYE_HEIGHT_M, - ), - orientation: { - heading: Cesium.Math.toRadians(bearing || 0), - pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0), - roll: 0, - }, - }); - requestRender(); - } - - /** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */ - let framing = null; - - /** - * Frame the current image from a short distance behind it, unless another - * action took the camera since `ticket` (from `beginFraming`) was issued. - */ - function lookAtPosition(ticket) { - const { position, bearing, altitude } = state.street; - if (!ticket || !position || !state.viewer) return; - if ( - ticket.generation !== null && - !navigation?.reassert?.(ticket.generation) - ) - return; - const ground = groundHeightAt(position.lon, position.lat, altitude); - const fly = () => { - // Owned before the call: starting it cancels the previous flight (ours - // included) synchronously, and a zero-length one completes at once. - const flight = {}; - framing = flight; - const release = () => { - if (framing === flight) framing = null; - }; - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), - 4, - ), - { - offset: new Cesium.HeadingPitchRange( - Cesium.Math.toRadians(bearing || 0), - Cesium.Math.toRadians(-32), - 140, - ), - duration: 1.6, - complete: release, - cancel: release, - }, - ); - }; - // A reasserted ticket has already released the other owners. - if (ticket.generation === null) claim(fly); - else fly(); - } - - /** Stop our framing flight if it is still in the air; another feature's flight is left alone. */ - function cancelFraming() { - if (!framing) return; - // Give up ownership first: cancelFlight delivers `cancel` synchronously. - framing = null; - state.viewer?.camera?.cancelFlight?.(); - } - - function setFollow(enabled) { - const wanted = enabled === true && state.street.followAvailable; - if (wanted && !state.street.follow) { - // Following owns the camera until another feature takes it. - const granted = claim(() => { - state.street.follow = true; - // The framing tween would keep overwriting the follow view until it lands. - cancelFraming(); - followCamera(); - }); - if (!granted) state.street.follow = false; - } else if (!wanted) { - state.street.follow = false; - } - state.notify?.(); - } - - function destroy() { - attachNavigation(null); - } - - return { - followCamera, - beginFraming, - lookAtPosition, - cancelFraming, - setFollow, - attachNavigation, - destroy, - }; -} diff --git a/src/layers/streetLevel/cameraFollow.test.mjs b/src/layers/streetLevel/cameraFollow.test.mjs deleted file mode 100644 index 365858e4e..000000000 --- a/src/layers/streetLevel/cameraFollow.test.mjs +++ /dev/null @@ -1,265 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import * as Cesium from 'cesium'; -import { createCameraFollow } from './cameraFollow.js'; -import { FOLLOW_EYE_HEIGHT_M } from './policy.js'; - -/** Height (m) of a Cartesian above the WGS84 ellipsoid. */ -const heightOf = (cartesian) => - Cesium.Cartographic.fromCartesian(cartesian).height; - -/** - * A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight` - * cancels the current one, `land()` completes it, `cancelled` counts stops. - */ -function flightCamera(flights, views) { - let current = null; - function stop() { - const flight = current; - current = null; - flight?.options.cancel?.(); - return Boolean(flight); - } - const camera = { - cancelled: 0, - flyToBoundingSphere(sphere, options = {}) { - stop(); - current = { sphere, options }; - flights.push(current); - }, - cancelFlight() { - if (stop()) camera.cancelled++; - }, - land() { - const flight = current; - current = null; - flight?.options.complete?.(); - }, - setView: (view) => views.push(view), - }; - return camera; -} - -/** - * A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles - * under the photo load) and whose bare earth is `dem`. - */ -function setup({ sampled, dem = null, globe = null, altitude = 149 }) { - const flights = []; - const views = []; - const state = { - services: {}, - street: { - position: { lon: -97.7364, lat: 30.2672 }, - bearing: 105, - tilt: -10, - altitude, - follow: false, - followAvailable: true, - }, - viewer: { - scene: { - sampleHeightSupported: true, - sampleHeight: () => sampled, - globe: { getHeight: () => globe }, - }, - camera: flightCamera(flights, views), - }, - }; - const parts = { - groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} }, - }; - return { - follow: createCameraFollow({ state, parts }), - state, - flights, - views, - }; -} - -test('framing a photo ignores a sample kilometres underground and uses bare earth', () => { - const { follow, flights } = setup({ sampled: -14_886, dem: 117 }); - follow.lookAtPosition(follow.beginFraming()); - assert.equal(flights.length, 1); - const centre = heightOf(flights[0].sphere.center); - assert.ok( - Math.abs(centre - 119) < 0.01, - `centre at ${centre} m, not underground`, - ); -}); - -test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => { - const { follow, flights } = setup({ sampled: 121, dem: 117 }); - follow.lookAtPosition(follow.beginFraming()); - assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01); -}); - -test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => { - const { follow, flights } = setup({ - sampled: -14_886, - dem: null, - globe: undefined, - altitude: 149, - }); - follow.lookAtPosition(follow.beginFraming()); - assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01); -}); - -test('following stands the camera at eye height above the checked ground', () => { - const { follow, state, views } = setup({ sampled: -14_886, dem: 117 }); - state.street.follow = true; - follow.followCamera(); - assert.equal(views.length, 1); - assert.ok( - Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) < - 0.01, - ); -}); - -test('cancelFraming stops the framing flight while it is still ours', () => { - const { follow, state } = setup({ sampled: 121, dem: 117 }); - const { camera } = state.viewer; - follow.lookAtPosition(follow.beginFraming()); - follow.cancelFraming(); - assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops'); - follow.cancelFraming(); - assert.equal(camera.cancelled, 1, 'and only once'); - - // Re-framing (the next photo) supersedes the first flight, not the claim. - follow.lookAtPosition(follow.beginFraming()); - follow.lookAtPosition(follow.beginFraming()); - follow.cancelFraming(); - assert.equal(camera.cancelled, 2); -}); - -test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => { - const { follow, state, flights } = setup({ sampled: 121, dem: 117 }); - const { camera } = state.viewer; - follow.lookAtPosition(follow.beginFraming()); - camera.land(); - follow.cancelFraming(); - assert.equal(camera.cancelled, 0, 'landed: nothing to stop'); - - follow.lookAtPosition(follow.beginFraming()); - // A search result flies the globe elsewhere; Cesium cancels ours first. - camera.flyToBoundingSphere(null, {}); - assert.equal(flights.length, 3); - follow.cancelFraming(); - assert.equal(camera.cancelled, 0, 'the newer flight keeps going'); -}); - -test('turning FOLLOW on stops the framing flight so it cannot fight the follow view', () => { - const { follow, state, views } = setup({ sampled: 121, dem: 117 }); - const { camera } = state.viewer; - follow.lookAtPosition(follow.beginFraming()); - follow.setFollow(true); - assert.equal(camera.cancelled, 1, 'the framing tween stops'); - assert.equal(views.length, 1, 'and the camera stands at the photo'); -}); - -/** - * The application's camera authority: `run` releases tracking (here, a tracked - * entity) and stamps a handoff before the move, or refuses like the cockpit. - */ -function navigationFor(state, { refuse = false } = {}) { - const listeners = new Set(); - let generation = 0; - const stamp = () => { - generation++; - for (const listener of listeners) listener({ generation }); - return generation; - }; - const nav = { - runs: [], - begins: [], - run(noun, move) { - nav.runs.push(noun); - if (refuse) return false; - const current = stamp(); - state.viewer.trackedEntity = undefined; - return move(current); - }, - /** Deferred: stamp now, release only on a successful reassert. */ - begin(noun) { - nav.begins.push(noun); - return refuse ? false : stamp(); - }, - reassert(ticket) { - if (ticket !== generation) return false; - state.viewer.trackedEntity = undefined; - return true; - }, - subscribeHandoff(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - /** Another feature takes the camera. */ - handOff: () => stamp(), - listeners, - }; - return nav; -} - -test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => { - const { follow, state, flights } = setup({ sampled: 160 }); - state.viewer.trackedEntity = { id: 'aircraft' }; - const nav = navigationFor(state); - follow.attachNavigation(nav); - const ticket = follow.beginFraming(); - assert.deepEqual(nav.begins, ['photo']); - assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads'); - follow.lookAtPosition(ticket); - assert.equal(state.viewer.trackedEntity, undefined); - assert.equal(flights.length, 1); -}); - -test('a photo that finishes loading after newer navigation does not frame', () => { - const { follow, state, flights } = setup({ sampled: 160 }); - const nav = navigationFor(state); - follow.attachNavigation(nav); - const ticket = follow.beginFraming(); - // The user flies to Sacramento, and then tracks an aircraft, while it loads. - nav.handOff(); - state.viewer.trackedEntity = { id: 'aircraft' }; - follow.lookAtPosition(ticket); - assert.equal(flights.length, 0, 'no flight back to the photo'); - assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' }); -}); - -test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => { - const { follow, state, flights, views } = setup({ sampled: 160 }); - follow.attachNavigation(navigationFor(state, { refuse: true })); - follow.lookAtPosition(follow.beginFraming()); - follow.setFollow(true); - assert.equal(flights.length, 0); - assert.equal(views.length, 0); - assert.equal(state.street.follow, false); -}); - -test('FOLLOW claims the camera once and ends when another feature takes it', () => { - const { follow, state, views } = setup({ sampled: 160 }); - let notified = 0; - state.notify = () => notified++; - const nav = navigationFor(state); - follow.attachNavigation(nav); - follow.setFollow(true); - assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on'); - assert.deepEqual(nav.runs, ['photo']); - follow.followCamera(); - assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again'); - assert.equal(views.length, 2); - nav.handOff(); - assert.equal(state.street.follow, false); - assert.ok(notified >= 2); - follow.followCamera(); - assert.equal(views.length, 2, 'the camera is no longer driven'); -}); - -test('destroying the follow part stops listening for handoffs', () => { - const { follow, state } = setup({ sampled: 160 }); - const nav = navigationFor(state); - follow.attachNavigation(nav); - assert.equal(nav.listeners.size, 1); - follow.destroy(); - assert.equal(nav.listeners.size, 0); -}); diff --git a/src/layers/streetLevel/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js new file mode 100644 index 000000000..c6bc9327a --- /dev/null +++ b/src/layers/streetLevel/cameraFraming.js @@ -0,0 +1,83 @@ +import * as Cesium from 'cesium'; +import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; + +/** Fly the globe camera once to frame a photo after it opens. */ +export function createCameraFraming({ state }) { + /** + * The application's camera authority: `reassert(begin())` succeeds only if + * nothing newer took the camera. Without it, framing flies directly. + */ + let navigation = null; + /** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */ + let flight = null; + + function attachNavigation(next) { + navigation = next || null; + } + + /** Ask for the camera as a photo starts opening: a ticket for `frame`, null when refused (cockpit). */ + function begin() { + if (!navigation?.begin || !navigation.reassert) return { generation: null }; + const generation = navigation.begin('photo'); + return generation === false ? null : { generation }; + } + + /** + * Ground under a photo. `sampleHeight` can return kilometres below the + * ellipsoid before tiles load, so only a plausible height counts. + */ + function groundHeightAt(lon, lat, fallback) { + const scene = state.viewer?.scene; + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + let height = sampledSurfaceHeight(scene, carto); + if (height === null) height = scene?.globe?.getHeight?.(carto); + if (!plausibleSurfaceHeight(height)) height = fallback; + return plausibleSurfaceHeight(height) ? height : 0; + } + + /** + * Frame the open photo from a short distance behind it, unless another + * action took the camera since `ticket` (from `begin`) was issued. + */ + function frame(ticket) { + const { position, bearing, altitude } = state.street; + if (!ticket || !position || !state.viewer) return; + // Detached (shell teardown) since the ticket was issued: nothing to frame for. + if (ticket.generation !== null && !navigation?.reassert(ticket.generation)) + return; + const ground = groundHeightAt(position.lon, position.lat, altitude); + // Owned before the call: starting it cancels the previous flight (ours + // included) synchronously, and a zero-length one completes at once. + const current = {}; + flight = current; + const release = () => { + if (flight === current) flight = null; + }; + state.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), + 4, + ), + { + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(bearing || 0), + Cesium.Math.toRadians(-32), + 140, + ), + duration: 1.6, + complete: release, + cancel: release, + }, + ); + } + + /** Stop our framing flight if it is still in the air; another feature's flight is left alone. */ + function cancel() { + if (!flight) return; + // Give up ownership first: cancelFlight delivers `cancel` synchronously. + flight = null; + state.viewer?.camera?.cancelFlight?.(); + } + + return { attachNavigation, begin, frame, cancel }; +} diff --git a/src/layers/streetLevel/cameraFraming.test.mjs b/src/layers/streetLevel/cameraFraming.test.mjs new file mode 100644 index 000000000..c1a17989e --- /dev/null +++ b/src/layers/streetLevel/cameraFraming.test.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createCameraFraming } from './cameraFraming.js'; +import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs'; + +/** Height (m) of a Cartesian above the WGS84 ellipsoid. */ +const heightOf = (cartesian) => + Cesium.Cartographic.fromCartesian(cartesian).height; + +/** + * A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles + * under the photo load) and whose globe terrain is `globe`. + */ +function setup({ sampled, globe = null, altitude = 149 }) { + const viewer = fakeCesiumViewer(); + Object.assign(viewer.scene, { + sampleHeightSupported: true, + sampleHeight: () => sampled, + globe: { getHeight: () => globe }, + }); + const state = { + services: {}, + street: { + position: { lon: -97.7364, lat: 30.2672 }, + bearing: 105, + altitude, + }, + viewer, + }; + return { + framing: createCameraFraming({ state }), + state, + viewer, + flights: viewer.flights.list, + }; +} + +test('framing a photo stands the sphere on a plausible scene sample', () => { + const { framing, flights } = setup({ sampled: 121 }); + framing.frame(framing.begin()); + assert.equal(flights.length, 1); + assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01); +}); + +test('a sample kilometres underground falls back to the globe, then the image altitude', () => { + const globe = setup({ sampled: -14_886, globe: 117 }); + globe.framing.frame(globe.framing.begin()); + assert.ok(Math.abs(heightOf(globe.flights[0].sphere.center) - 119) < 0.01); + const altitude = setup({ + sampled: -14_886, + globe: undefined, + altitude: 149, + }); + altitude.framing.frame(altitude.framing.begin()); + assert.ok(Math.abs(heightOf(altitude.flights[0].sphere.center) - 151) < 0.01); +}); + +test('cancel stops the framing flight while it is still ours', () => { + const { framing, viewer } = setup({ sampled: 121 }); + framing.frame(framing.begin()); + framing.cancel(); + assert.equal( + viewer.flights.cancelled, + 1, + 'the flight toward the photo stops', + ); + framing.cancel(); + assert.equal(viewer.flights.cancelled, 1, 'and only once'); + + // Re-framing (the next photo) supersedes the first flight, not the claim. + framing.frame(framing.begin()); + framing.frame(framing.begin()); + framing.cancel(); + assert.equal(viewer.flights.cancelled, 2); +}); + +test('cancel leaves a landed flight and a newer navigation flight alone', () => { + const { framing, viewer, flights } = setup({ sampled: 121 }); + framing.frame(framing.begin()); + viewer.flights.land(); + framing.cancel(); + assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop'); + + framing.frame(framing.begin()); + // A search result flies the globe elsewhere; Cesium cancels ours first. + viewer.camera.flyToBoundingSphere(null, {}); + assert.equal(flights.length, 3); + framing.cancel(); + assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going'); +}); + +/** + * The application's camera authority: `begin` stamps a generation (or refuses + * like the cockpit), `reassert` releases tracking (here, a tracked entity) + * only while that generation is current. + */ +function navigationFor(state, { refuse = false } = {}) { + let generation = 0; + const stamp = () => ++generation; + const nav = { + begins: [], + /** Deferred: stamp now, release only on a successful reassert. */ + begin(noun) { + nav.begins.push(noun); + return refuse ? false : stamp(); + }, + reassert(ticket) { + if (ticket !== generation) return false; + state.viewer.trackedEntity = undefined; + return true; + }, + /** Another feature takes the camera. */ + handOff: () => stamp(), + }; + return nav; +} + +test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => { + const { framing, state, flights } = setup({ sampled: 160 }); + state.viewer.trackedEntity = { id: 'aircraft' }; + const nav = navigationFor(state); + framing.attachNavigation(nav); + const ticket = framing.begin(); + assert.deepEqual(nav.begins, ['photo']); + assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads'); + framing.frame(ticket); + assert.equal(state.viewer.trackedEntity, undefined); + assert.equal(flights.length, 1); +}); + +test('a photo that finishes loading after newer navigation does not frame', () => { + const { framing, state, flights } = setup({ sampled: 160 }); + const nav = navigationFor(state); + framing.attachNavigation(nav); + const ticket = framing.begin(); + // The user flies to Sacramento, and then tracks an aircraft, while it loads. + nav.handOff(); + state.viewer.trackedEntity = { id: 'aircraft' }; + framing.frame(ticket); + assert.equal(flights.length, 0, 'no flight back to the photo'); + assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' }); +}); + +test('a refused claim (cockpit) does not frame the photo', () => { + const { framing, state, flights } = setup({ sampled: 160 }); + framing.attachNavigation(navigationFor(state, { refuse: true })); + framing.frame(framing.begin()); + assert.equal(flights.length, 0); +}); + +test('a photo that loads after the navigation was detached does not frame', () => { + const { framing, state, flights } = setup({ sampled: 160 }); + framing.attachNavigation(navigationFor(state)); + const ticket = framing.begin(); + framing.attachNavigation(null); + assert.doesNotThrow(() => framing.frame(ticket)); + assert.equal(flights.length, 0); +}); diff --git a/src/layers/streetLevel/filter.js b/src/layers/streetLevel/filter.js index 425de5092..ad244576b 100644 --- a/src/layers/streetLevel/filter.js +++ b/src/layers/streetLevel/filter.js @@ -27,7 +27,7 @@ export function sameFilter(a, b) { /** * Stored filter (relative days, stable in share links) to the absolute form - * providers compare capture times against. + * coverage, cones and lookups compare capture times against. * @returns {{pano: string, sinceMs: number|null}} */ export function resolveFilter(filter, now = Date.now()) { diff --git a/src/layers/streetLevel/glyphs.js b/src/layers/streetLevel/glyphs.js index 233a7bdde..803e4b548 100644 --- a/src/layers/streetLevel/glyphs.js +++ b/src/layers/streetLevel/glyphs.js @@ -1,48 +1,27 @@ -/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */ +/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position markers. */ -const _cache = new Map(); +const px = (n) => Number(n.toFixed(2)); -/** A square canvas glyph drawn once per key, centred at (c, c). */ -function cachedGlyph(key, size, draw) { - if (_cache.has(key)) return _cache.get(key); - const element = document.createElement('canvas'); - element.width = size; - element.height = size; - draw(element.getContext('2d'), size / 2); - _cache.set(key, element); - return element; +function svg(size, body) { + const markup = `${body}`; + return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`; } /** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */ -function wedge(ctx, c, radius, halfAngle, color, alpha) { - ctx.beginPath(); - ctx.moveTo(c, c); - ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle); - ctx.closePath(); - ctx.fillStyle = color; - ctx.globalAlpha = alpha; - ctx.fill(); - ctx.globalAlpha = 1; +function wedge(size, radius, halfAngle, color, opacity) { + const c = size / 2; + const dx = px(radius * Math.sin(halfAngle)); + const dy = px(radius * Math.cos(halfAngle)); + const r = px(radius); + return ``; } -function ring(ctx, c, radius, color, width, alpha) { - ctx.beginPath(); - ctx.arc(c, c, radius, 0, Math.PI * 2); - ctx.strokeStyle = color; - ctx.lineWidth = width; - ctx.globalAlpha = alpha; - ctx.stroke(); - ctx.globalAlpha = 1; +function ring(size, radius, color, width, opacity) { + return ``; } -function dot(ctx, c, radius, color, outline, width) { - ctx.beginPath(); - ctx.arc(c, c, radius, 0, Math.PI * 2); - ctx.fillStyle = color; - ctx.fill(); - ctx.lineWidth = width; - ctx.strokeStyle = outline; - ctx.stroke(); +function dot(size, radius, color, outline, width) { + return ``; } /** Cone glyph pointing "up"; rotate the billboard by the compass angle. */ @@ -51,34 +30,38 @@ export function imageConeGlyph({ color = '#e8eaed', pano = false, } = {}) { - return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => { - if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5); - else wedge(ctx, c, size * 0.46, 0.62, color, 0.42); - dot( - ctx, - c, - size * 0.16, - color, - 'rgba(10,10,15,0.85)', - Math.max(1, size * 0.05), - ); - }); + const shape = pano + ? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5) + : wedge(size, size * 0.46, 0.62, color, 0.42); + return svg( + size, + shape + + dot( + size, + size * 0.16, + color, + 'rgba(10,10,15,0.85)', + Math.max(1, size * 0.05), + ), + ); } /** Street View-style marker: a field-of-view cone from a white-ringed dot. */ export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) { - return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => { - wedge(ctx, c, size * 0.48, 0.62, color, 0.38); - wedge(ctx, c, size * 0.3, 0.62, color, 0.3); - dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05)); - }); + return svg( + size, + wedge(size, size * 0.48, 0.62, color, 0.38) + + wedge(size, size * 0.3, 0.62, color, 0.3) + + dot(size, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05)), + ); } /** Marker for the image the viewer currently shows. */ export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { - return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => { - wedge(ctx, c, size * 0.48, 0.55, color, 0.55); - dot(ctx, c, size * 0.2, color, '#0a0a0f', 2); - ring(ctx, c, size * 0.3, color, 1.5, 0.8); - }); + return svg( + size, + wedge(size, size * 0.48, 0.55, color, 0.55) + + dot(size, size * 0.2, color, '#0a0a0f', 2) + + ring(size, size * 0.3, color, 1.5, 0.8), + ); } diff --git a/src/layers/streetLevel/groundCast.js b/src/layers/streetLevel/groundCast.js deleted file mode 100644 index c5b1885e3..000000000 --- a/src/layers/streetLevel/groundCast.js +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Ground casting for street-level overlays on Google 3D. Draped overlays land - * on the mesh top (trees, overpasses), so casting places them at bare-earth - * (DEM) height plus a lift and lets the mesh hide what is behind buildings. - * Heights come from a coarse grid shared with the ground-floor service; a - * geoid fallback (terrain proxy down) counts as unknown and stays draped. - */ - -import { wrapLon } from './tileMath.js'; - -/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */ -export const GROUND_CAST_STEP_DEG = 0.001; -/** Longest line segment left undivided, in degrees, so lines follow the grid. */ -export const GROUND_CAST_DENSIFY_DEG = 0.0005; -/** Metres above the bare earth, so lines sit on the road instead of in it. */ -export const GROUND_CAST_LIFT_M = 2; -/** Grid corners one prepare call may request; more stays draped. */ -export const GROUND_CAST_MAX_CORNERS = 1024; -/** Cached grid corners before the oldest are dropped to make room. */ -const GROUND_CAST_CACHE_MAX = 50_000; - -/** - * Mesh refinement, where the Google 3D surface was sampled (the DEM grid - * misses trenches, steep streets and roads under trees): - * - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it; - * - higher mesh is over the road (canopy, deck): interpolate the road's - * offset from the nearest road samples on both sides; - * - otherwise bare earth plus GROUND_CAST_LIFT_M. - */ -export const MESH_ROAD_ABOVE_M = 3; -/** A sample this far below bare earth is a bad probe, not a road. */ -export const MESH_ROAD_BELOW_M = 30; -/** Metres above the sampled road surface. */ -export const MESH_LIFT_M = 1; -/** Longest run of covered points bridged from the road on either side. */ -export const MESH_BRIDGE_MAX_POINTS = 8; -/** Line spacing, in degrees (~22 m), while the mesh refines heights. */ -export const MESH_DENSIFY_DEG = 0.0002; - -/** - * Heights for a line's points by the rule above. - * @param {Array<{dem: number, mesh?: number|null}>} points - * @param {{lift?: number}} [options] - * @returns {Array} - */ -export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) { - const road = points.map(({ dem, mesh }) => - Number.isFinite(mesh) && - mesh <= dem + MESH_ROAD_ABOVE_M && - mesh >= dem - MESH_ROAD_BELOW_M - ? mesh - dem - : null, - ); - return points.map(({ dem, mesh }, i) => { - if (road[i] !== null) return dem + road[i] + MESH_LIFT_M; - if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) { - let a = i - 1; - while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--; - let b = i + 1; - while ( - b < points.length && - b - i <= MESH_BRIDGE_MAX_POINTS && - road[b] === null - ) - b++; - const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null; - const right = - b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null; - if (left !== null && right !== null) { - const t = (i - a) / (b - a); - return dem + left + (right - left) * t + MESH_LIFT_M; - } - } - return dem + lift; - }); -} - -/** Camera height (m above ground) below which Google 3D switches to terrain mode. */ -export const SURFACE_TERRAIN_ENTER_M = 1400; -/** Camera height above which terrain mode switches back to draped. */ -export const SURFACE_TERRAIN_EXIT_M = 1800; - -/** - * Next surface mode: terrain only on Google 3D at street zoom, with - * hysteresis between the enter and exit heights. - * @param {'draped'|'terrain'} current - * @param {{photoreal: boolean, heightM: number|null, available: boolean}} view - * @returns {'draped'|'terrain'} - */ -export function nextSurfaceMode(current, { photoreal, heightM, available }) { - if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped'; - if (current === 'terrain') - return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain'; - return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped'; -} - -/** - * Split long segments so a line follows the terrain. Segments across ±180° - * go the short way round, with added points wrapped into [-180, 180]. - * @param {Array<[number, number]>} coords [lon, lat] pairs - * @param {number} [maxStep] longest segment, in degrees - * @returns {Array<[number, number]>} - */ -export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) { - if (!Array.isArray(coords) || coords.length < 2) return coords || []; - const out = [coords[0]]; - for (let i = 1; i < coords.length; i++) { - const [lon0, lat0] = coords[i - 1]; - const [lon1, lat1] = coords[i]; - const dLon = wrapLon(lon1 - lon0); - const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0)); - const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9)); - for (let k = 1; k < parts; k++) { - const t = k / parts; - out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]); - } - out.push(coords[i]); - } - return out; -} - -/** - * A bare-earth height cache over the terrain service. - * @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options - */ -export function createGroundCaster({ - terrain, - step = GROUND_CAST_STEP_DEG, - lift = GROUND_CAST_LIFT_M, - maxCorners = GROUND_CAST_MAX_CORNERS, - cacheMax = GROUND_CAST_CACHE_MAX, -}) { - if (typeof terrain?.resolveEllipsoidalGround !== 'function') - throw new TypeError('Ground casting requires a terrain service'); - /** "i,j" grid corner → ellipsoidal ground height in metres. */ - const heights = new Map(); - /** Prepares run one at a time, so neighbouring tiles share their corners. */ - let queue = Promise.resolve(); - - const cornerKey = (i, j) => `${i},${j}`; - - function missingCorners(points) { - const missing = new Map(); - for (const point of points) { - const [lon, lat] = point; - if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; - const i0 = Math.floor(lon / step); - const j0 = Math.floor(lat / step); - for (let di = 0; di <= 1; di++) - for (let dj = 0; dj <= 1; dj++) { - const key = cornerKey(i0 + di, j0 + dj); - if (!heights.has(key) && !missing.has(key)) - missing.set(key, { i: i0 + di, j: j0 + dj }); - } - } - return [...missing.entries()]; - } - - async function resolve(points, signal) { - if (signal?.aborted) return false; - // Evict before counting, or we could drop corners this request counted - // as cached. Oldest first, so the tiles still on screen keep theirs. - for (const key of heights.keys()) { - if (heights.size + maxCorners <= cacheMax) break; - heights.delete(key); - } - const missing = missingCorners(points); - if (!missing.length) return true; - if (missing.length > maxCorners) return false; - // A corner east of 180° is asked for at its wrapped longitude. - const results = await terrain.resolveEllipsoidalGround( - missing.map(([, { i, j }]) => ({ - lon: Number(wrapLon(i * step).toFixed(6)), - lat: Number((j * step).toFixed(6)), - })), - { signal }, - ); - let complete = true; - missing.forEach(([key], n) => { - const result = results?.[n]; - if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid)) - heights.set(key, result.ellipsoid); - else complete = false; - }); - return complete && !signal?.aborted; - } - - /** - * Fetch the grid corners around the points. True when all can be cast; - * false when some are unknown, too many, or aborted. Never rejects. - * @param {Array<[number, number]>} points [lon, lat] pairs - * @param {{signal?: AbortSignal}} [options] - */ - function prepare(points, { signal } = {}) { - const run = queue.then(() => resolve(points, signal)).catch(() => false); - queue = run; - return run; - } - - /** - * `prepare` for lines at both densities: the finer mesh points can fall in - * cells the coarse ones skip, and a missing cell leaves the line draped. - */ - function prepareLines(lines, options) { - return prepare( - lines.flatMap((coords) => [ - ...densifyLine(coords), - ...densifyLine(coords, MESH_DENSIFY_DEG), - ]), - options, - ); - } - - /** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */ - function groundAt(lon, lat) { - const x = lon / step; - const y = lat / step; - const i0 = Math.floor(x); - const j0 = Math.floor(y); - const h00 = heights.get(cornerKey(i0, j0)); - const h10 = heights.get(cornerKey(i0 + 1, j0)); - const h01 = heights.get(cornerKey(i0, j0 + 1)); - const h11 = heights.get(cornerKey(i0 + 1, j0 + 1)); - if (h00 === undefined || h10 === undefined) return null; - if (h01 === undefined || h11 === undefined) return null; - const fx = x - i0; - const fy = y - j0; - const south = h00 + (h10 - h00) * fx; - const north = h01 + (h11 - h01) * fx; - return south + (north - south) * fy; - } - - /** - * Densified line as flat [lon, lat, height, ...]; null until every corner is - * cached. With `meshAt` it is densified finer and refined by `refineHeights`. - * @param {Array<[number, number]>} coords - * @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options] - */ - function castLine(coords, { meshAt = null } = {}) { - const rows = []; - const points = meshAt - ? densifyLine(coords, MESH_DENSIFY_DEG) - : densifyLine(coords); - for (const [lon, lat] of points) { - const dem = groundAt(lon, lat); - if (dem === null) return null; - rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null }); - } - const heights = refineHeights(rows, { lift }); - const flat = []; - rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i])); - return flat; - } - - return { prepare, prepareLines, groundAt, castLine }; -} diff --git a/src/layers/streetLevel/groundCast.test.mjs b/src/layers/streetLevel/groundCast.test.mjs deleted file mode 100644 index 44fee92e3..000000000 --- a/src/layers/streetLevel/groundCast.test.mjs +++ /dev/null @@ -1,370 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - GROUND_CAST_LIFT_M, - MESH_LIFT_M, - refineHeights, - SURFACE_TERRAIN_ENTER_M, - SURFACE_TERRAIN_EXIT_M, - createGroundCaster, - densifyLine, - nextSurfaceMode, -} from './groundCast.js'; - -/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */ -function fakeTerrain({ source = 'reearth', fail = false } = {}) { - const calls = []; - return { - calls, - async resolveEllipsoidalGround(coords) { - calls.push(coords); - if (fail) throw new Error('proxy down'); - return coords.map(({ lon, lat }) => ({ - ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000, - source, - })); - }, - }; -} - -test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => { - const view = { photoreal: true, available: true }; - assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain'); - assert.equal( - nextSurfaceMode('draped', { - ...view, - heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2, - }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { - ...view, - heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2, - }), - 'terrain', - ); - assert.equal( - nextSurfaceMode('terrain', { - ...view, - heightM: SURFACE_TERRAIN_EXIT_M + 1, - }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { ...view, heightM: null }), - 'draped', - ); -}); - -test('densifyLine splits long segments and keeps the vertices', () => { - const line = [ - [10, 50], - [10.002, 50], - ]; - const dense = densifyLine(line, 0.0005); - assert.equal(dense.length, 5); - assert.deepEqual(dense[0], [10, 50]); - assert.deepEqual(dense.at(-1), [10.002, 50]); - assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9); - const short = [ - [10, 50], - [10.0001, 50], - ]; - assert.deepEqual(densifyLine(short, 0.0005), short); - assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]); -}); - -test('densifyLine crosses the date line the short way and wraps what it adds', () => { - // 0.0002° apart: short enough to stay one segment, not 65 points via 0°. - const short = [ - [179.9999, 0], - [-179.9999, 0], - ]; - assert.deepEqual(densifyLine(short), short); - const line = [ - [179.999, 50.0005], - [-179.999, 50.0005], - ]; - const dense = densifyLine(line, 0.0005); - assert.equal(dense.length, 5); - assert.deepEqual(dense[0], line[0]); - assert.deepEqual(dense.at(-1), line[1]); - for (const [lon] of dense) - assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`); - assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9); - assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9); - assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9); - // Westward across the date line too. - const back = densifyLine([...line].reverse(), 0.0005); - assert.equal(back.length, 5); - assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9); -}); - -test('the caster interpolates cached grid corners and adds the lift', async () => { - const terrain = fakeTerrain(); - const caster = createGroundCaster({ terrain, step: 0.001 }); - assert.equal(caster.groundAt(10.0005, 50.0005), null); - assert.equal(await caster.prepare([[10.0005, 50.0005]]), true); - assert.equal(terrain.calls.length, 1); - assert.equal(terrain.calls[0].length, 4); - // Centre of the cell: mean of 100, 101, 102, 103. - assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6); - // A cast line sits the lift above it. - const point = [10.0005, 50.0005]; - const [, , height] = caster.castLine([point, point]); - assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6); - // Same cell again: nothing to fetch. - assert.equal(await caster.prepare([[10.0002, 50.0008]]), true); - assert.equal(terrain.calls.length, 1); -}); - -test('castLine returns lon, lat, height triples or null when a corner is missing', async () => { - const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 }); - const line = [ - [10.0001, 50.0001], - [10.0009, 50.0001], - ]; - assert.equal(caster.castLine(line), null); - await caster.prepareLines([line]); - const flat = caster.castLine(line); - assert.equal(flat.length % 3, 0); - assert.ok(flat.length >= 6); - assert.equal(flat[0], 10.0001); - assert.equal(flat[1], 50.0001); - assert.ok(flat[2] > 100 && flat[2] < 110); -}); - -test('a geoid fallback or a failing proxy leaves the heights unknown', async () => { - const fallback = createGroundCaster({ - terrain: fakeTerrain({ source: 'geoid-fallback' }), - }); - assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false); - assert.equal(fallback.groundAt(10.0005, 50.0005), null); - - const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) }); - assert.equal(await down.prepare([[10.0005, 50.0005]]), false); - assert.equal(down.groundAt(10.0005, 50.0005), null); -}); - -test('too many corners are not requested, and aborted prepares do nothing', async () => { - const terrain = fakeTerrain(); - const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 }); - const wide = []; - for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]); - assert.equal(await caster.prepare(wide), false); - assert.equal(terrain.calls.length, 0); - - const controller = new AbortController(); - controller.abort(); - assert.equal( - await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }), - false, - ); - assert.equal(terrain.calls.length, 0); -}); - -test('prepares run one at a time so neighbours share corners', async () => { - const terrain = fakeTerrain(); - const caster = createGroundCaster({ terrain, step: 0.001 }); - const [a, b] = await Promise.all([ - caster.prepare([[10.0005, 50.0005]]), - caster.prepare([[10.0015, 50.0005]]), - ]); - assert.equal(a, true); - assert.equal(b, true); - // The second cell shares its west corners with the first. - assert.equal(terrain.calls[1].length, 2); -}); - -test('the caster needs a terrain service', () => { - assert.throws(() => createGroundCaster({ terrain: null }), TypeError); -}); - -test('refineHeights follows the mesh where it is the road', () => { - // A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it. - const heights = refineHeights([ - { dem: 100, mesh: 94 }, - { dem: 100, mesh: 102 }, - { dem: 100, mesh: null }, - ]); - assert.deepEqual(heights, [ - 94 + MESH_LIFT_M, - 102 + MESH_LIFT_M, - 100 + GROUND_CAST_LIFT_M, - ]); -}); - -test('refineHeights carries the road under a canopy from both sides', () => { - // Road 1 m and 3 m above bare earth either side of a tree 12 m tall. - const heights = refineHeights([ - { dem: 50, mesh: 51 }, - { dem: 50, mesh: 62 }, - { dem: 50, mesh: 62 }, - { dem: 50, mesh: 53 }, - ]); - assert.equal(heights[0], 51 + MESH_LIFT_M); - assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9); - assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9); - assert.equal(heights[3], 53 + MESH_LIFT_M); -}); - -test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => { - assert.deepEqual( - refineHeights([ - { dem: 20, mesh: 35 }, - { dem: 20, mesh: 35 }, - ]), - [20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M], - ); - // A probe kilometres under the ground is not a road. - assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [ - 20 + GROUND_CAST_LIFT_M, - ]); -}); - -test('castLine refines heights with sampled mesh and densifies finer', async () => { - const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 }); - const line = [ - [10.0001, 50.0001], - [10.0009, 50.0001], - ]; - await caster.prepareLines([line]); - const plain = caster.castLine(line); - const meshed = caster.castLine(line, { meshAt: () => 98 }); - assert.ok(meshed.length > plain.length, 'finer spacing with the mesh'); - for (let i = 2; i < meshed.length; i += 3) - assert.equal(meshed[i], 98 + MESH_LIFT_M); - // Unsampled points keep the bare-earth height. - const unsampled = caster.castLine(line, { meshAt: () => undefined }); - assert.ok(unsampled[2] > 100 && unsampled[2] < 110); -}); - -test('a full cache is cleared before a request counts its corners, so the request still casts', async () => { - const caster = createGroundCaster({ - terrain: fakeTerrain(), - step: 0.001, - maxCorners: 8, - cacheMax: 5, - }); - // One cell's four corners are cached... - assert.equal(await caster.prepare([[10.0005, 50.0005]]), true); - // ...then a line over that cell and the next needs two more, and the first - // cell must survive the eviction. - const line = [ - [10.0002, 50.0005], - [10.0018, 50.0005], - ]; - assert.equal(await caster.prepareLines([line]), true); - assert.ok(caster.castLine(line), 'the whole line casts'); -}); - -test('a full cache drops its oldest corners, not every corner', async () => { - const caster = createGroundCaster({ - terrain: fakeTerrain(), - step: 0.001, - maxCorners: 4, - cacheMax: 12, - }); - // Three cells apart from each other, four corners each: the cache is full. - const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [ - lon, - 50.0005, - ]); - for (const cell of cells.slice(0, 3)) - assert.equal(await caster.prepare([cell]), true); - // A fourth makes room by dropping the first cell only. - assert.equal(await caster.prepare([cells[3]]), true); - assert.equal(caster.groundAt(...cells[0]), null, 'oldest dropped'); - for (const cell of cells.slice(1)) - assert.notEqual(caster.groundAt(...cell), null, 'newer cells kept'); -}); - -test('cancelling a prepare cancels its terrain request', async () => { - const seen = []; - const caster = createGroundCaster({ - terrain: { - async resolveEllipsoidalGround(coords, options) { - seen.push(options?.signal); - return coords.map(() => ({ ellipsoid: 100, source: 'reearth' })); - }, - }, - }); - const controller = new AbortController(); - await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }); - assert.equal(seen.length, 1); - assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy'); -}); - -test('prepared lines always cast with the mesh, however their segments cross the grid', async () => { - // Seeded walks with 50–300 m segments: the finer mesh points can land in - // grid cells the coarse points skip. - let seed = 7; - const random = () => { - seed = (seed * 16807) % 2147483647; - return seed / 2147483647; - }; - let failures = 0; - for (let n = 0; n < 300; n++) { - const caster = createGroundCaster({ terrain: fakeTerrain() }); - const line = [[10 + random() * 0.01, 50 + random() * 0.01]]; - for (let k = 0; k < 10; k++) { - const [lon, lat] = line[line.length - 1]; - const length = (50 + random() * 250) / 111_000; - const angle = random() * 2 * Math.PI; - line.push([ - lon + length * Math.cos(angle), - lat + length * Math.sin(angle), - ]); - } - assert.equal(await caster.prepareLines([line]), true); - if (caster.castLine(line, { meshAt: () => undefined }) === null) failures++; - } - assert.equal(failures, 0); -}); - -test('a line across the date line casts along the date line', async () => { - // Ground that rises northward only, so both sides of ±180° agree. - const calls = []; - const terrain = { - async resolveEllipsoidalGround(coords) { - calls.push(...coords); - return coords.map(({ lat }) => ({ - ellipsoid: 100 + (lat - 50) * 2000, - source: 'reearth', - })); - }, - }; - const caster = createGroundCaster({ terrain, step: 0.001 }); - const line = [ - [179.9995, 50.0005], - [180, 50.0005], - [-179.9995, 50.0005], - ]; - assert.equal(await caster.prepareLines([line]), true); - for (const meshAt of [null, () => undefined]) { - const flat = caster.castLine(line, { meshAt }); - assert.ok(flat, 'every point casts'); - assert.ok( - flat.length / 3 <= 8, - `${flat.length / 3} points, not a line round the world`, - ); - for (let i = 0; i < flat.length; i += 3) { - assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180); - assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6); - } - } - // Only ground near the date line is asked for, and every longitude is a - // real one (the far corner of a point on 180° is asked for at -179.999°). - assert.ok(calls.length <= 16, `${calls.length} corners`); - for (const { lon } of calls) - assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`); -}); diff --git a/src/layers/streetLevel/groundClick.js b/src/layers/streetLevel/groundClick.js index fef6ddb47..d33791b8a 100644 --- a/src/layers/streetLevel/groundClick.js +++ b/src/layers/streetLevel/groundClick.js @@ -3,34 +3,24 @@ import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js'; import { cameraHeightAboveGround } from './view.js'; /** Whether the camera is low enough for a click on the ground to mean a street. */ -export function groundClickHeightOk(viewer, groundAt) { +export function groundClickHeightOk(viewer) { if (!viewer?.camera?.positionCartographic) return false; - return ( - cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M - ); + return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M; } /** * A click on the map itself at street zoom opens the nearest image of the * active `groundClick` providers, without moving the camera. The height is * measured at the click, not read from the cached flag. - * @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options + * @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options * @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise|false} */ -export function createGroundClick({ - state, - openNearest, - isAvailable, - groundAt = null, -}) { +export function createGroundClick({ state, openNearest, isAvailable }) { return function openAtGround(screenPosition, picked) { const scenePick = state.services.scenePick; if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition) return false; - if ( - !scenePick.isSurfacePick(picked) || - !groundClickHeightOk(state.viewer, groundAt) - ) + if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer)) return false; const providerIds = [...state.providers.values()] .filter( diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 6e8b9e2d7..a59ddc1d4 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,7 +1,7 @@ import * as Cesium from 'cesium'; import { createState } from './state.js'; import { createMarker } from './marker.js'; -import { createCameraFollow } from './cameraFollow.js'; +import { createCameraFraming } from './cameraFraming.js'; import { createCredits } from './credits.js'; import { createPickRouter } from './pickRouter.js'; import { createSelection } from './selection.js'; @@ -14,17 +14,12 @@ import { keyAlternatives, summarizeCoverage, } from './uiState.js'; -import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js'; +import { groundUnderCamera, viewCentre } from './view.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js'; -import { createGroundCaster, nextSurfaceMode } from './groundCast.js'; -import { createMeshSampler } from './meshSampler.js'; import { msg, t, translateKnown } from '../../i18n/index.js'; - -/** Above this camera height the ground under the camera is not worth fetching. */ -const SURFACE_WARM_BELOW_M = 6000; import { AIM_AT_GROUND_MESSAGE, - FOLLOW_MAP_STACK_ID, + keyStatusLabel, NEAREST_RADIUS_M, POSITION_PICK_ID, STREET_LEVEL_LAYER_ID, @@ -32,35 +27,31 @@ import { export { STREET_LEVEL_LAYER_ID } from './policy.js'; +/** Longest wait for a provider's key status; past it, the provider decides. */ +const KEY_STATUS_TIMEOUT_MS = 10_000; +/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */ +const GROUND_RETRY_LIMIT = 5; +const GROUND_RETRY_MS = 1000; + /** * The Street Level layer over imagery providers (contract in registry.js). - * The core owns what they share; providers own coverage, sequences, viewer. + * The core owns what they share: the filter and switches, the photo viewer + * host, the position marker, the click handler and nearest-image lookups. + * Providers own coverage, sequences and their viewer. One source is lit at + * a time. * @param {{providers: Array, services?: object}} options */ export function createStreetLevelLayer({ providers: definitions, services = {}, }) { - const definitionsFrozen = validateProviders(definitions); + const defs = validateProviders(definitions); const state = createState({ services }); const parts = {}; const context = { state, parts }; - parts.groundCaster = services.terrain?.resolveEllipsoidalGround - ? createGroundCaster({ terrain: services.terrain }) - : null; - // Mesh samples follow the application's rules: a real bare-earth prior, - // its mesh window, and a tileset that has finished streaming. - parts.meshSampler = parts.groundCaster - ? createMeshSampler({ - getViewer: () => state.viewer, - groundAt: parts.groundCaster.groundAt, - withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null, - tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null, - }) - : null; parts.credits = createCredits(); parts.marker = createMarker(context); - parts.follow = createCameraFollow(context); + parts.framing = createCameraFraming(context); parts.router = createPickRouter(() => state.providers.values(), { positionId: POSITION_PICK_ID, }); @@ -78,7 +69,6 @@ export function createStreetLevelLayer({ state, openNearest: (point, options) => layer.openNearest(point, options), isAvailable: (entry) => providerAvailable(entry), - groundAt: parts.groundCaster?.groundAt ?? null, }); let notifyQueued = false; @@ -102,11 +92,10 @@ export function createStreetLevelLayer({ function providerContext(entry) { return Object.freeze({ services: state.services, + /** Resolved against now, so "since N days" keeps up with the clock. */ getFilter: () => resolveFilter(state.filter), + /** The layer and this provider's switch are both on. */ isActive: () => state.enabled && entry.on, - groundCaster: parts.groundCaster, - meshSampler: parts.meshSampler, - getSurface: () => state.surface, notify, actions: { /** `{frame: false}` opens it without moving the camera. */ @@ -138,13 +127,15 @@ export function createStreetLevelLayer({ } // One source at a time: the first provider that defaults on. - const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false); - for (const def of definitionsFrozen) { + const startsOn = defs.find((def) => def.defaultOn !== false); + for (const def of defs) { const entry = { def, instance: null, on: def === startsOn, status: null, + /** The status request in flight; overlapping checks share it. */ + statusCheck: null, /** The error this provider last reported, until it withdraws it. */ reportedError: null, }; @@ -155,15 +146,6 @@ export function createStreetLevelLayer({ let mapStack = null; let unsubscribeMapStack = null; - /** Follow is offered only on the Google 3D stack; leaving it stops following. */ - function syncFollowAvailability() { - const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID; - if (available === state.street.followAvailable) return; - state.street.followAvailable = available; - if (!available && state.street.follow) parts.follow.setFollow(false); - notify(); - } - /** Usable only on its map stack (Google's terms: Street View on Google 3D only). */ function providerAvailable(entry) { const required = entry.def.requiresMapStack; @@ -184,109 +166,79 @@ export function createStreetLevelLayer({ parts.viewerHost.unmount(entry.def.id); } } + remeasureGround(); notify(); } - /** Whether a ground click can open an image: the camera is at street zoom. */ + let groundRetry = null; + let groundRetries = 0; + function cancelGroundRetry() { + clearTimeout(groundRetry); + groundRetry = null; + } + + /** + * Whether a ground click can open an image: the camera is at street zoom. + * Under Google 3D the ground is unknown until its tiles load, so an unknown + * ground is measured again a few times instead of waiting for the next move. + */ function syncGroundClickReady() { - const ready = + cancelGroundRetry(); + const ready = state.enabled && groundClickHeightOk(state.viewer); + if ( state.enabled && - groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt); + !ready && + groundRetries < GROUND_RETRY_LIMIT && + groundUnderCamera(state.viewer) === null + ) { + groundRetries++; + groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS); + } if (ready === state.groundClickReady) return; state.groundClickReady = ready; notify(); } - /** - * Camera height above bare earth, for the surface mode. A cold grid cell is - * fetched once and the mode rechecked when it lands. - */ - function cameraHeightForSurface() { - const carto = state.viewer?.camera?.positionCartographic; - if (!carto) return null; - const caster = parts.groundCaster; - const height = cameraHeightAboveGround(state.viewer, { - groundAt: caster.groundAt, - }); - const lon = (carto.longitude * 180) / Math.PI; - const lat = (carto.latitude * 180) / Math.PI; - if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M) - caster.prepare([[lon, lat]]).then((ready) => { - if (ready) scheduleSurfaceSync(); - }); - return height; - } - - /** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */ - function syncSurface() { + /** A camera move or a map change measures afresh. */ + function remeasureGround() { + groundRetries = 0; syncGroundClickReady(); - const available = Boolean(parts.groundCaster) && state.enabled; - const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID; - const mode = nextSurfaceMode(state.surface, { - available, - photoreal, - heightM: available && photoreal ? cameraHeightForSurface() : null, - }); - if (mode === state.surface) return; - state.surface = mode; - parts.meshSampler?.setEnabled(mode === 'terrain'); - parts.marker.setSurface(mode); - for (const entry of state.providers.values()) - entry.instance.setSurface?.(mode); - notify(); } - let surfaceTimer = null; - function scheduleSurfaceSync() { - clearTimeout(surfaceTimer); - surfaceTimer = setTimeout(syncSurface, 150); - } - - let removeSurfaceListeners = null; - function watchSurface(viewer) { - removeSurfaceListeners?.(); - const camera = viewer?.camera; - if (!camera?.changed || !camera?.moveEnd) { - removeSurfaceListeners = null; - return; - } - const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync); - const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync); - removeSurfaceListeners = () => { - removeChanged(); - removeEnd(); - }; - } - - function unwatchSurface() { - removeSurfaceListeners?.(); - removeSurfaceListeners = null; - clearTimeout(surfaceTimer); - surfaceTimer = null; + let removeCameraListener = null; + function watchCamera(viewer) { + removeCameraListener?.(); + removeCameraListener = + viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null; } const activeEntries = () => [...state.providers.values()].filter((entry) => entry.on); - async function refreshStatus(entry) { - try { - entry.status = await entry.instance.status(); - } catch { - entry.status = null; - } - notify(); + /** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */ + function checkStatus(entry) { + entry.statusCheck ||= (async () => { + try { + entry.status = await entry.instance.status({ + signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS), + }); + } catch { + entry.status = null; + } + notify(); + })().finally(() => { + entry.statusCheck = null; + }); + return entry.statusCheck; } function activate(entry) { if (!state.viewer) return; entry.instance.activate(state.viewer); - if (!entry.status) refreshStatus(entry); - if (!providerAvailable(entry)) return; - parts.credits.show(state.viewer, entry.def); - // The map may have changed by then. - whenIdle(() => { - if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]); - }, 1500); + // Without a known key, a switch-on asks again: the server may be back, + // or have a key now. + if (entry.status?.configured !== true) checkStatus(entry); + if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def); } function deactivate(entry) { @@ -442,7 +394,6 @@ export function createStreetLevelLayer({ error: stats.error || null, color: entry.def.colors.coverage, coverageLabel: entry.def.coverageLabel || null, - highlightsSequence: entry.def.highlightsSequence !== false, sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture', groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], @@ -450,23 +401,30 @@ export function createStreetLevelLayer({ }); } + /** The selected sequence; the provider showing the open image answers first. */ function sequenceSnapshot() { - // The provider showing the open image answers first. const owner = state.providers.get(state.street.providerId); const candidates = owner ? [owner, ...[...state.providers.values()].filter((e) => e !== owner)] : [...state.providers.values()]; for (const entry of candidates) { const stats = entry.instance.sequenceStats?.(); - if (stats?.selectedId || stats?.loading) + if (stats?.selectedId || stats?.loading || stats?.error) return { providerId: entry.def.id, selectedId: stats.selectedId || null, images: stats.images || 0, loading: stats.loading === true, + error: stats.error || null, }; } - return { providerId: null, selectedId: null, images: 0, loading: false }; + return { + providerId: null, + selectedId: null, + images: 0, + loading: false, + error: null, + }; } function getUIState() { @@ -478,7 +436,6 @@ export function createStreetLevelLayer({ providers: providerSnapshots(), street, sequence: sequenceSnapshot(), - surface: state.surface, }); // The lit source cannot run on this map: say so, not a blank panel. const stranded = [...state.providers.values()].find( @@ -503,15 +460,13 @@ export function createStreetLevelLayer({ statsRefreshInterval: 1000, /** The key the switched-on providers share (or null), for the layer row. */ get requiresKeyId() { - const on = [...state.providers.values()].filter((entry) => entry.on); - return requiresKeyIdFor( - on.length ? on.map((entry) => entry.def) : definitionsFrozen, - ); + const on = activeEntries(); + return requiresKeyIdFor(on.length ? on.map((entry) => entry.def) : defs); }, /** Registered providers, in chip order. */ - providerIds: definitionsFrozen.map((def) => def.id), + providerIds: defs.map((def) => def.id), getSourceAvailability() { - const available = definitionsFrozen.length > 0; + const available = defs.length > 0; return { available, source: STREET_LEVEL_LAYER_ID, @@ -522,15 +477,12 @@ export function createStreetLevelLayer({ }, init(viewer) { - if (state.initialized) - throw new Error('Street Level layer is already initialized'); state.viewer = viewer; - state.initialized = true; parts.marker.ensure(viewer); parts.marker.setVisible(false); for (const entry of state.providers.values()) { entry.instance.init(viewer); - refreshStatus(entry); + checkStatus(entry); } console.log('[Data:StreetLevel] Initialized'); }, @@ -541,18 +493,20 @@ export function createStreetLevelLayer({ parts.marker.setVisible(true); parts.selection.install(viewer); for (const entry of activeEntries()) activate(entry); - watchSurface(viewer); - syncSurface(); + watchCamera(viewer); + remeasureGround(); notify(); }, disable() { state.enabled = false; abortNearest(); - unwatchSurface(); + removeCameraListener?.(); + removeCameraListener = null; + cancelGroundRetry(); parts.viewerHost.unmount(); for (const entry of state.providers.values()) entry.instance.deactivate(); - syncSurface(); + syncGroundClickReady(); parts.credits.hideAll(state.viewer); parts.selection.uninstall(); parts.marker.setVisible(false); @@ -568,27 +522,22 @@ export function createStreetLevelLayer({ for (const entry of state.providers.values()) entry.instance.destroy(viewer); parts.marker.destroy(viewer); - parts.meshSampler?.destroy(); - parts.follow.destroy(); + parts.framing.attachNavigation(null); unsubscribeMapStack?.(); unsubscribeMapStack = null; mapStack = null; state.listeners.clear(); state.viewer = null; - state.initialized = false; - state.destroyed = true; }, getStats() { // The lifecycle polls this every second: summarise, don't snapshot. + // English data: the layer panel translates these through translateKnown. const snapshots = providerSnapshots(); const coverage = summarizeCoverage(snapshots); - // English data: the layer panel translates these through translateKnown. + const keyLabel = keyStatusLabel(coverage); let loadingLabel = ''; - const keyLabel = coverage.keyRejected - ? msg('KEY REJECTED') - : msg('KEY REQUIRED'); - if (coverage.keyRequired) loadingLabel = keyLabel; + if (keyLabel) loadingLabel = keyLabel; else if (coverage.loading) loadingLabel = msg('loading coverage...'); else if (coverage.hint && state.enabled) loadingLabel = coverage.hint; return { @@ -597,7 +546,8 @@ export function createStreetLevelLayer({ loading: coverage.loading, keyRequired: coverage.keyRequired, keyAlternatives: keyAlternatives(snapshots), - error: coverage.keyRequired + // A rejected key's message names the fix; a missing one says so. + error: keyLabel ? coverage.keyRejected ? coverage.error || keyLabel : keyLabel @@ -606,30 +556,18 @@ export function createStreetLevelLayer({ }; }, - /** - * The application's camera authority: `run(noun, move)`, the deferred - * `begin(noun)` / `reassert(generation)` pair and - * `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a - * photo claims it when opening starts and frames only if it still owns - * it once loaded. FOLLOW stops when another feature takes the camera. - */ + /** A photo frames itself only if it still owns the camera once loaded. */ attachNavigation(navigation) { - parts.follow.attachNavigation(navigation); + parts.framing.attachNavigation(navigation); }, - /** The application map stack; FOLLOW is available only on Google 3D. */ + /** The application map stack; Street View is usable only on Google 3D. */ attachMapStackController(controller) { unsubscribeMapStack?.(); mapStack = controller || null; unsubscribeMapStack = - mapStack?.subscribe?.(() => { - syncFollowAvailability(); - syncProviderAvailability(); - syncSurface(); - }) || null; - syncFollowAvailability(); + mapStack?.subscribe?.(syncProviderAvailability) || null; syncProviderAvailability(); - syncSurface(); }, /** Share-link and stored state: provider switches plus the filter. */ @@ -640,6 +578,7 @@ export function createStreetLevelLayer({ }); }, + /** Unknown providers and malformed values are ignored, so any link applies. */ setParams(params = {}) { const decoded = decodeParams(params, { providerIds: state.providers.keys(), @@ -666,23 +605,14 @@ export function createStreetLevelLayer({ /** The DOM element provider viewers render into. */ attachViewerHost(element) { parts.viewerHost.attach(element); - if (element && state.enabled) - whenIdle( - () => - parts.viewerHost.prewarm( - activeEntries().filter((entry) => providerAvailable(entry)), - ), - 1500, - ); }, setProviderEnabled, - /** Imagery filter for coverage, cones and nearest-image lookups. */ - setCoverageFilter, openImage, /** * Open the nearest image of the active providers (or `providerIds`) near * a point, else the view centre, or with `aim: 'screen-centre'` the ground - * under the screen centre (sky: nothing). + * under the screen centre (sky: nothing). Providers without + * `nearestImage` are skipped. */ async openNearest( point, @@ -712,6 +642,7 @@ export function createStreetLevelLayer({ let lastError = null; const asked = (entry) => (!providerIds || providerIds.includes(entry.def.id)) && + typeof entry.instance.nearestImage === 'function' && providerAvailable(entry); const entries = activeEntries().filter(asked); for (const entry of entries) { @@ -751,35 +682,20 @@ export function createStreetLevelLayer({ }) : t( 'No street-level imagery within {radius} m of the view centre', - { - radius: NEAREST_RADIUS_M, - }, + { radius: NEAREST_RADIUS_M }, )); state.street.loading = false; notify(); return false; }, - /** Close the image and deselect it everywhere on the map. */ + /** Close the image and deselect its sequence on the map. */ closeViewer() { abortNearest(); parts.viewerHost.close(); parts.clearSequences(); }, setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode), - setFollow: (enabled) => parts.follow.setFollow(enabled), resizeViewer: () => parts.viewerHost.resize(), - selectSequence( - sequenceId, - providerId = state.providers.keys().next().value, - ) { - return state.providers - .get(providerId) - ?.instance.selectSequence?.(sequenceId); - }, - clearSequence: () => parts.clearSequences(), - refreshCoverage() { - for (const entry of activeEntries()) entry.instance.refreshCoverage(); - }, }; return layer; } diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index d52570c94..6c79a52ca 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -5,9 +5,306 @@ import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { createMapillaryProvider } from './providers/mapillary/index.js'; import { createKartaViewProvider } from './providers/kartaview/index.js'; -import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs'; +import { + fakeCesiumViewer, + fakeMapillarySource, + fakePhotoViewer, + fakeStreetLevelProvider as fakeProvider, +} from '../../testSupport/streetLevelFakes.mjs'; import { setLocale } from '../../i18n/index.js'; +/** + * A layer over `source` and `photoViewer`, initialised on a stand-in viewer + * and (unless `enable` is false) enabled. + */ +async function startLayer( + t, + { + source = fakeMapillarySource(), + photoViewer = fakePhotoViewer(), + providers = [createMapillaryProvider({ source, photoViewer })], + view = false, + enable = true, + } = {}, +) { + const saved = globalThis.document; + // The click handler listens for Esc on the document. + globalThis.document = new EventTarget(); + // Skip Cesium's one-time terrain table download for draped lines. + t.mock.method( + Cesium.GroundPolylinePrimitive, + 'initializeTerrainHeights', + async () => {}, + ); + const viewer = fakeCesiumViewer({ view }); + const layer = createStreetLevelLayer({ providers }); + layer.init(viewer); + if (enable) layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + await settle(); + return { layer, viewer, source, photoViewer }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +/** A photo viewer reporting a pose for every image it opens. */ +const posingViewer = () => + fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } }); + +test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => { + const { layer, viewer } = await startLayer(t); + const shown = () => viewer.credits.map((credit) => credit.html); + assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable'); + assert.match(shown()[0], /CC BY-SA 4\.0/); + layer.setParams({ mapillary: false }); + assert.deepEqual(shown(), [], 'hidden with the switch'); + layer.setParams({ mapillary: true }); + assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]); + layer.disable(); + assert.deepEqual(shown(), [], 'the credit goes with the layer'); +}); + +test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => { + for (const configured of [false, true]) { + const status = deferred(); + const source = fakeMapillarySource({ getStatus: () => status.promise }); + const { layer } = await startLayer(t, { source, view: true }); + assert.equal(source.calls.tiles.length, 0, 'nothing before the status'); + status.resolve({ configured }); + await settle(); + if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage'); + else { + assert.equal( + source.calls.tiles.length, + 0, + 'a key-less install never asks', + ); + assert.deepEqual( + [layer.getStats().loadingLabel, layer.getStats().error], + ['KEY REQUIRED', 'KEY REQUIRED'], + ); + assert.equal(layer.getUIState().keyRequired, true); + assert.equal(layer.getUIState().keyRejected, false); + } + layer.destroy(); + } +}); + +test('a key status that failed is asked again on the next switch-on', async (t) => { + let up = false; + let asked = 0; + const source = fakeMapillarySource({ + hasToken: () => false, + getStatus: async () => { + asked++; + if (!up) throw new TypeError('fetch failed'); + return { configured: true }; + }, + }); + const { layer, viewer } = await startLayer(t, { source, view: true }); + assert.equal(asked, 1, 'one check for init and the first switch-on'); + assert.equal(layer.getUIState().keyRequired, true); + up = true; // the server is reachable again + layer.disable(); + layer.enable(viewer); + await settle(); + assert.equal(asked, 2); + assert.equal(layer.getUIState().keyRequired, false); + assert.ok(source.calls.tiles.length > 0, 'coverage loads'); + // A known key is not asked again. + layer.disable(); + layer.enable(viewer); + await settle(); + assert.equal(asked, 2); +}); + +test('a key status that never answers gives up, so the next switch-on asks again', async (t) => { + // The check's timeout, fired when the test says. + const timeouts = []; + t.mock.method(AbortSignal, 'timeout', (ms) => { + const controller = new AbortController(); + timeouts.push({ ms, fire: () => controller.abort(new Error('timed out')) }); + return controller.signal; + }); + let asked = 0; + const source = fakeMapillarySource({ + hasToken: () => false, + getStatus: ({ signal } = {}) => + new Promise((_, reject) => { + asked++; + signal?.addEventListener('abort', () => reject(signal.reason)); + }), + }); + const { layer, viewer } = await startLayer(t, { source, view: true }); + assert.equal(asked, 1); + assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short'); + assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile'); + timeouts[0].fire(); + await settle(); + assert.equal(layer.getUIState().coverage.loading, false); + assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED'); + layer.disable(); + layer.enable(viewer); + assert.equal(asked, 2, 'asked again, not stuck on the first'); +}); + +test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => { + const rejected = Object.assign(new Error('Mapillary rejected the token'), { + keyRejected: true, + }); + const source = fakeMapillarySource({ + getTile: async () => { + throw rejected; + }, + }); + const { layer, viewer } = await startLayer(t, { source, view: true }); + await settle(); + const stats = layer.getStats(); + assert.equal(stats.keyRequired, true); + assert.equal(stats.loadingLabel, 'KEY REJECTED'); + assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/); + const ui = layer.getUIState(); + assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]); + layer.disable(); + layer.enable(viewer); + assert.equal(layer.getUIState().keyRejected, false, 'asked again once on'); +}); + +test('switching Mapillary off clears coverage and closes the image it shows', async (t) => { + const { layer, photoViewer } = await startLayer(t); + layer.attachViewerHost({}); + assert.equal(await layer.openImage('mapillary', 'img1'), true); + assert.deepEqual(photoViewer.calls.open, ['img1']); + assert.equal(layer.getUIState().street.open, true); + layer.setParams({ mapillary: false }); + assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released'); + assert.equal(layer.getUIState().street.open, false); + assert.equal(layer.getUIState().providers[0].on, false); +}); + +test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => { + const source = fakeMapillarySource({ + getSequenceImages: async () => { + throw new Error('Sequence images unavailable'); + }, + }); + // The photo's pose names its sequence, which opening selects. + const photoViewer = fakePhotoViewer({ + pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' }, + }); + const { layer } = await startLayer(t, { source, photoViewer }); + layer.attachViewerHost({}); + assert.equal(await layer.openImage('mapillary', 'img1'), true); + await settle(); + let ui = layer.getUIState(); + assert.equal(ui.sequence.error, 'Sequence images unavailable'); + assert.equal(ui.street.error, null, 'the photo itself is fine'); + layer.closeViewer(); + ui = layer.getUIState(); + assert.equal(ui.sequence.error, null, 'withdrawn with the sequence'); +}); + +test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => { + const { layer, viewer } = await startLayer(t, { + photoViewer: posingViewer(), + }); + layer.attachViewerHost({}); + assert.equal(await layer.openImage('mapillary', 'img1'), true); + assert.equal(viewer.flights.started, 1, 'the photo is framed'); + layer.closeViewer(); + assert.equal(viewer.flights.cancelled, 1, 'closed'); + + await layer.openImage('mapillary', 'img2'); + layer.disable(); + assert.equal(viewer.flights.cancelled, 2, 'layer off'); + + layer.enable(viewer); + await layer.openImage('mapillary', 'img3'); + layer.destroy(); + assert.equal(viewer.flights.cancelled, 3, 'destroyed'); +}); + +test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => { + const { layer } = await startLayer(t); + assert.deepEqual(layer.getParams(), { + mapillary: true, + pano: 'all', + sinceDays: 0, + }); + layer.setParams({ pano: 'flat', sinceDays: 365 }); + assert.equal(layer.getParams().sinceDays, 365); + layer.setParams({ sinceDays: 0 }); + assert.deepEqual(layer.getParams(), { + mapillary: true, + pano: 'flat', + sinceDays: 0, + }); + layer.setParams({ mapillary: false }); + assert.equal(layer.getParams().mapillary, false); +}); + +test('setParams ignores unknown keys and malformed values', async (t) => { + const { layer } = await startLayer(t); + layer.setParams({ pano: 'pano', sinceDays: 730 }); + layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 }); + layer.setParams({ sinceDays: -3 }); + layer.setParams(null); + assert.deepEqual(layer.getParams(), { + mapillary: true, + pano: 'pano', + sinceDays: 730, + }); +}); + +test('switching Mapillary on while the layer is off draws nothing', async (t) => { + const { layer, viewer, source, photoViewer } = await startLayer(t, { + view: true, + enable: false, + }); + layer.attachViewerHost({}); + layer.setParams({ mapillary: false }); + layer.setParams({ mapillary: true }); + await settle(); + assert.equal(source.calls.tiles.length, 0, 'no coverage drawn'); + assert.deepEqual(viewer.credits, [], 'no credit shown'); + assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up'); + assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept'); + // Enabling the layer is what draws it. + layer.enable(viewer); + await settle(); + assert.ok(source.calls.tiles.length > 0); + assert.equal(viewer.credits.length, 1); +}); + +test('an empty provider composition enables and tears down without acquiring imagery', async (t) => { + const { layer, viewer } = await startLayer(t, { providers: [] }); + assert.deepEqual(layer.providerIds, []); + assert.deepEqual(layer.getUIState().providers, []); + assert.equal(layer.getUIState().keyRequired, false); + assert.match( + layer.getUIState().coverage.hint, + /No street-level imagery providers/, + ); + layer.disable(); + layer.enable(viewer); + assert.deepEqual(layer.getUIState().providers, []); +}); + +// ── Several providers: one source at a time, Google 3D, nearest lookups ── + /** * A stand-in Cesium viewer the layer can be enabled on: a canvas for the * click handler, camera events, a primitive list and a credit display. @@ -55,8 +352,11 @@ function fakeViewer() { }, creditDisplay: { addStaticCredit: (credit) => credits.push(credit), - removeStaticCredit: (credit) => - credits.splice(credits.indexOf(credit), 1), + removeStaticCredit: (credit) => { + // Cesium ignores a credit it does not hold. + const at = credits.indexOf(credit); + if (at !== -1) credits.splice(at, 1); + }, }, }; } @@ -81,19 +381,6 @@ async function enabledLayer(t, providers = [fakeProvider()]) { return { layer, viewer }; } -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -function deferred() { - let resolve; - let reject; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - return { promise, resolve, reject }; -} - -/** A provider whose viewer reports a pose for every image it opens. */ function posingProvider(options) { const provider = fakeProvider(options); const create = provider.create; @@ -148,35 +435,6 @@ function fakeMapStack(initial) { listenerCount: () => listeners.size, }; } - -test('FOLLOW is available only while the Google 3D map stack is active', () => { - const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); - assert.equal( - layer.getUIState().street.followAvailable, - false, - 'unknown stack: off', - ); - - const stack = fakeMapStack('esri-imagery'); - layer.attachMapStackController(stack); - assert.equal(layer.getUIState().street.followAvailable, false); - layer.setFollow(true); - assert.equal(layer.getUIState().street.follow, false, 'refused on Esri'); - - stack.switchTo('photoreal'); - assert.equal(layer.getUIState().street.followAvailable, true); - layer.setFollow(true); - assert.equal(layer.getUIState().street.follow, true); - - stack.switchTo('osm'); - assert.equal(layer.getUIState().street.followAvailable, false); - assert.equal( - layer.getUIState().street.follow, - false, - 'leaving Google 3D stops following', - ); -}); - test('the layer lets go of the map stack when destroyed or re-attached', () => { const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); const first = fakeMapStack('photoreal'); @@ -190,60 +448,6 @@ test('the layer lets go of the map stack when destroyed or re-attached', () => { assert.equal(second.listenerCount(), 0); }); -test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => { - const { layer, viewer } = await enabledLayer(t); - const shown = () => viewer.credits.map((credit) => credit.html); - assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate'); - assert.match(shown()[0], /CC BY-SA 4\.0/); - layer.setProviderEnabled('mapillary', false); - assert.deepEqual(shown(), [], 'hidden with the provider'); - layer.setProviderEnabled('mapillary', true); - assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]); - layer.disable(); - assert.deepEqual(shown(), [], 'every credit goes with the layer'); -}); - -test('getStats tells a rejected key from a missing one', async (t) => { - const provider = fakeProvider(); - const { layer } = await enabledLayer(t, [provider]); - provider.stats.loading = true; - assert.equal(layer.getStats().loadingLabel, 'loading coverage...'); - assert.equal(layer.getStats().keyRequired, false); - - provider.stats.keyRequired = true; - assert.deepEqual( - [layer.getStats().loadingLabel, layer.getStats().error], - ['KEY REQUIRED', 'KEY REQUIRED'], - ); - - // A rejected key gates the provider exactly like a missing one. - provider.stats.keyRequired = false; - provider.stats.keyRejected = true; - provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN'; - const stats = layer.getStats(); - assert.equal(stats.keyRequired, true); - assert.equal(stats.loadingLabel, 'KEY REJECTED'); - assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN'); - const ui = layer.getUIState(); - assert.equal(ui.keyRequired, true); - assert.equal(ui.keyRejected, true); - assert.equal(ui.providers[0].keyRequired, true); -}); - -test('switching a provider off deactivates it and closes the image it shows', async (t) => { - const provider = fakeProvider(); - const { layer } = await enabledLayer(t, [provider]); - layer.attachViewerHost({}); - assert.equal(await layer.openImage('mapillary', 'img1'), true); - assert.deepEqual(provider.calls.open, ['img1']); - assert.equal(layer.getUIState().street.open, true); - layer.setProviderEnabled('mapillary', false); - assert.equal(provider.calls.deactivate, 1); - assert.equal(provider.calls.unmount, 1, 'its viewer is released'); - assert.equal(layer.getUIState().street.open, false); - assert.equal(layer.getUIState().street.providerId, null); -}); - test('openNearest without the panel reports it instead of loading forever', async (t) => { const provider = fakeProvider({ nearestImage: async () => 'img1' }); const { layer } = await enabledLayer(t, [provider]); @@ -299,40 +503,6 @@ test('a provider withdraws only the error it reported', async (t) => { actions.reportError(null); assert.match(layer.getUIState().street.error, /Open the Street Level panel/); }); - -test('closing the photo stops the globe flying to it', async (t) => { - const { layer, viewer } = await enabledLayer(t, [posingProvider()]); - layer.attachViewerHost({}); - assert.equal(await layer.openImage('mapillary', 'img1'), true); - assert.equal(viewer.flights.started, 1, 'the photo is framed'); - layer.closeViewer(); - assert.equal(viewer.flights.cancelled, 1); -}); - -test('switching the layer off or destroying it stops the framing flight', async (t) => { - const { layer, viewer } = await enabledLayer(t, [posingProvider()]); - layer.attachViewerHost({}); - await layer.openImage('mapillary', 'img1'); - layer.disable(); - assert.equal(viewer.flights.cancelled, 1, 'layer off'); - - layer.enable(viewer); - await layer.openImage('mapillary', 'img2'); - layer.destroy(); - assert.equal(viewer.flights.cancelled, 2, 'destroyed'); -}); - -test('closing the photo leaves a newer navigation flight alone', async (t) => { - const { layer, viewer } = await enabledLayer(t, [posingProvider()]); - layer.attachViewerHost({}); - await layer.openImage('mapillary', 'img1'); - assert.equal(viewer.flights.started, 1, 'the photo is being framed'); - // A search result flies the globe elsewhere before the framing lands. - viewer.camera.flyToBoundingSphere(null, {}); - layer.closeViewer(); - assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going'); -}); - test('an older nearest lookup that answers late cannot replace a newer one', async (t) => { const { lookups, nearestImage } = slowLookups(); const provider = fakeProvider({ nearestImage }); @@ -395,54 +565,6 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too'); }); -test('the Mapillary provider hands the lookup signal to its source', async () => { - const requests = []; - const source = { - hasToken: () => true, - getStatus: async () => ({ configured: true }), - getTile: async () => new Uint8Array(0), - getSequenceImages: async () => [], - nearestImages: async (query, options) => { - requests.push(options); - return [{ id: 7, is_pano: false, captured_at: 10 }]; - }, - }; - const instance = createMapillaryProvider({ source }).create({ - services: {}, - getFilter: () => ({ pano: 'all', sinceMs: null }), - isActive: () => true, - notify() {}, - actions: { openImage() {}, reportError() {} }, - }); - const { signal } = new AbortController(); - assert.equal( - await instance.nearestImage({ lat: 1, lon: 2 }, { signal }), - '7', - ); - assert.equal(requests[0]?.signal, signal); -}); - -test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => { - const provider = fakeProvider(); - const { layer } = await enabledLayer(t, [provider]); - layer.setParams({ pano: 'flat', sinceDays: 365 }); - assert.equal(layer.getParams().sinceDays, 365); - layer.setParams({ sinceDays: 0 }); - assert.deepEqual(layer.getParams(), { - mapillary: true, - pano: 'flat', - sinceDays: 0, - }); - assert.equal( - provider.filters.at(-1).sinceMs, - null, - 'providers drop the cut-off', - ); - layer.setParams({ mapillary: false }); - assert.equal(layer.getParams().mapillary, false); -}); - -/** A layer initialised on a stand-in viewer but never enabled. */ function initialisedLayer(t, providers) { const saved = globalThis.document; const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); @@ -635,7 +757,7 @@ test('one imagery source at a time: lighting one darkens the other, either way', assert.deepEqual(lit(), []); }); -test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => { +test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => { const { layer, viewer, stack } = await withStreetView(t, { mapStack: 'esri-imagery', }); @@ -646,7 +768,6 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc const credited = () => viewer.credits.some((credit) => /Street View imagery/.test(credit.html)); assert.deepEqual(listed(), ['mapillary'], 'no Street View chip'); - assert.ok(!ui().legend.some((item) => item.key === 'google')); assert.equal(credited(), false, 'no Google credit on another map'); assert.equal(layer.source, 'Mapillary'); assert.equal(layer.getStats().source, 'Mapillary'); @@ -668,6 +789,62 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc assert.deepEqual(listed(), ['mapillary']); }); +test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => { + const saved = globalThis.document; + globalThis.document = new EventTarget(); + t.after(() => { + globalThis.document = saved; + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const viewer = fakeViewer(); + // 4 km up the ellipsoid, 2 km over a city whose tiles are still loading. + let ground; + viewer.camera.positionCartographic = { height: 4000 }; + viewer.scene.globe = { show: false }; + viewer.scene.sampleHeightSupported = true; + viewer.scene.sampleHeight = () => ground; + const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => layer.destroy()); + assert.equal(layer.getUIState().groundClickReady, false); + + ground = 2000; + t.mock.timers.tick(1000); + assert.equal(layer.getUIState().groundClickReady, true); +}); + +test('an unknown ground is measured only a few times, and not after the layer is off', (t) => { + const saved = globalThis.document; + globalThis.document = new EventTarget(); + t.after(() => { + globalThis.document = saved; + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const viewer = fakeViewer(); + let samples = 0; + viewer.camera.positionCartographic = { height: 4000 }; + viewer.scene.globe = { show: false }; + viewer.scene.sampleHeightSupported = true; + viewer.scene.sampleHeight = () => { + samples++; + return undefined; + }; + const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => layer.destroy()); + t.mock.timers.tick(60_000); + const measured = samples; + assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`); + + viewer.camera.moveEnd.raiseEvent(); + layer.disable(); + const atDisable = samples; + t.mock.timers.tick(60_000); + assert.equal(samples, atDisable, 'no measuring once the layer is off'); +}); + test('the UI state says when a click (or the button) can open Street View here', async (t) => { const { layer } = await withStreetView(t); assert.equal(typeof layer.getUIState().groundClickReady, 'boolean'); @@ -825,20 +1002,6 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl assert.equal(layer.getStats().keyRequired, false); }); -test('an empty provider composition enables and tears down without acquiring imagery', async (t) => { - const { layer, viewer } = await enabledLayer(t, []); - assert.deepEqual(layer.providerIds, []); - assert.deepEqual(layer.getUIState().providers, []); - assert.equal(layer.getUIState().keyRequired, false); - assert.match( - layer.getUIState().coverage.hint, - /No street-level imagery providers/, - ); - layer.disable(); - layer.enable(viewer); - assert.deepEqual(layer.getUIState().providers, []); -}); - test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => { const failing = fakeProvider({ nearestImage: async () => { @@ -890,7 +1053,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav assert.equal(viewer.flights.started, 0, 'the user picked the spot'); }); -test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => { +test('KartaView through the core: its lines counted as streets', async (t) => { const kartaView = createKartaViewProvider({ source: { getTile: async () => ({ lines: [] }), @@ -906,15 +1069,7 @@ test('KartaView through the core: its own swatch without Selected, its lines cou layer.setProviderEnabled('kartaview', true); await settle(); const ui = layer.getUIState(); - assert.deepEqual( - ui.legend.map((entry) => entry.key), - ['kartaview'], - ); assert.equal(ui.coverage.label?.noun, 'streets'); - assert.equal( - ui.providers.find((p) => p.id === 'kartaview').highlightsSequence, - false, - ); }); test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => { @@ -962,3 +1117,13 @@ test('switching source by params clears a failed lookup’s error; reapplying th second.setParams({ mapillary: false, other: true }); assert.equal(second.getUIState().street.error, kept); }); + +test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => { + const { presentStreetLevelPanel } = + await import('../../ui/streetLevelPresentation.js'); + const { layer, stack } = await withStreetView(t); + layer.setProviderEnabled('google', true); + stack.switchTo('esri-imagery'); + const view = presentStreetLevelPanel(layer.getUIState()); + assert.match(view.meta, /Google Street View needs the Google 3D map/); +}); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index fbd992f88..bab1af46d 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -1,105 +1,148 @@ import * as Cesium from 'cesium'; import { passesImageryFilter } from './filter.js'; -import { - createHorizonCull, - groundUnderCamera, - metresBetween, - viewFocus, - visibleBbox, - whenIdle, -} from './view.js'; -import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js'; +import { groundUnderCamera, viewFocus, visibleBbox } from './view.js'; +import { tilesForBbox } from './tileMath.js'; import { msg } from '../../i18n/index.js'; -import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js'; -import { tileBounds, tilesForBbox } from './tileMath.js'; -/** A tile's shared point cap must move by this much to redraw it. */ -const SHARE_HYSTERESIS = 0.1; -/** Draped lines stay at most this long while a tile's cast lines build. */ -export const SWAP_MAX_WAIT_MS = 4000; -/** Tiles touched by new mesh samples are redrawn at most this often. */ -const REMESH_INTERVAL_MS = 1500; -/** Gap between redrawing one dirty tile and the next. */ -const REMESH_STAGGER_MS = 120; -/** Old-zoom tiles are kept at most this long after a zoom change. */ -const STALE_TILE_MAX_MS = 6000; /** Sequences per primitive: smaller batches build, and show, sooner. */ const SEQUENCE_PRIMITIVE_BATCH = 120; -/** Separates a multi-part sequence's part index from its id in pick ids. */ -const PART_SEPARATOR = '~'; +/** Hint above the street-zoom ceiling, where no coverage is drawn. */ +export const ZOOM_IN_HINT = msg('Zoom in to see street-level coverage'); +/** Hint when no ground is in view within range (looking at the sky). */ +const NO_GROUND_HINT = msg( + 'Point the camera at the ground for street-level coverage', +); -/** Pick ids for every part of a sequence; the first part is unsuffixed. */ -function partIds(sequence, prefix) { - return sequence.parts.map((_, index) => - index - ? `${prefix}${sequence.id}${PART_SEPARATOR}${index}` - : `${prefix}${sequence.id}`, - ); -} - -/** Sequence id for any part's pick id (`[~]`), else null. */ +/** Sequence id for a line's pick id (``), else null. */ export function sequenceIdFromPick(pickId, prefix) { if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix)) return null; - const rest = pickId.slice(prefix.length); - const cut = rest.indexOf(PART_SEPARATOR); - return cut === -1 ? rest : rest.slice(0, cut); + return pickId.slice(prefix.length) || null; } /** - * Whether any point of `box` ({west, south, east, north}) is within the mesh - * sampler's range of `centre`, the ground point under the camera. + * A provider's coverage bookkeeping, before the first refresh. A provider + * with a key waits for its status (`blocked: 'status'`); a keyless one + * draws at once. */ -export function meshBoxInRange(box, centre) { - // Move the camera's longitude to the box's side of the date line first: - // clamping 179.999 into a box at -179.99 would pick its far edge. - const lon = - centre.lon + - 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); - const nearest = { - lon: Math.min(box.east, Math.max(box.west, lon)), - lat: Math.min(box.north, Math.max(box.south, centre.lat)), +export function freshCoverage({ keyless = false } = {}) { + return { + zoom: null, + /** Current zoom's tiles by `z/x/y` key. */ + tiles: new Map(), + /** The selected sequence drawn over the coverage, or null. */ + highlight: null, + /** Tile key → its request's controller; any entry means LOADING. */ + pending: new Map(), + lastError: null, + /** + * Why tile requests are paused, or null: 'status' until the key status + * answers, 'no-key', 'rejected' (until the provider goes off) or + * 'rate-limited' (until `blockTimer` fires). + */ + blocked: keyless ? null : 'status', + blockTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', }; - return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; +} + +/** Line instances for sequences' parts, every part picking its sequence. */ +function lineInstances(sequences, color, width, prefix) { + const attributes = { + color: Cesium.ColorGeometryInstanceAttribute.fromColor(color), + }; + const instances = []; + for (const sequence of sequences) + for (const coordinates of sequence.parts) { + let positions; + try { + positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); + } catch { + continue; + } + if (positions.length < 2) continue; + instances.push( + new Cesium.GeometryInstance({ + geometry: new Cesium.GroundPolylineGeometry({ positions, width }), + id: `${prefix}${sequence.id}`, + attributes, + }), + ); + } + return instances; +} + +/** Draped lines that follow terrain and 3D tiles (Google 3D) alike. */ +function linePrimitive(geometryInstances, asynchronous) { + return new Cesium.GroundPolylinePrimitive({ + geometryInstances, + appearance: new Cesium.PolylineColorAppearance(), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous, + allowPicking: true, + }); +} + +function pointsOf(sequence) { + let count = 0; + for (const part of sequence.parts) count += part.length; + return count; } /** * @typedef {object} LineCoverageTuning - * @property {number} maxTiles Line tiles drawn at once. - * @property {number} overviewMaxTiles Overview tiles drawn at once. + * @property {number} maxTiles Tiles drawn at once. * @property {number} maxSequences Lines drawn at once, over every tile. - * @property {number} lineWidthPx - * @property {number} overviewPointPx - * @property {number} moveDebounceMs Camera rest before the tile set is recomputed. - * @property {number} viewRangeMinM Street-zoom coverage reaches at least this far… - * @property {number} viewRangePerHeight …or camera height times this. - * @property {number} viewNearM Ground always covered around a tilted camera. - * @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first). * @property {number} [maxPoints] Line points drawn at once, over every tile (default: no limit). + * @property {number} lineWidthPx + * @property {number} moveDebounceMs Camera rest before the tile set is recomputed. + * @property {number} viewRangeMinM Coverage reaches at least this far… + * @property {number} viewRangePerHeight …or camera height times this. + * @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first). */ /** - * Camera-driven coverage from any XYZ line-tile source: overview points from - * orbit, sequence lines near the ground. In terrain surface mode a tile is - * drawn draped first, then swapped for lines cast to the bare earth (draped - * lines would land on roofs), so coverage never waits on terrain heights. + * The tile zoom a provider's `zoomFor` answered, or null. Providers written + * before the core went slim return `{zoom, kind}`: its zoom is read, and an + * `'overview'` level draws nothing, since nothing is drawn from orbit. Any + * other answer that is not a whole zoom draws nothing too, rather than + * asking for no tiles without a hint. + * @param {unknown} level + * @returns {number|null} + */ +export function coverageZoom(level) { + const zoom = + level && typeof level === 'object' + ? level.kind === 'overview' + ? null + : level.zoom + : level; + return Number.isInteger(zoom) && zoom >= 0 ? zoom : null; +} + +/** + * Camera-driven sequence lines from any XYZ line-tile source, near the + * ground only, draped on terrain and 3D tiles alike. Nothing is drawn from + * orbit. * * @param {object} options - * @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags. - * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array, overview: Array}>} options.fetchTile - * A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview - * points `{lon, lat, capturedAt, isPano}`. A rejection may carry - * `keyRequired`, `keyRejected` or `retryAfterSec`. - * @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor - * Tile zoom for a camera height above ground; null draws nothing. + * @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage` (freshCoverage), `sequence`. + * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array}>} options.fetchTile + * A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A + * rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`. + * @param {(heightM: number|null) => number|{zoom: number, kind?: string}|null} options.zoomFor + * Tile zoom for a camera height above ground; null draws nothing. The + * older `{zoom, kind}` shape is still read (see `coverageZoom`). * @param {{coverage: string, selected: string}} options.colors * @param {string} options.pickPrefix Pick-id prefix of the lines. * @param {string} options.renderKey Render-governor reason. * @param {LineCoverageTuning} options.tuning * @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages - * @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point. - * @param {(point: object) => number} [options.overviewPointSize] An overview point's size in pixels (default `tuning.overviewPointPx`). + * @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line. */ export function createLineCoverage({ state, @@ -111,7 +154,6 @@ export function createLineCoverage({ tuning, messages, passes = passesImageryFilter, - overviewPointSize = null, }) { const { render } = state.services; const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles); @@ -119,13 +161,6 @@ export function createLineCoverage({ ? Math.floor(tuning.maxPoints / tuning.maxTiles) : Infinity; - /** Colour for a line: the provider's, GEV cyan while selected. */ - function sequenceColor({ selected = false } = {}) { - return selected - ? Cesium.Color.fromCssColorString(colors.selected) - : Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92); - } - function requestRender() { render?.governorRequestRender?.(renderKey); } @@ -144,506 +179,115 @@ export function createLineCoverage({ return state.context.getFilter(); } - function terrainMode() { - return ( - state.context.getSurface?.() === 'terrain' && - Boolean(state.context.groundCaster) - ); - } - /** * A tile's sequences, filtered then capped: capping first could leave a - * dense tile of newer flat captures with no 360° lines at all. `measure` - * only records what the lines need, for a draw that follows. + * dense tile of newer flat captures with no 360° lines at all. A line past + * the point budget is skipped; a shorter one may still fit. The selected + * sequence is the answer to a click: it is drawn whatever the caps leave + * out, and not counted against them. */ - function drawnSequences(entry, { measure = false } = {}) { + function drawnSequences(entry) { const drawn = []; const current = filter(); const selectedId = state.sequence.selectedId ?? null; - const cap = entry.pointCap ?? perTilePointCap; let points = 0; - let demand = 0; + let counted = 0; for (const sequence of entry.sequenceList) { if (!passes(sequence, current)) continue; - const size = pointsOf(sequence); - demand += size; - // The selected sequence is always drawn: its highlight is the answer - // to a click, whatever the caps leave out. It is outside the cap, so - // it is not counted against it. if (sequence.id === selectedId) { drawn.push(sequence); continue; } - if (drawn.length >= perTileSequenceCap) continue; - // A line past the point budget is skipped; a shorter one may still fit. - if (points + size > cap) continue; + if (counted >= perTileSequenceCap) continue; + const size = pointsOf(sequence); + if (points + size > perTilePointCap) continue; points += size; + counted++; drawn.push(sequence); } - // What every passing line would take: the budget is shared by it. - entry.pointDemand = demand; - if (!measure) { - entry.pointsDrawn = points; - // The cap this draw used: a share is judged against it, not against - // later shares that redrew nothing (small steps add up). - entry.drawnCap = cap; - } return drawn; } - /** - * Share the point budget across the tiles in view (water-filling): a - * sparse tile keeps what it needs and the rest goes to the dense ones, - * so a city centre is not drawn thinner than its suburbs. Sets each - * tile's cap; returns the tiles whose cap changes what they draw. - */ - function shareCaps() { - const changed = []; - if (!tuning.maxPoints) return changed; - const entries = [...state.coverage.tiles.values()].filter( - (entry) => entry.kind === 'sequence', - ); - const byDemand = [...entries].sort( - (a, b) => (a.pointDemand || 0) - (b.pointDemand || 0), - ); - let left = tuning.maxPoints; - byDemand.forEach((entry, i) => { - const share = left / (byDemand.length - i); - const cap = Math.floor(Math.min(entry.pointDemand || 0, share)); - left -= cap; - const before = entry.drawnCap ?? perTilePointCap; - entry.pointCap = cap; - // Only a change that alters what is drawn, by enough to be worth a - // rebuild: more room for a tile that had lines left out, or well - // under what it draws now (a small overshoot stays until the next - // natural redraw). - const grows = - cap > before * (1 + SHARE_HYSTERESIS) && - entry.pointsDrawn < entry.pointDemand; - const shrinks = cap < entry.pointsDrawn * (1 - SHARE_HYSTERESIS); - if (grows || shrinks) changed.push(entry); - }); - return changed; - } - - /** - * Once loading settles: redraw the tiles the shared budget changes, - * keeping their old lines until the new ones are ready (no blink). - */ - function shareBudget() { - const changed = shareCaps(); - for (const entry of changed) redrawEntry(entry, { swap: true }); - if (changed.length) { - requestRender(); - notify(); - } - } - - /** - * Draw one tile again from its decoded lines (filter, caps or selection - * changed). With `swap`, its old lines stay until the new ones are ready. - */ - function redrawEntry(entry, { swap = false } = {}) { - cancelCast(entry); - entry.castRequested = false; - entry.meshParts = null; - if (swap && entry.kind === 'sequence' && entry.primitives?.length) { - redrawTile(entry); - return; - } - detachPrimitive(entry); - attachPrimitive(entry); - } - - function pointsOf(sequence) { - let count = 0; - for (const part of sequence.parts) count += part.length; - return count; - } - - /** - * Batched primitives for a tile's sequence parts: cast polylines where - * terrain heights are cached, draped ground lines otherwise. Each batch - * records the selection its colours were built with. - */ + /** Batched draped primitives for a tile's sequences. */ function buildSequencePrimitives(sequences) { - const ground = terrainMode() ? state.context.groundCaster : null; - const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; - const selectedId = state.sequence.selectedId ?? null; - const draped = []; - const cast = []; - for (const sequence of sequences) { - const color = Cesium.ColorGeometryInstanceAttribute.fromColor( - sequenceColor({ selected: sequence.id === selectedId }), - ); - const ids = partIds(sequence, pickPrefix); - sequence.parts.forEach((coordinates, index) => { - const flat = ground?.castLine(coordinates, { meshAt }); - let positions; - try { - positions = flat - ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) - : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); - } catch { - return; - } - if (positions.length < 2) return; - const geometry = flat - ? new Cesium.PolylineGeometry({ - positions, - width: tuning.lineWidthPx, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - arcType: Cesium.ArcType.NONE, - }) - : new Cesium.GroundPolylineGeometry({ - positions, - width: tuning.lineWidthPx, - }); - (flat ? cast : draped).push( - new Cesium.GeometryInstance({ - geometry, - id: ids[index], - attributes: { color }, - }), - ); - }); - } + const instances = lineInstances( + sequences, + Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92), + tuning.lineWidthPx, + pickPrefix, + ); const primitives = []; - for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: true, - selectedId, - primitive: new Cesium.GroundPolylinePrimitive({ - geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance(), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous: true, - allowPicking: true, - }), - }); - for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: false, - selectedId, - primitive: new Cesium.Primitive({ - geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - asynchronous: true, - allowPicking: true, - }), - }); - return { primitives, draped, cast: cast.length }; - } - - /** - * Fetch the terrain heights a tile's lines need, then redraw it cast. - * `castRequested` stops the redraw from casting again at once; a refresh - * `retry`s a tile whose lines are still draped (a failed or partial cast), - * and it is redrawn only when more of its lines can be cast. - */ - function castTile(entry, { retry = false } = {}) { - if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) - return; - entry.castRequested = true; - entry.castAbort = new AbortController(); - const { signal } = entry.castAbort; - const lines = [...entry.sequences.values()].flatMap( - (sequence) => sequence.parts, - ); - const caster = state.context.groundCaster; - caster.prepareLines(lines, { signal }).then(() => { - if (entry.castAbort?.signal === signal) entry.castAbort = null; - const attached = [...state.coverage.tiles.values()].includes(entry); - if (signal.aborted || !attached || !terrainMode()) return; - // Nothing new to cast: keep what is drawn until the next refresh. - // Counted like buildSequencePrimitives: a cast line needs two points. - const castable = lines.filter( - (coords) => caster.castLine(coords)?.length >= 6, - ).length; - if (castable <= (entry.castLines || 0)) { - entry.castRequested = (entry.castLines || 0) > 0; - return; - } - // A remesh may have cleared the flag while this was in flight. - entry.castRequested = true; - redrawTile(entry); - requestMesh(entry); - }); - } - - /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ - function redrawTile(entry) { - let previous = entry.primitives; - if (entry.swap) { - // A swap still building: what is on screen is its old lines and - // whichever new ones are ready. Those stay; the rest never showed. - const shown = [...entry.swap.old]; - const building = []; - for (const record of previous) - (record.primitive.ready && !record.primitive.isDestroyed?.() - ? shown - : building - ).push(record); - entry.swap.stop(); - entry.swap = null; - removePrimitives(building); - previous = shown; - } - entry.primitives = []; - attachPrimitive(entry); - removeWhenReady(entry, previous); - } - - /** Lon/lat under the camera, where mesh range is measured from. */ - function meshCentre() { - const carto = state.viewer?.camera?.positionCartographic; - if (!carto) return null; - return { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), - }; - } - - /** - * A tile's drawn lines with bounding boxes, built once per filter; mesh - * points are densified the first time a line is in range. - */ - function meshParts(entry) { - if (entry.meshParts) return entry.meshParts; - entry.meshParts = []; - for (const sequence of entry.sequences.values()) - for (const part of sequence.parts) { - const box = { - west: Infinity, - south: Infinity, - east: -Infinity, - north: -Infinity, - }; - for (const [lon, lat] of part) { - if (lon < box.west) box.west = lon; - if (lon > box.east) box.east = lon; - if (lat < box.south) box.south = lat; - if (lat > box.north) box.north = lat; - } - entry.meshParts.push({ part, box, points: null }); - } - return entry.meshParts; - } - - /** - * Ask for mesh samples under a cast tile's lines within the sampler's range - * of the camera; the rest are asked for when a camera move brings them in. - */ - function requestMesh(entry) { - const sampler = state.context.meshSampler; - if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; - const centre = meshCentre(); - if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) - return; - const points = []; - for (const line of meshParts(entry)) { - if (!meshBoxInRange(line.box, centre)) continue; - line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); - for (const point of line.points) points.push(point); - } - if (points.length) sampler.request(points); - } - - /** Redraw the tiles new mesh samples fall in, throttled. */ - function onMeshSampled(batch) { - if (!terrainMode() || !state.context.isActive()) return; - for (const entry of state.coverage.tiles.values()) { - if (entry.kind !== 'sequence' || !entry.bounds) continue; - const { west, south, east, north } = entry.bounds; - if ( - batch.some( - ([lon, lat]) => - lon >= west && lon <= east && lat >= south && lat <= north, - ) - ) - state.coverage.remeshDirty.add(entry); - } - if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; - const wait = Math.max( - 0, - REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), - ); - state.coverage.remeshTimer = setTimeout(remesh, wait); - } - - /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ - function remesh() { - state.coverage.remeshTimer = null; - state.coverage.remeshAt = Date.now(); - if (!terrainMode() || !state.context.isActive()) { - state.coverage.remeshDirty.clear(); - return; - } - const attached = new Set(state.coverage.tiles.values()); - const [entry] = state.coverage.remeshDirty; - if (!entry) return; - state.coverage.remeshDirty.delete(entry); - if (attached.has(entry)) { - redrawTile(entry); - requestRender(); - } - if (state.coverage.remeshDirty.size) - state.coverage.remeshTimer = setTimeout( - () => whenIdle(remesh, 500), - REMESH_STAGGER_MS, + for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push( + linePrimitive(instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), true), ); + return primitives; } - state.coverage.remeshDirty = new Set(); - state.context.meshSampler?.onSampled(onMeshSampled); - /** - * Remove a tile's previous primitives once all its current ones are ready - * (or after a few seconds); detaching the tile removes them at once. + * Draw the selected sequence again over the coverage, in the selection + * colour, from every loaded tile it crosses; none when nothing is selected. + * Built at once: its tiles loaded, so the terrain table has too. */ - function removeWhenReady(entry, old) { + function highlight(sequenceId = state.sequence.selectedId) { const scene = state.viewer?.scene; - finishSwap(entry); - if (!scene?.postRender) { - removePrimitives(old); - return; + if (state.coverage.highlight) { + scene?.groundPrimitives?.remove(state.coverage.highlight); + state.coverage.highlight = null; } - const fresh = entry.primitives; - const started = Date.now(); - const stop = scene.postRender.addEventListener(() => { - const ready = fresh.every( - ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), - ); - if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { - requestRender(); - return; - } - finishSwap(entry); - requestRender(); - }); - entry.swap = { old, stop }; + if (!sequenceId || !scene) return; + // From every loaded line, drawn or not: a sequence selected after its + // tiles were drawn may have been left out by their caps. + const current = filter(); + const parts = []; + for (const entry of state.coverage.tiles.values()) { + const sequence = entry.sequenceList.find(({ id }) => id === sequenceId); + if (sequence && passes(sequence, current)) parts.push(...sequence.parts); + } + const instances = lineInstances( + [{ id: sequenceId, parts }], + Cesium.Color.fromCssColorString(colors.selected), + tuning.lineWidthPx + 1, + pickPrefix, + ); + if (!instances.length) return; + state.coverage.highlight = scene.groundPrimitives.add( + linePrimitive(instances, false), + ); requestRender(); } - function finishSwap(entry) { - if (!entry.swap) return; - entry.swap.stop(); - removePrimitives(entry.swap.old); - entry.swap = null; - } - - function buildOverviewCollection(points) { - const collection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - const tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha( - 0.85, - ); - const drawn = []; - const current = filter(); - for (const point of points) { - if (!passes(point, current)) continue; - drawn.push( - collection.add({ - position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), - color: tint, - pixelSize: overviewPointSize?.(point) ?? tuning.overviewPointPx, - // Google 3D terrain and clouds must not hide the near side's dots; - // the horizon cull hides the far side's, which this lets through. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }), - ); - } - return { collection, points: drawn }; - } - - /** Every overview point on the globe, current zoom and old alike. */ - function* overviewPoints() { - for (const entry of drawnEntries()) - if (entry.overviewPoints) yield* entry.overviewPoints; - } - - // Overview points skip the depth test: hide the far hemisphere's. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: overviewPoints, - onChange: requestRender, - }); - function attachPrimitive(entry) { const scene = state.viewer?.scene; if (!scene) return; - if (entry.kind === 'sequence') { - // Lookup, picking and counts follow what is drawn, not the whole tile. - const sequences = drawnSequences(entry); - entry.sequences = new Map(sequences.map((s) => [s.id, s])); - const { primitives, draped, cast } = buildSequencePrimitives(sequences); - entry.primitives = primitives; - entry.count = sequences.length; - for (const { primitive, onGround } of primitives) - (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); - // Fewer lines cast than last time: the caster has dropped heights this - // tile used (a full cache), so it must be cast again. - if (cast < (entry.castLines || 0)) entry.castRequested = false; - entry.castLines = cast; - entry.drapedLines = draped.length; - if (draped.length) castTile(entry); - watchSelection(); - } else { - const { collection, points } = buildOverviewCollection(entry.points); - entry.primitive = collection; - entry.overviewPoints = points; - entry.count = points.length; - scene.primitives.add(collection); - // Cull the new points now, then all of them whenever the camera moves. - horizon.update(points); - } - } - - function removePrimitives(list) { - const scene = state.viewer?.scene; - for (const { primitive, onGround } of list || []) { - try { - (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( - primitive, - ); - } catch { - /* already gone */ - } - } + // Lookup, picking and counts follow what is drawn, not the whole tile. + const sequences = drawnSequences(entry); + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + entry.primitives = buildSequencePrimitives(sequences); + for (const primitive of entry.primitives) + scene.groundPrimitives.add(primitive); } function detachPrimitive(entry) { const scene = state.viewer?.scene; - finishSwap(entry); - removePrimitives(entry.primitives); - entry.primitives = []; - if (entry.primitive) { + for (const primitive of entry.primitives || []) { try { - scene?.primitives?.remove(entry.primitive); + scene?.groundPrimitives?.remove(primitive); } catch { /* already gone */ } - entry.primitive = null; - entry.overviewPoints = null; } - } - - /** Stop a tile's pending terrain lookup (tile dropped or retired). */ - function cancelCast(entry) { - entry.castAbort?.abort(); - entry.castAbort = null; + entry.primitives = []; } function removeTile(key) { const entry = state.coverage.tiles.get(key); if (!entry) return; - cancelCast(entry); detachPrimitive(entry); state.coverage.tiles.delete(key); } - async function loadTile(tile, kind) { + async function loadTile(tile) { const key = tileKey(tile); if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) return; @@ -655,48 +299,30 @@ export function createLineCoverage({ // both handled from the start so an early tile failure is not unhandled. const [decoded] = await Promise.all([ fetchTile(tile, { signal: controller.signal }), - kind === 'sequence' ? ensureTerrainReady() : null, + ensureTerrainReady(), ]); - // Only an abort, a retire, a clear or a newer request for this tile - // discards the bytes; a refresh that still wants the tile keeps them. - const current = () => - state.coverage.pending.get(key) === controller && - !state.coverage.tiles.has(key); + // Only an abort, a zoom change, a clear or a newer request for this tile + // discards it; a refresh that still wants the tile keeps it. if ( controller.signal.aborted || - !current() || - kind !== state.coverage.kind || + state.coverage.pending.get(key) !== controller || + state.coverage.tiles.has(key) || tile.z !== state.coverage.zoom ) return; - const entry = { kind, primitive: null, primitives: [], count: 0 }; - // Padded: vector tiles carry a small buffer past their edge. - const bounds = tileBounds(tile.x, tile.y, tile.z); - const pad = (bounds.east - bounds.west) * 0.05; - entry.bounds = { - west: bounds.west - pad, - east: bounds.east + pad, - south: bounds.south - pad, - north: bounds.north + pad, - }; - if (kind === 'sequence') { - // Newest first, unless the provider ordered them itself; the - // per-tile cap applies to what passes the filter. - entry.sequenceList = tuning.keepOrder - ? decoded.sequences - : decoded.sequences.sort( - (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), - ); - entry.sequences = new Map(); - entry.total = decoded.sequences.length; - } else { - entry.points = decoded.overview; - entry.sequences = new Map(); - entry.total = decoded.overview.length; - } + const sequences = decoded?.sequences || []; + const entry = { primitives: [], sequences: new Map() }; + // Newest first, unless the provider ordered them itself; the per-tile + // cap applies to what passes the filter. + entry.sequenceList = tuning.keepOrder + ? sequences + : sequences.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0)); + entry.total = sequences.length; attachPrimitive(entry); state.coverage.tiles.set(key, entry); state.coverage.lastError = null; + // Over the new lines, and along any of the selection's parts they add. + if (entry.sequences.has(state.sequence.selectedId)) highlight(); requestRender(); } catch (error) { if ( @@ -704,79 +330,60 @@ export function createLineCoverage({ state.coverage.pending.get(key) !== controller ) return; - state.coverage.lastError = error?.message || msg('Coverage tile failed'); - if (error?.keyRequired) state.keyRequired = true; - if (error?.keyRejected) { - // Every other tile would be refused too: stop asking. - state.keyRejected = true; + // Every other tile would be refused too: stop asking. + if (error?.keyRejected) block('rejected'); + else if (error?.keyRequired) block('no-key'); + else if (error?.retryAfterSec) block('rate-limited', error.retryAfterSec); + else state.coverage.lastError = - messages.keyRejected || state.coverage.lastError; - } - if (error?.retryAfterSec) holdFor(error.retryAfterSec); + error?.message || msg('Coverage tile failed'); } finally { // Only the request that still owns the key settles it: a superseded one // must not drop a newer request's entry (which is what counts as loading). - if (state.coverage.pending.get(key) === controller) { + if (state.coverage.pending.get(key) === controller) state.coverage.pending.delete(key); - if (!state.coverage.pending.size) { - purgeStale(); - shareBudget(); - } - } notify(); } } /** - * The source is rate-limiting: keep what is drawn, request nothing until the - * wait is over, then refresh once. + * Pause tile requests (see `blocked` in freshCoverage). A rate limit keeps + * what is drawn and refreshes once its wait is over; a key problem + * outranks it, and a rejected key holds until `unblock` (the provider goes + * off). */ - function holdFor(seconds) { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdUntil = Date.now() + seconds * 1000; - state.coverage.lastError = messages.rateLimited; - state.coverage.holdTimer = setTimeout(() => { - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - if (state.coverage.lastError === messages.rateLimited) - state.coverage.lastError = null; - refresh(); - }, seconds * 1000); + function block(reason, retryAfterSec = 0) { + const current = state.coverage.blocked; + if (current === 'rejected') return; + if (reason === 'rate-limited' && current === 'no-key') return; + clearTimeout(state.coverage.blockTimer); + state.coverage.blockTimer = null; + state.coverage.blocked = reason; + if (reason === 'rate-limited') + state.coverage.blockTimer = setTimeout(() => { + state.coverage.blockTimer = null; + state.coverage.blocked = null; + refresh(); + }, retryAfterSec * 1000); } - /** Forget refusals when the provider goes off, so the next run asks again. */ - function resetErrors() { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; + /** The provider went off: forget refusals and errors, so the next run asks again. */ + function unblock() { + clearTimeout(state.coverage.blockTimer); + state.coverage.blockTimer = null; + if (['rejected', 'rate-limited'].includes(state.coverage.blocked)) + state.coverage.blocked = null; state.coverage.lastError = null; - state.keyRejected = false; } - /** Drop the previous zoom's tiles once the new ones are on screen. */ - function purgeStale() { - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = null; - if (!state.coverage.stale.size) return; - for (const entry of state.coverage.stale.values()) detachPrimitive(entry); - state.coverage.stale.clear(); - requestRender(); - } - - /** Move tiles to the stale set: the old zoom shows until the new one loads. */ - function retire() { - for (const controller of state.coverage.pending.values()) - controller.abort(); - state.coverage.pending.clear(); - for (const [key, entry] of state.coverage.tiles) { - cancelCast(entry); - const previous = state.coverage.stale.get(key); - if (previous) detachPrimitive(previous); - state.coverage.stale.set(key, entry); + /** The server's key status: until it answers, nothing is requested. */ + function setKeyStatus(configured) { + if (!configured) block('no-key'); + else if (['status', 'no-key'].includes(state.coverage.blocked)) { + state.coverage.blocked = null; + refresh(); } - state.coverage.tiles.clear(); - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); + notify(); } function notify() { @@ -784,72 +391,50 @@ export function createLineCoverage({ } /** - * Whether the screen centre meets the ground further ahead than the camera - * is high (a view tilted above 45°), or misses it. + * The tiles for the camera: what the screen shows within range of it, at + * the zoom for its height above ground, nearest the camera first. */ - function isTilted({ nadir, ahead }, height) { - if (!ahead) return true; - return metresBetween(nadir, ahead) > Math.max(1, height); + function wantedTiles(viewer) { + const ground = groundUnderCamera(viewer); + const cameraHeight = viewer.camera?.positionCartographic?.height; + const height = Number.isFinite(cameraHeight) + ? cameraHeight - (ground ?? 0) + : null; + const zoom = coverageZoom(zoomFor(height)); + if (zoom == null) return { hint: messages.tooHigh || ZOOM_IN_HINT }; + // Rays meet the ground where it really is (1,600 m up in Denver) and stop + // short of the horizon, which would stretch the box to the world. + const ranged = { + groundHeight: ground, + maxRange: Math.max( + tuning.viewRangeMinM, + height * tuning.viewRangePerHeight, + ), + }; + const bbox = visibleBbox(viewer, ranged); + if (!bbox) return { hint: NO_GROUND_HINT }; + // Ranked from between the camera's ground point and the screen centre's: + // a tilted view's box centre can sit kilometres past both. + const tiles = tilesForBbox(bbox, zoom, { + limit: tuning.maxTiles, + from: viewFocus(viewer, ranged), + }); + return { zoom, tiles }; } /** Recompute the tile set for the current camera and reconcile primitives. */ function refresh() { const viewer = state.viewer; - if (!viewer || !state.context.isActive() || state.keyRequired) return; - if (state.statusKnown === false) return; - if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; - const ground = groundUnderCamera(viewer, { - groundAt: state.context.groundCaster?.groundAt, - }); - const cameraHeight = viewer.camera?.positionCartographic?.height; - const height = Number.isFinite(cameraHeight) - ? cameraHeight - (ground ?? 0) - : null; - const level = zoomFor(height); - const zoom = level?.zoom ?? null; - const kind = level?.kind ?? null; - // At street zooms the rays meet the ground where it really is (1,600 m up - // in Denver; Google 3D hides the globe) and stop short of the horizon. - const ranged = - kind === 'sequence' - ? { - groundHeight: ground, - maxRange: Math.max( - tuning.viewRangeMinM, - height * tuning.viewRangePerHeight, - ), - } - : {}; - // Tilted street views rank tiles along the line of sight, from the ground - // under the camera to the ground at the centre of the screen. - const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; - let bbox = visibleBbox(viewer, { - ...ranged, - // A tilted view's screen rows jump from the first metres to the horizon: - // keep the ground around the camera too. Looking down needs no help. - nearRange: focus && isTilted(focus, height) ? tuning.viewNearM : null, - }); - if (kind === 'overview' && (!bbox || zoom <= 1)) - bbox = [-180, -85, 180, 85]; - if (zoom == null || !bbox) { - state.coverage.hint = - zoom == null && Number.isFinite(height) && messages.tooHigh - ? messages.tooHigh - : msg('Point the camera at the globe for street-level coverage'); + if (!viewer || !state.context.isActive() || state.coverage.blocked) return; + const { zoom, tiles, hint } = wantedTiles(viewer); + if (hint) { + state.coverage.hint = hint; clear(); notify(); return; } state.coverage.hint = ''; - if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { - retire(); - state.coverage.zoom = zoom; - state.coverage.kind = kind; - } - const { tiles } = tilesForBbox(bbox, zoom, { - limit: kind === 'sequence' ? tuning.maxTiles : tuning.overviewMaxTiles, - focus: focus && { from: focus.nadir, to: focus.ahead }, - }); + state.coverage.zoom = zoom; const wanted = new Set(tiles.map(tileKey)); for (const key of [...state.coverage.tiles.keys()]) if (!wanted.has(key)) removeTile(key); @@ -860,18 +445,7 @@ export function createLineCoverage({ controller.abort(); state.coverage.pending.delete(key); } - for (const tile of tiles) loadTile(tile, kind); - if (!state.coverage.pending.size) { - purgeStale(); - shareBudget(); - } - // The camera moved: cells that were out of the sampler's range may not be, - // and lines left draped by a failed, partial or dropped cast get another try. - if (terrainMode()) - for (const entry of state.coverage.tiles.values()) { - if (entry.castRequested) requestMesh(entry); - if (entry.drapedLines) castTile(entry, { retry: true }); - } + for (const tile of tiles) loadTile(tile); notify(); } @@ -903,162 +477,58 @@ export function createLineCoverage({ controller.abort(); state.coverage.pending.clear(); for (const key of [...state.coverage.tiles.keys()]) removeTile(key); - purgeStale(); + highlight(null); state.coverage.zoom = null; - state.coverage.kind = null; - clearTimeout(state.coverage.remeshTimer); - state.coverage.remeshTimer = null; - state.coverage.remeshDirty.clear(); - stopSelectionWatch(); - horizon.stop(); requestRender(); } - /** Rebuild every loaded tile from its decoded cache (after a filter change). */ + /** Rebuild every loaded tile from its decoded lines (after a filter change). */ function rebuild() { - purgeStale(); - // What each tile's lines need under the new filter, then the shares, - // then one redraw each. - for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') drawnSequences(entry, { measure: true }); - shareCaps(); - for (const entry of state.coverage.tiles.values()) redrawEntry(entry); + for (const entry of state.coverage.tiles.values()) { + detachPrimitive(entry); + attachPrimitive(entry); + } + highlight(); requestRender(); notify(); } - /** - * Load every tile again (a source that filters on its server had its - * filter changed); the lines drawn now show until the new ones are in. - */ + /** Load every tile again (a source that filters on its server had its filter changed). */ function reload() { - retire(); + clear(); refresh(); } /** - * Redraw tiles draped or cast for the new surface mode, then re-pick the - * zoom, since the ground height under the camera may have changed. - */ - function setSurface() { - if (!state.context.isActive()) return; - rebuild(); - refresh(); - } - - /** Recolour every part of a sequence in one ready primitive. */ - function recolorInstances(primitive, sequence, selected) { - const value = sequenceColor({ selected }); - for (const instanceId of partIds(sequence, pickPrefix)) { - try { - const attributes = primitive.getGeometryInstanceAttributes(instanceId); - if (attributes) - attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( - value, - attributes.color, - ); - } catch { - /* instance not in this primitive */ - } - } - } - - /** Every tile on the globe: the current zoom's and the stale zoom's. */ - function drawnEntries() { - return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; - } - - /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ - function drawnRecords(entry) { - const records = entry.primitives || []; - return entry.swap ? [...records, ...entry.swap.old] : records; - } - - /** Recolour one sequence, every part of it, in place (selection highlight). */ - function recolorSequence(id, selected) { - // Selected but left out by a cap: draw it (it is always admitted). - if (selected && (tuning.maxPoints || 0) > 0) - for (const entry of state.coverage.tiles.values()) - if ( - entry.kind === 'sequence' && - !entry.sequences.has(id) && - entry.sequenceList.some( - (sequence) => sequence.id === id && passes(sequence, filter()), - ) - ) - redrawEntry(entry, { swap: true }); - for (const entry of drawnEntries()) { - const sequence = entry.sequences.get(id); - if (!sequence) continue; - for (const record of drawnRecords(entry)) { - // Still building: `syncSelection` catches it up once it is ready. - if (!record.primitive.ready) continue; - recolorInstances(record.primitive, sequence, selected); - if (selected) record.selectedId = id; - else if (record.selectedId === id) record.selectedId = null; - } - } - requestRender(); - } - - /** - * Bring a ready primitive's highlight up to the current selection, when - * it was built with another one (or none). True when it changed. - */ - function syncSelection(entry, record) { - const current = state.sequence.selectedId ?? null; - if (record.selectedId === current || !record.primitive.ready) return false; - const previous = - record.selectedId && entry.sequences.get(record.selectedId); - if (previous) recolorInstances(record.primitive, previous, false); - const next = current && entry.sequences.get(current); - if (next) recolorInstances(record.primitive, next, true); - record.selectedId = current; - return true; - } - - /** - * A primitive cannot be recoloured until it is ready, so while any is - * building, catch each one up with the selection as it becomes ready. - */ - function watchSelection() { - const scene = state.viewer?.scene; - if (state.coverage.stopSelectionWatch || !scene?.postRender) return; - const stop = scene.postRender.addEventListener(() => { - let building = false; - let changed = false; - for (const entry of drawnEntries()) { - for (const record of entry.primitives || []) { - if (!record.primitive.ready) building = true; - else if (syncSelection(entry, record)) changed = true; - } - // Lines a swap still shows were ready (or never show): catch up only. - for (const record of entry.swap?.old || []) - if (syncSelection(entry, record)) changed = true; - } - if (changed) requestRender(); - if (!building) stopSelectionWatch(); - }); - state.coverage.stopSelectionWatch = stop; - } - - function stopSelectionWatch() { - state.coverage.stopSelectionWatch?.(); - state.coverage.stopSelectionWatch = null; - } - - /** - * Sequences (or overview points) drawn. A sequence that crosses a tile - * edge is in both tiles' lists, so sequences count once by id. + * Sequences drawn. A sequence that crosses a tile edge is in both tiles' + * lists, so sequences count once by id. */ function sequenceCount() { const ids = new Set(); - let points = 0; for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') - for (const id of entry.sequences.keys()) ids.add(id); - else points += entry.count; - return ids.size + points; + for (const id of entry.sequences.keys()) ids.add(id); + return ids.size; + } + + /** What the panel and the layer list show: count, LOADING, key gate, error. */ + function stats() { + const { blocked } = state.coverage; + return { + count: sequenceCount(), + // Waiting for the key status is loading too, not an empty view. + loading: + state.coverage.pending.size > 0 || + (blocked === 'status' && state.context.isActive()), + hint: state.coverage.hint, + keyRequired: blocked === 'no-key' || blocked === 'rejected', + keyRejected: blocked === 'rejected', + error: + blocked === 'rejected' + ? messages.keyRejected || null + : blocked === 'rate-limited' + ? messages.rateLimited + : state.coverage.lastError, + }; } return { @@ -1066,11 +536,12 @@ export function createLineCoverage({ detach, refresh, clear, - resetErrors, + unblock, + setKeyStatus, rebuild, reload, - setSurface, - recolorSequence, + highlight, sequenceCount, + stats, }; } diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 7fdc19773..60bae7eb3 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -1,532 +1,656 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createLineCoverage } from './lineCoverage.js'; +import * as Cesium from 'cesium'; import { - coverageViewer, - tileCentre, -} from '../../testSupport/streetLevelFakes.mjs'; -import { until } from '../../testSupport/kartaViewFakes.mjs'; + coverageZoom, + createLineCoverage, + freshCoverage, + ZOOM_IN_HINT, +} from './lineCoverage.js'; +import { resolveFilter } from './filter.js'; +import { encodeCoverageTile } from './providers/mapillary/coverageFixture.mjs'; +import { decodeCoverageTile } from './providers/mapillary/decode.js'; +import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; +import { + COLORS, + COVERAGE_TUNING, + KEY_REJECTED_MESSAGE, + PICK_PREFIX, + RATE_LIMITED_MESSAGE, +} from './providers/mapillary/policy.js'; +import { + coverageZoomForHeight, + lonToTileX, + latToTileY, + tileBounds, +} from './tileMath.js'; -const TUNING = Object.freeze({ - maxTiles: 9, - overviewMaxTiles: 0, - maxSequences: 900, - lineWidthPx: 2, - overviewPointPx: 2, - moveDebounceMs: 10, - viewRangeMinM: 2_500, - viewRangePerHeight: 10, - viewNearM: 1_000, -}); +const { maxSequences: COVERAGE_MAX_SEQUENCES, maxTiles: COVERAGE_MAX_TILES } = + COVERAGE_TUNING; -/** - * Line coverage over one tile holding one line, on a viewer looking down from - * `height`. `fetchTile` answers with that line unless `fail` says otherwise. - */ -function setup( - t, - { +/** Line coverage as the Mapillary provider builds it, over `source`. */ +function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) { + return createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile( + await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }), + tile, + ), zoomFor, - messages = { rateLimited: 'busy' }, - passes, - fail, - decoded = null, - tuning = TUNING, - overviewPointSize = null, - } = {}, -) { + colors: COLORS, + pickPrefix: PICK_PREFIX.sequence, + renderKey: 'mapillary-coverage', + tuning: COVERAGE_TUNING, + messages: { + keyRejected: KEY_REJECTED_MESSAGE, + rateLimited: RATE_LIMITED_MESSAGE, + }, + }); +} + +const RAD = Math.PI / 180; +// Node has no WebGL context to report line-width limits; a browser's +// context sets these on startup, and appearances validate against them. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 10; +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** A scene collection that remembers what is on the globe. */ +function collection() { + const items = new Set(); + return { + items, + add(primitive) { + items.add(primitive); + return primitive; + }, + remove(primitive) { + return items.delete(primitive); + }, + }; +} + +/** The centre of the z14 tile holding a point, so a small view is one tile. */ +function tileCentre(lon, lat) { + const x = lonToTileX(lon, 14); + const y = latToTileY(lat, 14); + const { west, east, south, north } = tileBounds(x, y, 14); + return { + tile: { x, y, z: 14 }, + lon: (west + east) / 2, + lat: (south + north) / 2, + }; +} + +/** A viewer looking straight down on a 0.004° square around `view`. */ +function fakeViewer(view) { + return { + view, + scene: { + canvas: { clientWidth: 100, clientHeight: 100 }, + globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, + groundPrimitives: collection(), + primitives: collection(), + }, + camera: { + get positionCartographic() { + return { + longitude: view.lon * RAD, + latitude: view.lat * RAD, + height: view.height, + }; + }, + pickEllipsoid(point) { + return Cesium.Cartesian3.fromDegrees( + view.lon - 0.002 + (point.x / 100) * 0.004, + view.lat + 0.002 - (point.y / 100) * 0.004, + ); + }, + }, + }; +} + +/** A source whose tile requests the test resolves, one call at a time. */ +function deferredSource() { + const calls = []; + return { + calls, + getTile(layer, z, x, y, { signal } = {}) { + return new Promise((resolve, reject) => { + calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject }); + }); + }, + }; +} + +function setup({ zoomFor } = {}) { const centre = tileCentre(-121.4944, 38.5816); - const viewer = coverageViewer( - { lon: centre.lon, lat: centre.lat, height: 900 }, - t, - ); + const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 }); + const source = deferredSource(); const state = { viewer, services: {}, - keyRequired: false, - keyRejected: false, + enabled: true, + providerOn: true, + filter: { pano: 'all', sinceDays: 0 }, context: { - getFilter: () => ({ pano: 'all', sinceMs: null }), - isActive: () => true, + getFilter: () => resolveFilter(state.filter), + isActive: () => state.enabled && state.providerOn, notify() {}, - getSurface: () => 'draped', - groundCaster: null, - meshSampler: null, }, coverage: { - zoom: null, - kind: null, - tiles: new Map(), - stale: new Map(), - staleTimer: null, - pending: new Map(), - lastError: null, - holdUntil: 0, - holdTimer: null, - debounceTimer: null, - removeCameraListener: null, + ...freshCoverage(), + // The key status is in; skip Cesium's one-time terrain table download. + blocked: null, terrainReady: Promise.resolve(), - hint: '', }, sequence: { selectedId: null }, }; - const asked = []; - const coverage = createLineCoverage({ - state, - fetchTile: async (tile) => { - asked.push(tile); - if (fail) throw fail; - if (decoded) return decoded(centre, tile); - return { - sequences: [ - { - id: 'line-1', - parts: [ - [ - [centre.lon - 0.001, centre.lat], - [centre.lon + 0.001, centre.lat], - ], - ], - capturedAt: 0, - isPano: false, - }, - ], - overview: [], - }; - }, - zoomFor: - zoomFor ?? - ((height) => - Number.isFinite(height) && height <= 6_000 - ? { zoom: 14, kind: 'sequence' } - : null), - colors: { coverage: '#635bff', selected: '#00e5ff' }, - pickPrefix: 'test:line:', - renderKey: 'test-coverage', - tuning, - messages, - ...(passes ? { passes } : {}), - ...(overviewPointSize ? { overviewPointSize } : {}), - }); - t.after(() => { - coverage.clear(); - coverage.resetErrors(); - }); - return { viewer, state, coverage, asked }; -} - -test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => { - const tooHigh = 'zoom in to see test coverage'; - const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } }); - high.viewer.view.height = 7_000; - high.coverage.refresh(); - assert.equal(high.state.coverage.hint, tooHigh); - assert.equal(high.state.coverage.zoom, null); - assert.equal(high.asked.length, 0, 'nothing is asked from up there'); - const generic = setup(t); - generic.viewer.view.height = 7_000; - generic.coverage.refresh(); - assert.notEqual(generic.state.coverage.hint, tooHigh); - assert.match(generic.state.coverage.hint, /Point the camera/); -}); - -test('a provider’s passes() decides which lines are drawn and counted', async (t) => { - const all = setup(t, { passes: () => true }); - all.coverage.refresh(); - assert.ok(await until(() => all.state.coverage.tiles.size === 1)); - assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn'); - const none = setup(t, { passes: () => false }); - none.coverage.refresh(); - assert.ok(await until(() => none.state.coverage.tiles.size === 1)); - assert.equal(none.coverage.sequenceCount(), 0, 'filtered out'); -}); - -test('a refused key without its own message keeps the error it came with', async (t) => { - const refused = Object.assign(new Error('the source refused the key'), { - keyRejected: true, - }); - const { state, coverage } = setup(t, { fail: refused }); - coverage.refresh(); - assert.ok(await until(() => state.keyRejected === true)); - assert.equal(state.coverage.lastError, 'the source refused the key'); -}); - -/** Three lines in one tile: an old long one, then two newer short ones. */ -function threeLines(centre) { - const line = (id, capturedAt, span) => ({ - id, - parts: [ - [ - [centre.lon - span, centre.lat], - [centre.lon + span, centre.lat], - ], - ], - capturedAt, - isPano: false, - }); - return { + const coverage = createCoverage({ state, source, zoomFor }); + const bytes = encodeCoverageTile(centre.tile, { sequences: [ - line('old-long', 1, 0.002), - line('new-short', 2, 0.0002), - line('newer-short', 3, 0.0001), - ], - overview: [], - }; -} - -test('the per-tile cap keeps the newest lines, or the provider’s own order with keepOrder', async (t) => { - const capped = { ...TUNING, maxTiles: 1, maxSequences: 2 }; - const drawnIds = (state) => - [...state.coverage.tiles.values()].flatMap((tile) => [ - ...tile.sequences.keys(), - ]); - const newest = setup(t, { decoded: threeLines, tuning: capped }); - newest.coverage.refresh(); - assert.ok(await until(() => newest.state.coverage.tiles.size === 1)); - assert.deepEqual(drawnIds(newest.state).sort(), ['new-short', 'newer-short']); - const kept = setup(t, { - decoded: threeLines, - tuning: { ...capped, keepOrder: true }, - }); - kept.coverage.refresh(); - assert.ok(await until(() => kept.state.coverage.tiles.size === 1)); - assert.deepEqual(drawnIds(kept.state), ['old-long', 'new-short']); -}); - -test('overview points take the provider’s size, each its own', async (t) => { - const { state, coverage, viewer } = setup(t, { - zoomFor: () => ({ zoom: 3, kind: 'overview' }), - tuning: { ...TUNING, overviewMaxTiles: 4 }, - decoded: (centre) => ({ - sequences: [], - overview: [ - { id: 'a', lon: centre.lon, lat: centre.lat, weight: 1 }, - { id: 'b', lon: centre.lon + 0.01, lat: centre.lat, weight: 50_000 }, - ], - }), - overviewPointSize: (point) => (point.weight > 1 ? 7 : 3), - }); - coverage.refresh(); - assert.ok(await until(() => state.coverage.tiles.size > 0)); - const sizes = []; - for (const primitive of viewer.scene.primitives.items) - for (let i = 0; i < (primitive.length ?? 0); i++) - sizes.push(primitive.get(i).pixelSize); - assert.deepEqual(sizes.sort(), [3, 7]); -}); - -test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => { - const { state, coverage } = setup(t, { - tuning: { - ...TUNING, - maxTiles: 1, - maxSequences: 10, - maxPoints: 5, - keepOrder: true, - }, - decoded: (centre) => { - const line = (id, n) => ({ - id, + { + id: 'seq-1', parts: [ - Array.from({ length: n }, (_, i) => [ - centre.lon - 0.001 + i * 0.0001, - centre.lat, - ]), + [ + [centre.lon - 0.001, centre.lat], + [centre.lon + 0.001, centre.lat], + ], ], - capturedAt: 0, - isPano: false, - }); - return { - sequences: [line('three', 3), line('four', 4), line('two', 2)], - overview: [], - }; - }, - }); - coverage.refresh(); - assert.ok(await until(() => state.coverage.tiles.size === 1)); - const [tile] = state.coverage.tiles.values(); - assert.deepEqual([...tile.sequences.keys()], ['three', 'two']); -}); - -/** A line of `n` points, its id unique to the tile. */ -function pointsLine(id, n, tile, centre) { - return { - id, - parts: [ - Array.from({ length: n }, (_, i) => [ - centre.lon - 0.001 + i * 0.00001, - centre.lat, - ]), + }, ], - capturedAt: 0, - isPano: false, - }; + }); + const tileKey = `14/${centre.tile.x}/${centre.tile.y}`; + const onGlobe = () => + viewer.scene.groundPrimitives.items.size + + viewer.scene.primitives.items.size; + return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre }; } -test('the point budget is shared: a sparse tile keeps its few, a dense one gets the rest', async (t) => { - const tuning = { - ...TUNING, - maxTiles: 9, - maxSequences: 900, - maxPoints: 90, - keepOrder: true, - }; - const { state, coverage } = setup(t, { - // Street zoom far enough in that several tiles are in view. - zoomFor: () => ({ zoom: 18, kind: 'sequence' }), - tuning, - decoded: (centre, tile) => { - const dense = tile.x % 2 === 0 && tile.y % 2 === 0; - const lines = dense - ? Array.from({ length: 30 }, (_, i) => - pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre), - ) - : [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)]; - return { sequences: lines, overview: [] }; - }, - }); +test('a superseded tile request cannot strand lines on the globe', async () => { + const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup(); + const forTile = () => source.calls.filter((call) => call.key === tileKey); + + coverage.refresh(); // street zoom: request #1 for the tile + assert.equal(forTile().length, 1); + viewer.view.height = 100_000; // zoom out: clear() drops request #1 coverage.refresh(); - assert.ok( - await until( - () => state.coverage.tiles.size > 1 && !state.coverage.pending.size, - ), - ); - const entries = [...state.coverage.tiles.values()]; - const drawn = (entry) => entry.pointsDrawn; - const total = entries.reduce((sum, entry) => sum + drawn(entry), 0); - assert.ok(total <= tuning.maxPoints, `within the budget: ${total}`); - const sparse = entries.filter((entry) => entry.pointDemand === 2); - const dense = entries.filter((entry) => entry.pointDemand === 150); - assert.ok(sparse.length && dense.length, 'both kinds in view'); - for (const entry of sparse) - assert.equal(drawn(entry), 2, 'a sparse tile keeps all'); - const fair = Math.floor(tuning.maxPoints / tuning.maxTiles); - const left = tuning.maxPoints - sparse.length * 2; - for (const entry of dense) - assert.ok( - drawn(entry) > fair || dense.length * fair >= left, - `a dense tile gets more than the even share: ${drawn(entry)}`, - ); - assert.ok( - total > tuning.maxPoints - 5 * dense.length, - 'little budget unused', + viewer.view.height = 900; // back in before #1 settles: request #2 + coverage.refresh(); + assert.equal(forTile().length, 2); + await settle(); + + // The superseded request finishes late. It must not settle request #2's + // key, or the next refresh would start a duplicate load. + forTile()[0].resolve(bytes); + await settle(); + assert.equal( + state.coverage.pending.has(tileKey), + true, + '#2 still owns the key', ); + assert.equal(state.coverage.pending.size, 1, 'still loading'); + coverage.refresh(); + assert.equal(forTile().length, 2, 'no duplicate request'); + + forTile()[1].resolve(bytes); + await settle(); + assert.equal(state.coverage.tiles.size, 1); + assert.ok(onGlobe() > 0, 'the tile drew its lines'); + + // Everything drawn can be removed: nothing is orphaned. + coverage.clear(); + assert.equal(onGlobe(), 0); }); -test('a selected sequence the budget left out is drawn, highlighted', async (t) => { - const { state, coverage } = setup(t, { - tuning: { - ...TUNING, - maxTiles: 1, - maxSequences: 10, - maxPoints: 5, - keepOrder: true, - }, - decoded: (centre, tile) => ({ - sequences: [ - pointsLine('long', 5, tile, centre), - pointsLine('short', 3, tile, centre), - ], - overview: [], - }), - }); +test('sequence lines are draped on the globe and on 3D tiles alike', async () => { + const { viewer, source, coverage, bytes } = setup(); coverage.refresh(); - assert.ok(await until(() => state.coverage.tiles.size === 1)); - const [tile] = state.coverage.tiles.values(); - assert.deepEqual([...tile.sequences.keys()], ['long']); - state.sequence.selectedId = 'short'; - coverage.recolorSequence('short', true); - assert.deepEqual([...tile.sequences.keys()].sort(), ['long', 'short']); + source.calls[0].resolve(bytes); + await settle(); + const { groundPrimitives, primitives } = viewer.scene; + assert.equal(primitives.items.size, 0); + assert.equal(groundPrimitives.items.size, 1); + const [line] = groundPrimitives.items; + assert.ok(line instanceof Cesium.GroundPolylinePrimitive); + assert.equal(line.classificationType, Cesium.ClassificationType.BOTH); + coverage.clear(); + assert.equal(groundPrimitives.items.size, 0); }); -/** Count the line primitives a coverage adds to the scene. */ -function countAdds(viewer) { - const counts = { added: 0 }; - for (const collection of [ - viewer.scene.groundPrimitives, - viewer.scene.primitives, - ]) { - const add = collection.add.bind(collection); - collection.add = (primitive) => { - counts.added++; - return add(primitive); - }; - } - return counts; -} +test('panning off a tile that is still loading stops counting it as loading', async () => { + const { viewer, source, state, coverage } = setup(); + coverage.refresh(); + assert.equal(state.coverage.pending.size, 1); + // Same zoom, two tiles east: the first request is dropped, a new one starts. + viewer.view.lon += 0.05; + coverage.refresh(); + assert.equal(source.calls.length, 2); + assert.equal(source.calls[0].signal.aborted, true); + assert.equal( + state.coverage.pending.size, + 1, + 'only the live request counts as loading', + ); + source.calls[0].reject(new DOMException('aborted', 'AbortError')); + source.calls[1].resolve(new Uint8Array()); + await settle(); + assert.equal(state.coverage.pending.size, 0, 'LOADING clears'); + coverage.clear(); +}); -test('a tile redrawn for a new share keeps its old lines until the new ones are ready', async (t) => { - const tuning = { - ...TUNING, - maxTiles: 9, - maxSequences: 900, - maxPoints: 90, - keepOrder: true, - }; - const { state, coverage, viewer } = setup(t, { - zoomFor: () => ({ zoom: 18, kind: 'sequence' }), - tuning, - decoded: (centre, tile) => { - const dense = tile.x % 2 === 0 && tile.y % 2 === 0; - return { - sequences: dense - ? Array.from({ length: 30 }, (_, i) => - pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre), - ) - : [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)], - overview: [], - }; +test('a zoomFor in the older {zoom, kind} shape still draws its lines', async () => { + // Providers written before the core went slim (#1139's Vegbilder) answer + // an object: it must not ask for no tiles and say nothing. + const { viewer, source, coverage, bytes } = setup({ + zoomFor: (height) => { + const zoom = coverageZoomForHeight(height); + return zoom == null ? null : { zoom, kind: 'sequence' }; }, }); coverage.refresh(); - assert.ok( - await until( - () => state.coverage.tiles.size > 1 && !state.coverage.pending.size, - ), - ); - const redrawn = [...state.coverage.tiles.values()].filter( - (entry) => entry.swap, - ); - assert.ok(redrawn.length > 0, 'a dense tile took a bigger share'); - for (const entry of redrawn) - for (const { primitive } of entry.swap.old) - assert.ok( - viewer.scene.groundPrimitives.items.has(primitive) || - viewer.scene.primitives.items.has(primitive), - 'the old lines are still on the globe while the new ones build', + assert.equal(source.calls.length, 1, 'the tile is asked for'); + assert.equal(source.calls[0].z, 14); + source.calls[0].resolve(bytes); + await settle(); + assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'its line drawn'); + assert.equal(coverage.stats().hint, ''); + coverage.clear(); +}); + +test('a zoom level is read from either shape; an overview or a bad answer draws nothing', () => { + assert.equal(coverageZoom(14), 14); + assert.equal(coverageZoom({ zoom: 15, kind: 'sequence' }), 15); + assert.equal(coverageZoom({ zoom: 12 }), 12); + assert.equal(coverageZoom({ zoom: 5, kind: 'overview' }), null, 'orbit'); + assert.equal(coverageZoom(null), null); + assert.equal(coverageZoom(undefined), null); + assert.equal(coverageZoom({}), null); + assert.equal(coverageZoom(14.5), null); + assert.equal(coverageZoom('14'), null); +}); + +test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => { + const { viewer, source, state, coverage, bytes } = setup(); + coverage.refresh(); + source.calls[0].resolve(bytes); + await settle(); + assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines'); + viewer.view.height = 20_000_000; + coverage.refresh(); + assert.equal(source.calls.length, 1, 'no tile is asked for'); + assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn'); + assert.equal(state.coverage.zoom, null); + assert.equal(coverage.stats().hint, ZOOM_IN_HINT); + viewer.view.height = 900; + coverage.refresh(); + assert.equal(coverage.stats().hint, '', 'back at street zoom'); + assert.equal(source.calls.length, 2); + coverage.clear(); +}); + +test('the key status gates coverage: nothing before it answers, nothing without a key', () => { + for (const configured of [false, true]) { + const { source, state, coverage } = setup(); + state.coverage.blocked = 'status'; + coverage.refresh(); + assert.equal(source.calls.length, 0, 'nothing before the status answers'); + assert.equal(coverage.stats().loading, true, 'waiting reads as loading'); + state.enabled = false; + assert.equal(coverage.stats().loading, false, 'but not while off'); + state.enabled = true; + coverage.setKeyStatus(configured); + if (configured) + assert.equal(source.calls.length, 1, 'coverage loads once it is known'); + else { + assert.equal(source.calls.length, 0, 'a key-less install never asks'); + assert.equal(coverage.stats().loading, false); + assert.equal(coverage.stats().keyRequired, true); + assert.equal(coverage.stats().keyRejected, false); + coverage.unblock(); + assert.equal( + coverage.stats().keyRequired, + true, + 'kept: it is no refusal', ); -}); - -test('a second redraw while a swap still builds keeps the lines on screen', async (t) => { - const { state, coverage, viewer } = setup(t, { - zoomFor: () => ({ zoom: 18, kind: 'sequence' }), - tuning: { - ...TUNING, - maxTiles: 9, - maxSequences: 900, - maxPoints: 90, - keepOrder: true, - }, - decoded: (centre, tile) => { - const dense = tile.x % 2 === 0 && tile.y % 2 === 0; - return { - sequences: dense - ? Array.from({ length: 30 }, (_, i) => - pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre), - ) - : [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)], - overview: [], - }; - }, - }); - coverage.refresh(); - assert.ok( - await until( - () => state.coverage.tiles.size > 1 && !state.coverage.pending.size, - ), - ); - // A tile mid-swap (its new lines never become ready here) with a line - // the cap left out: selecting that line redraws the tile again. - const entry = [...state.coverage.tiles.values()].find( - (tile) => - tile.swap && - tile.sequenceList.some((sequence) => !tile.sequences.has(sequence.id)), - ); - assert.ok(entry, 'a swapping tile with a line left out'); - const onScreen = [...entry.swap.old]; - const building = entry.primitives.map(({ primitive }) => primitive); - const left = entry.sequenceList.find( - (sequence) => !entry.sequences.has(sequence.id), - ); - state.sequence.selectedId = left.id; - coverage.recolorSequence(left.id, true); - assert.ok(entry.sequences.has(left.id), 'the selected line is drawn'); - const onGlobe = (primitive) => - viewer.scene.groundPrimitives.items.has(primitive) || - viewer.scene.primitives.items.has(primitive); - for (const { primitive } of onScreen) - assert.ok( - onGlobe(primitive), - 'the lines on screen stay until the new ones are ready', - ); - for (const primitive of building) - assert.ok(!onGlobe(primitive), 'the first swap’s unready lines are gone'); - assert.ok(entry.swap, 'still swapping'); -}); - -test('a selected line outside the cap does not make its tile redraw on every settle, nor does one the filter hides', async (t) => { - let pano = 'all'; - const { state, coverage, viewer } = setup(t, { - tuning: { - ...TUNING, - maxTiles: 1, - maxSequences: 10, - maxPoints: 5, - keepOrder: true, - }, - decoded: (centre, tile) => ({ - sequences: [ - pointsLine('long', 5, tile, centre), - { ...pointsLine('short', 3, tile, centre), isPano: true }, - ], - overview: [], - }), - }); - state.context.getFilter = () => ({ pano, sinceMs: null }); - coverage.refresh(); - assert.ok(await until(() => state.coverage.tiles.size === 1)); - state.sequence.selectedId = 'short'; - coverage.recolorSequence('short', true); - const counts = countAdds(viewer); - for (let i = 0; i < 3; i++) { - coverage.refresh(); - await new Promise((resolve) => setTimeout(resolve, 20)); + } + coverage.clear(); } - assert.equal(counts.added, 0, 'settles redraw nothing'); - // A selection the filter hides is not drawn, so it is not redrawn for. - pano = 'flat'; - coverage.rebuild(); - const after = countAdds(viewer); - for (let i = 0; i < 3; i++) coverage.recolorSequence('short', true); - assert.equal(after.added, 0); }); -test('small share increases add up: a retained tile redraws once its share has grown past the threshold since its last draw', async (t) => { - const tuning = { - ...TUNING, - maxTiles: 1, - maxSequences: 900, - maxPoints: 100, - keepOrder: true, - }; - const { state, coverage } = setup(t, { - tuning, - decoded: (centre, tile) => ({ - sequences: Array.from({ length: 30 }, (_, i) => - pointsLine(`l${i}`, 4, tile, centre), - ), - overview: [], - }), - }); +test('a rejected key stops coverage requests until the layer goes off', async () => { + const { viewer, source, state, coverage } = setup(); coverage.refresh(); - assert.ok( - await until( - () => state.coverage.tiles.size === 1 && !state.coverage.pending.size, - ), + const rejected = Object.assign( + new Error('Mapillary rejected the access token'), + { + keyRejected: true, + }, ); - const [tile] = state.coverage.tiles.values(); - assert.equal(tile.pointsDrawn, 100); - const settle = async () => { - coverage.refresh(); - await until(() => !state.coverage.pending.size); - await new Promise((resolve) => setTimeout(resolve, 10)); - }; - // +8%: under the threshold, nothing is redrawn. - tuning.maxPoints = 108; + source.calls[0].reject(rejected); await settle(); - assert.equal(tile.pointsDrawn, 100); - // +8% again: +16% since the tile was last drawn, so it is redrawn. - tuning.maxPoints = 116; + assert.equal(state.coverage.blocked, 'rejected'); + assert.equal(coverage.stats().keyRequired, true, 'gated like a missing key'); + assert.equal(coverage.stats().keyRejected, true); + assert.match(coverage.stats().error, /rejected MAPILLARY_CLIENT_TOKEN/); + viewer.view.lon += 0.05; + coverage.refresh(); + assert.equal(source.calls.length, 1, 'panning asks for nothing more'); + // Turning the layer off forgets the verdict; the next run asks again. + coverage.clear(); + coverage.unblock(); + assert.equal(coverage.stats().keyRejected, false); + assert.equal(coverage.stats().error, null); + coverage.refresh(); + assert.equal(source.calls.length, 2); + coverage.clear(); +}); + +test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const { viewer, source, coverage } = setup(); + coverage.refresh(); + source.calls[0].reject( + Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + assert.match(coverage.stats().error, /rate-limited/); + assert.equal(coverage.stats().keyRequired, false); + viewer.view.lon += 0.05; + coverage.refresh(); + assert.equal(source.calls.length, 1, 'held: nothing is requested'); + t.mock.timers.tick(30_000); + assert.equal(source.calls.length, 2, 'one refresh once the wait is over'); + assert.equal(coverage.stats().error, null); + coverage.clear(); + coverage.unblock(); +}); + +test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => { + const { state, source, coverage, centre } = setup(); + const line = (n) => [ + [ + [centre.lon - 0.001, centre.lat + n * 1e-6], + [centre.lon + 0.001, centre.lat + n * 1e-6], + ], + ]; + // 700 newer flat sequences, then 300 older 360° ones. + const sequences = []; + for (let i = 0; i < 700; i++) + sequences.push({ + id: `flat-${i}`, + parts: line(i), + capturedAt: 2_000_000_000_000 + i, + }); + for (let i = 0; i < 300; i++) + sequences.push({ + id: `pano-${i}`, + parts: line(i), + capturedAt: 1_000_000_000_000 + i, + isPano: true, + }); + state.filter = { pano: 'pano', sinceDays: 0 }; + coverage.refresh(); + source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); await settle(); - assert.equal(tile.pointsDrawn, 116, 'the grown share is drawn'); + const [entry] = state.coverage.tiles.values(); + const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES); + assert.equal( + entry.sequences.size, + Math.min(300, cap), + 'every 360° line is drawn', + ); + assert.equal(coverage.stats().count, Math.min(300, cap)); + // Picking and the highlight look sequences up among the drawn lines. + assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up'); + assert.equal(entry.sequences.has('flat-699'), false); + state.filter = { pano: 'all', sinceDays: 0 }; + coverage.rebuild(); + assert.equal(entry.sequences.size, cap, 'all imagery is capped as before'); + assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn'); + assert.equal(entry.sequences.has('pano-299'), false, 'over the cap'); + coverage.clear(); +}); + +test('a sequence that crosses a tile edge is counted once', async () => { + const { viewer, source, coverage, centre } = setup(); + // Look straight down on the edge between this tile and its east neighbour. + const { east } = tileBounds(centre.tile.x, centre.tile.y, 14); + viewer.view.lon = east; + coverage.refresh(); + assert.equal(source.calls.length, 2, 'two tiles in view'); + const across = [ + [ + [east - 0.001, centre.lat], + [east + 0.001, centre.lat], + ], + ]; + for (const call of source.calls) { + const [, x, y] = call.key.split('/').map(Number); + call.resolve( + encodeCoverageTile( + { x, y, z: 14 }, + { + sequences: [ + { id: 'across', parts: across }, + { id: `only-${x}`, parts: across }, + ], + }, + ), + ); + } + await settle(); + assert.equal(coverage.stats().count, 3, 'across, plus one per tile'); + coverage.clear(); +}); + +/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */ +function tiltedCamera({ lon, lat, height, heading, pitch }) { + return rayCamera({ + lon, + lat, + altitude: height, + heading, + pitch, + width: 100, + height: 100, + fovX: 60, + fovY: 40, + }); +} + +/** + * Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east + * at ground `aheadM` metres out; the globe terrain knows the ground. + */ +function denverStreetView(aheadM) { + const z = 14; + const x = lonToTileX(-104.99, z); + const y = latToTileY(39.74, z); + const tile = tileBounds(x, y, z); + const lon = (tile.west + tile.east) / 2; + const lat = (tile.south + tile.north) / 2; + const ground = 1600; + const above = 300; + const context = setup(); + context.viewer.scene.globe = { + show: true, + ellipsoid: Cesium.Ellipsoid.WGS84, + getHeight: () => ground, + }; + const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD); + context.viewer.camera = tiltedCamera({ + lon, + lat, + height: ground + above, + heading: 90, + pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD, + }); + context.coverage.refresh(); + const keys = context.source.calls.map((call) => call.key); + /** Requested tiles as [dx, dy] from the tile under the camera. */ + const offsets = keys.map((k) => { + const [, tx, ty] = k.split('/').map(Number); + return [tx - x, ty - y]; + }); + return { + ...context, + keys, + offsets, + key: (dx, dy) => `${z}/${x + dx}/${y + dy}`, + }; +} + +test('a tilted street view over ground 1,600 m up asks for the ground it looks at, nearest the camera first', () => { + // The screen centre meets the ground at the centre of the next tile east. + const { state, coverage, keys, offsets, key } = denverStreetView( + (tileWidthM) => tileWidthM, + ); + assert.equal(state.coverage.zoom, 14, 'height measured from the ground'); + assert.equal(keys[0], key(0, 0), 'the tile under the camera first'); + assert.ok(keys.includes(key(1, 0)), 'the tile the screen centre looks at'); + for (const [dx, dy] of offsets) + assert.ok(dx >= 0 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in view`); + coverage.clear(); +}); + +test('a street view toward the horizon asks only for the ground within range', () => { + // The screen centre meets the ground 6 km out, past the 3 km range. + const { coverage, keys, offsets, key } = denverStreetView(() => 6000); + assert.equal(keys[0], key(0, 0), 'the tile under the camera first'); + for (const [dx, dy] of offsets) + assert.ok(dx >= 0 && dx <= 1 && Math.abs(dy) <= 1, `${dx},${dy} near it`); + coverage.clear(); +}); + +test('the selected sequence is drawn over the coverage, and goes with the selection', async () => { + const { viewer, source, state, coverage, bytes } = setup(); + coverage.refresh(); + source.calls[0].resolve(bytes); + await settle(); + const lines = () => [...viewer.scene.groundPrimitives.items]; + assert.equal(lines().length, 1, 'the coverage'); + coverage.highlight('seq-1'); + assert.equal(lines().length, 2); + const highlight = lines().at(-1); + assert.equal(state.coverage.highlight, highlight, 'drawn last, on top'); + assert.equal(highlight.classificationType, Cesium.ClassificationType.BOTH); + coverage.highlight('not-drawn'); + assert.equal(lines().length, 1, 'nothing to highlight off the map'); + coverage.highlight(null); + assert.equal(state.coverage.highlight, null); + coverage.clear(); + assert.equal(lines().length, 0); +}); + +test('a tile that lands with the selected sequence draws the highlight again, on top', async () => { + const { viewer, source, state, coverage, bytes } = setup(); + state.sequence.selectedId = 'seq-1'; + coverage.refresh(); + assert.equal(state.coverage.highlight, null, 'nothing loaded yet'); + source.calls[0].resolve(bytes); + await settle(); + assert.ok(state.coverage.highlight, 'highlighted once its tile is in'); + assert.equal( + [...viewer.scene.groundPrimitives.items].at(-1), + state.coverage.highlight, + ); + coverage.clear(); +}); + +test("a zoom change drops the old zoom's lines", async () => { + const { viewer, source, state, coverage, bytes } = setup(); + coverage.refresh(); + source.calls[0].resolve(bytes); + await settle(); + const [old] = viewer.scene.groundPrimitives.items; + assert.ok(old, 'the z14 lines are drawn'); + viewer.view.height = 3000; // zoom out to z13 + coverage.refresh(); + assert.equal(state.coverage.zoom, 13); + assert.equal(viewer.scene.groundPrimitives.items.has(old), false); + assert.ok(state.coverage.pending.size > 0, 'the new zoom is loading'); + coverage.clear(); +}); + +test('a rate limit holds every refresh inside its wait, not only the first', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const { viewer, source, coverage } = setup(); + coverage.refresh(); + source.calls[0].reject( + Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + t.mock.timers.tick(1000); + viewer.view.lon += 0.05; + coverage.refresh(); + assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait'); + t.mock.timers.tick(28_000); + viewer.view.lon += 0.05; + coverage.refresh(); + assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait'); + t.mock.timers.tick(1000); + assert.ok(source.calls.length > 1, 'asked again once the wait is over'); + coverage.clear(); + coverage.unblock(); +}); + +test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => { + const { source, state, coverage, tileKey } = setup(); + let terrainLoaded; + state.coverage.terrainReady = new Promise((resolve) => { + terrainLoaded = resolve; + }); + const unhandled = []; + const onUnhandled = (reason) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + coverage.refresh(); + source.calls + .find((call) => call.key === tileKey) + .reject(new Error('Mapillary tiles HTTP 500')); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500'); + assert.deepEqual(unhandled, []); + } finally { + terrainLoaded(); + await settle(); + process.off('unhandledRejection', onUnhandled); + } +}); + +test('the selected sequence is drawn and highlighted even past the per-tile cap', async () => { + const { state, source, coverage, centre } = setup(); + const line = (n) => [ + [ + [centre.lon - 0.001, centre.lat + n * 1e-6], + [centre.lon + 0.001, centre.lat + n * 1e-6], + ], + ]; + const sequences = []; + for (let i = 0; i < 700; i++) + sequences.push({ + id: `new-${i}`, + parts: line(i), + capturedAt: 2_000_000_000_000 + i, + }); + sequences.push({ + id: 'old', + parts: line(800), + capturedAt: 1_000_000_000_000, + }); + state.sequence.selectedId = 'old'; + coverage.refresh(); + source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); + await settle(); + const [entry] = state.coverage.tiles.values(); + assert.ok(entry.sequences.has('old'), 'the selected line is drawn'); + coverage.highlight('old'); + assert.ok(state.coverage.highlight, 'the selected line is highlighted'); + coverage.clear(); }); diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index 047eebcc2..a61036b84 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -1,7 +1,6 @@ import * as Cesium from 'cesium'; import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js'; import { COLORS, POSITION_PICK_ID } from './policy.js'; -import { refineHeights } from './groundCast.js'; import { createHorizonCull } from './view.js'; const SPRITE_ID = 'street-level:marker'; @@ -22,10 +21,9 @@ function markerLook(style) { }; } -/** On-globe marker for the open image; in terrain mode it stands on bare earth. */ -export function createMarker({ state, parts }) { +/** On-globe marker for the open image, clamped to the ground. */ +export function createMarker({ state }) { const { render, sprites } = state.services; - let last = null; function requestRender() { render?.governorRequestRender?.('street-level-marker'); @@ -47,38 +45,10 @@ export function createMarker({ state, parts }) { sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection); } - /** - * Terrain-mode height (bare earth refined by mesh samples), or null to - * clamp. Requests a missing terrain cell or mesh sample as a side effect. - */ - function castHeight(position) { - const ground = parts?.groundCaster; - if (state.surface !== 'terrain' || !ground) return null; - const { lon, lat } = position; - const dem = ground.groundAt(lon, lat); - if (dem === null) { - ground.prepare([[lon, lat]]).then((ready) => { - if (ready && last?.position === position) - set(position, last.bearing, last.style); - }); - return null; - } - const mesh = parts.meshSampler?.meshAt(lon, lat); - if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]); - return refineHeights([{ dem, mesh }])[0]; - } - - // A new mesh sample may move the marker onto (or off) the road surface. - parts?.meshSampler?.onSampled(() => { - if (last && state.surface === 'terrain') - set(last.position, last.bearing, last.style); - }); - /** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */ function set(position, bearing, style = null) { const collection = state.marker.collection; if (!collection) return; - last = position ? { position, bearing, style } : null; if (!position) { collection.removeAll(); state.marker.billboard = null; @@ -86,16 +56,7 @@ export function createMarker({ state, parts }) { requestRender(); return; } - const height = castHeight(position); - const cartesian = Cesium.Cartesian3.fromDegrees( - position.lon, - position.lat, - height ?? 0, - ); - const heightReference = - height === null - ? Cesium.HeightReference.CLAMP_TO_GROUND - : Cesium.HeightReference.NONE; + const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat); const look = markerLook(style); if (state.marker.billboard && state.marker.look !== look.key) { collection.remove?.(state.marker.billboard); @@ -111,12 +72,11 @@ export function createMarker({ state, parts }) { width: look.size, height: look.size, alignedAxis: Cesium.Cartesian3.UNIT_Z, - heightReference, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: Number.POSITIVE_INFINITY, verticalOrigin: Cesium.VerticalOrigin.CENTER, }); } else { - state.marker.billboard.heightReference = heightReference; state.marker.billboard.position = cartesian; } state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0); @@ -139,17 +99,11 @@ export function createMarker({ state, parts }) { state.marker.billboard = null; } - /** Re-place the marker after the surface mode changes. */ - function setSurface() { - if (last) set(last.position, last.bearing, last.style); - } - return { ensure, set, clear: () => set(null), setVisible, - setSurface, destroy, }; } diff --git a/src/layers/streetLevel/marker.test.mjs b/src/layers/streetLevel/marker.test.mjs index e9e565bb6..d0a1e486a 100644 --- a/src/layers/streetLevel/marker.test.mjs +++ b/src/layers/streetLevel/marker.test.mjs @@ -2,17 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { createMarker } from './marker.js'; -import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js'; - -// The marker glyph draws on a canvas; Node has none, so drawing is a no-op. -const noop = () => {}; -const context2d = new Proxy({}, { get: () => noop, set: () => true }); -globalThis.document ??= { - createElement: () => ({ getContext: () => context2d }), -}; /** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */ -function setup({ parts = {}, surface = 'draped' } = {}) { +function setup() { const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; const preRender = new Set(); const viewer = { @@ -34,7 +26,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) { const state = { services: {}, viewer, - surface, marker: { // Stands in for the billboard collection; ensure() keeps it. collection: { @@ -46,7 +37,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) { billboard: null, }, }; - const marker = createMarker({ state, parts }); + const marker = createMarker({ state }); const frame = () => { for (const listener of [...preRender]) listener(); }; @@ -79,114 +70,23 @@ test('destroying the marker stops the horizon cull', () => { assert.equal(preRender.size, 0); }); -/** - * Bare earth from a ground caster (null until its cell is fetched) and the - * sampled Google 3D surface from a mesh sampler (undefined until sampled). - */ -function terrainParts({ dem = null, mesh } = {}) { - const ground = { dem, prepares: [] }; - const sampler = { mesh, requests: [], listeners: [] }; - return { - ground, - sampler, - parts: { - groundCaster: { - groundAt: () => ground.dem, - prepare(points) { - let resolve; - const promise = new Promise((done) => { - resolve = done; - }); - ground.prepares.push({ points, resolve }); - return promise; - }, - }, - meshSampler: { - meshAt: () => sampler.mesh, - request: (points) => sampler.requests.push(points), - onSampled: (listener) => sampler.listeners.push(listener), - }, - }, - }; -} - -const heightOf = (billboard) => - Cesium.Cartographic.fromCartesian(billboard.position).height; const SPOT = { lon: -121.49, lat: 38.58 }; -test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => { - const { parts } = terrainParts({ dem: 10, mesh: 11.5 }); - const { state, marker } = setup({ parts, surface: 'terrain' }); - marker.set(SPOT, 45); - const billboard = state.marker.billboard; - assert.equal(billboard.heightReference, Cesium.HeightReference.NONE); - assert.ok( - Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3, - `height ${heightOf(billboard)}`, - ); -}); - -test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => { - const { parts, sampler } = terrainParts({ dem: 10 }); - const { state, marker } = setup({ parts, surface: 'terrain' }); - marker.set(SPOT, 45); - const billboard = state.marker.billboard; - assert.equal(billboard.heightReference, Cesium.HeightReference.NONE); - assert.ok( - Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3, - `height ${heightOf(billboard)}`, - ); - assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]); - // The sample lands: the marker moves onto the road surface. - sampler.mesh = 10.5; - for (const listener of sampler.listeners) listener(); - assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3); -}); - -test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => { - const { parts, ground } = terrainParts({ mesh: 11 }); - const { state, marker } = setup({ parts, surface: 'terrain' }); +test('the marker is clamped to the ground and turned to the bearing', () => { + const { state, marker } = setup(); marker.set(SPOT, 45); const billboard = state.marker.billboard; assert.equal( billboard.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND, ); - assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]); - ground.dem = 10; - ground.prepares[0].resolve(true); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(billboard.heightReference, Cesium.HeightReference.NONE); - assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3); - assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept'); -}); - -test('ground that arrives after the marker moved on does not move it back', async () => { - const { parts, ground } = terrainParts(); - const { state, marker } = setup({ parts, surface: 'terrain' }); - marker.set(SPOT, 0); - const elsewhere = { lon: -121.5, lat: 38.6 }; - marker.set(elsewhere, 0); - ground.dem = 10; - ground.prepares[0].resolve(true); - await new Promise((resolve) => setTimeout(resolve, 0)); - const placed = Cesium.Cartographic.fromCartesian( - state.marker.billboard.position, - ); - assert.ok( - Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9, - ); -}); - -test('draped mode clamps the marker to the globe whatever the ground says', () => { - const { parts, ground } = terrainParts({ dem: 10, mesh: 11 }); - const { state, marker } = setup({ parts, surface: 'draped' }); - marker.set(SPOT, 0); - assert.equal( - state.marker.billboard.heightReference, - Cesium.HeightReference.CLAMP_TO_GROUND, - ); - assert.equal(ground.prepares.length, 0); + assert.equal(billboard.rotation, -Cesium.Math.toRadians(45)); + // Moving it keeps the same billboard. + marker.set({ lon: -121.5, lat: 38.6 }, 90); + assert.equal(state.marker.billboard, billboard); + const placed = Cesium.Cartographic.fromCartesian(billboard.position); + assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9); + assert.equal(billboard.rotation, -Cesium.Math.toRadians(90)); }); test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => { @@ -205,9 +105,6 @@ test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pi // Dragging the panorama keeps the look and the billboard. marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView); assert.equal(state.marker.billboard, cone); - // Re-placed for a surface change: still the cone. - marker.setSurface(); - assert.equal(state.marker.billboard, cone); // A Mapillary photo next (its provider has no marker): the pin. marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined); assert.equal(state.marker.billboard.imageId, 'sl-position:pin'); diff --git a/src/layers/streetLevel/meshSampler.js b/src/layers/streetLevel/meshSampler.js deleted file mode 100644 index 2711e8c68..000000000 --- a/src/layers/streetLevel/meshSampler.js +++ /dev/null @@ -1,371 +0,0 @@ -import * as Cesium from 'cesium'; -import { metresBetween, whenIdle } from './view.js'; - -/** - * Google 3D surface heights for ground casting (groundCast.js refineHeights): - * one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a - * pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the - * camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's - * mesh floor, a probe waits for the visible tileset to finish streaming and a - * sample counts only within the shared window around a real bare-earth height; - * anything else is a miss, retried later. Probes hit 3D tilesets only. - */ - -/** Cell size, in degrees (~11 m): samples are shared within a cell. */ -export const MESH_CELL_DEG = 0.0001; -/** Cells farther than this from the point under the camera are not probed. */ -export const MESH_SAMPLE_RADIUS_M = 900; -/** Main-thread time per idle slice, taking requests in and ranking included. */ -export const MESH_SAMPLE_BUDGET_MS = 6; -/** The queue is re-ranked once the camera has moved this far, in metres. */ -export const MESH_RERANK_M = 100; -/** A cell whose probe missed is tried again after this long. */ -export const MESH_MISS_RETRY_MS = 8000; -/** While the visible tileset is streaming, the next probe waits this long. */ -export const MESH_STREAMING_WAIT_MS = 500; -/** - * A sampled cell is probed again once the camera is half as far from it as at - * its last probe (finer tiles have loaded by then), down to this distance: - * at most a handful of probes per cell. - */ -export const MESH_REFRESH_MIN_M = 40; -/** A refreshed sample that moved less than this is not redrawn. */ -const MESH_REFRESH_CHANGE_M = 0.5; -/** Cached cells (and remembered misses) before they are dropped and refilled. */ -const MESH_CACHE_MAX = 80_000; -/** Listeners hear about new samples at most this often. */ -const MESH_NOTIFY_MS = 700; - -const cellOf = (value) => Math.round(value / MESH_CELL_DEG); - -/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */ -export const meshCellKey = (lon, lat) => - (cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000; - -/** Add a queued cell to a min-heap on `distance`. */ -function heapPush(heap, cell) { - let i = heap.push(cell) - 1; - while (i > 0) { - const parent = (i - 1) >> 1; - if (heap[parent].distance <= cell.distance) break; - heap[i] = heap[parent]; - i = parent; - } - heap[i] = cell; -} - -/** Restore the heap below `i` after its cell was replaced. */ -function siftDown(heap, i) { - const cell = heap[i]; - const n = heap.length; - for (;;) { - let child = 2 * i + 1; - if (child >= n) break; - if (child + 1 < n && heap[child + 1].distance < heap[child].distance) - child++; - if (heap[child].distance >= cell.distance) break; - heap[i] = heap[child]; - i = child; - } - heap[i] = cell; -} - -function heapPop(heap) { - const top = heap[0]; - const last = heap.pop(); - if (heap.length) { - heap[0] = last; - siftDown(heap, 0); - } - return top; -} - -/** Every top-level primitive that is not a 3D tileset: what a probe skips. */ -function overlays(scene) { - const out = []; - const primitives = scene.primitives; - for (let i = 0; i < primitives.length; i++) { - const primitive = primitives.get(i); - if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive); - } - return out; -} - -/** - * `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height, - * prior)` the application's mesh window, and `tilesReady(scene)` whether the - * visible tileset has finished streaming. - * @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options - */ -export function createMeshSampler({ - getViewer, - groundAt = null, - withinPrior = null, - tilesReady = null, - budgetMs = MESH_SAMPLE_BUDGET_MS, - radiusM = MESH_SAMPLE_RADIUS_M, - cacheMax = MESH_CACHE_MAX, - now = () => performance.now(), -}) { - /** cell key → sampled mesh height (ellipsoidal metres). */ - const heights = new Map(); - /** cell key → camera distance (m) at the cell's last probe. */ - const probedFrom = new Map(); - /** cell key → retry time for probes that hit nothing; bounded like the height cache. */ - const misses = new Map(); - /** cell key → queued cell, so a cell is queued once. */ - const wanted = new Map(); - /** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */ - let queue = []; - /** Requests not yet taken in: {points, next, centre} (centre: the camera then). */ - let intake = []; - /** Where the camera was when the queue was ranked. */ - let rankedFrom = null; - /** Running estimate of one probe's cost, so a slice stops before overrunning. */ - let probeMs = 1; - const listeners = new Set(); - let enabled = false; - let running = false; - let fresh = []; - let notifyTimer = null; - - /** The sampled mesh height for a point, or undefined when not sampled. */ - function meshAt(lon, lat) { - return heights.get(meshCellKey(lon, lat)); - } - - /** The point under the camera, with the camera's height, or null. */ - function cameraCentre() { - const carto = getViewer()?.camera?.positionCartographic; - if (!carto) return null; - return { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), - height: carto.height, - }; - } - - /** Camera distance (m) to a cell at `height`, from its ground distance. */ - function cameraDistance(ground, centre, height) { - const above = (centre.height ?? 0) - (height ?? 0); - return Number.isFinite(above) - ? Math.hypot(ground, Math.max(0, above)) - : ground; - } - - /** A sampled cell is probed again once the camera is half as far as before. */ - function dueForRefresh(key, distance) { - const last = probedFrom.get(key); - return last > MESH_REFRESH_MIN_M && distance < last / 2; - } - - /** A probe result the application would accept, or undefined. */ - function validated(height, lon, lat) { - if (!Number.isFinite(height)) return undefined; - if (!groundAt) return height; - const prior = groundAt(lon, lat); - if (!Number.isFinite(prior)) return undefined; - return !withinPrior || withinPrior(height, prior) ? height : undefined; - } - - /** - * Ask for the cells under [lon, lat] points; the list is read later, so do - * not mutate it. Out-of-range cells are dropped; callers ask again on move. - */ - function request(points) { - if (!enabled || !points?.length) return; - const centre = cameraCentre(); - if (!centre) return; - intake.push({ points, next: 0, centre }); - schedule(); - } - - /** Queue requested cells, oldest request first, until the slice reaches `until`. */ - function takeIn(until) { - const time = Date.now(); - while (intake.length) { - const pending = intake[0]; - const { points, centre } = pending; - while (pending.next < points.length) { - if ((pending.next & 255) === 255 && now() > until) return; - const [lon, lat] = points[pending.next++]; - if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; - const key = meshCellKey(lon, lat); - if (wanted.has(key)) continue; - const cell = { - key, - lon: cellOf(lon) * MESH_CELL_DEG, - lat: cellOf(lat) * MESH_CELL_DEG, - distance: 0, - }; - const ground = metresBetween(cell, centre); - if (ground > radiusM) continue; - if ( - heights.has(key) && - !dueForRefresh(key, cameraDistance(ground, centre, heights.get(key))) - ) - continue; - const retryAt = misses.get(key); - if (retryAt !== undefined) { - if (retryAt > time) continue; - misses.delete(key); - } - cell.distance = metresBetween(cell, rankedFrom); - wanted.set(key, cell); - heapPush(queue, cell); - } - intake.shift(); - } - } - - /** - * Rank the queue from where the camera is now, dropping cells it has left - * out of range: one linear heapify, run only after the camera has moved. - */ - function rerank(centre) { - rankedFrom = centre; - const kept = []; - for (const cell of queue) { - cell.distance = metresBetween(cell, centre); - if (cell.distance > radiusM) wanted.delete(cell.key); - else kept.push(cell); - } - queue = kept; - for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i); - } - - function clearQueue() { - intake = []; - wanted.clear(); - queue = []; - rankedFrom = null; - } - - function schedule() { - if (running || !enabled || !(queue.length || intake.length)) return; - running = true; - whenIdle(step, 300); - } - - function emit() { - notifyTimer = null; - const batch = fresh; - fresh = []; - for (const listener of [...listeners]) { - try { - listener(batch); - } catch (error) { - console.warn('[Data:StreetLevel] mesh listener error:', error); - } - } - } - - function step(deadline) { - running = false; - // The slice starts now: taking requests in and ranking count too. - const started = now(); - const until = - started + - Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs)); - const scene = getViewer()?.scene; - const centre = cameraCentre(); - if (!enabled || !scene?.sampleHeightSupported || !centre) return; - // Mid-stream tiles answer with coarse heights: wait for the stream. - if (tilesReady && !tilesReady(scene)) { - running = true; - setTimeout(() => { - running = false; - schedule(); - }, MESH_STREAMING_WAIT_MS); - return; - } - // Nearest first, ranked from where the camera was; out-of-range cells - // are dropped (they stay bare earth). - let worked = false; - if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) { - rerank(centre); - worked = true; - } - if (intake.length) { - // Leave room for a probe, so a stream of requests cannot starve them. - takeIn(until - probeMs); - worked = true; - } - const exclude = overlays(scene); - let probed = 0; - while (queue.length) { - // Stop before a probe would overrun the slice. A slice that did - // nothing else always probes once, so the queue drains. - if ((probed || worked) && now() + probeMs > until) break; - const { key, lon, lat } = heapPop(queue); - wanted.delete(key); - // Ranked from up to MESH_RERANK_M away: check the range from here. - const ground = metresBetween({ lon, lat }, centre); - if (ground > radiusM) continue; - probed++; - const probeStarted = now(); - let height; - try { - height = scene.sampleHeight( - Cesium.Cartographic.fromDegrees(lon, lat), - exclude, - ); - } catch { - height = undefined; - } - probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25; - height = validated(height, lon, lat); - const previous = heights.get(key); - if (height === undefined) { - // A miss, or a failed refresh: the old sample (if any) and the - // distance it was taken from stay, and the probe is retried after - // the same cooldown. - if (misses.size >= cacheMax) misses.clear(); - misses.set(key, Date.now() + MESH_MISS_RETRY_MS); - continue; - } - if (previous === undefined && heights.size >= cacheMax) forget(); - misses.delete(key); - heights.set(key, height); - probedFrom.set(key, cameraDistance(ground, centre, height)); - if ( - previous === undefined || - Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M - ) - fresh.push([lon, lat]); - } - if (fresh.length && !notifyTimer) - notifyTimer = setTimeout(emit, MESH_NOTIFY_MS); - schedule(); - } - - function forget() { - heights.clear(); - probedFrom.clear(); - misses.clear(); - } - - /** Probe only while overlays are cast (Google 3D at street zoom). */ - function setEnabled(on) { - enabled = on === true; - if (!enabled) clearQueue(); - else schedule(); - } - - /** Hear about newly sampled cells, as [lon, lat] cell centres. */ - function onSampled(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - } - - function destroy() { - enabled = false; - forget(); - clearQueue(); - listeners.clear(); - clearTimeout(notifyTimer); - notifyTimer = null; - } - - return { meshAt, request, setEnabled, onSampled, destroy }; -} diff --git a/src/layers/streetLevel/meshSampler.test.mjs b/src/layers/streetLevel/meshSampler.test.mjs deleted file mode 100644 index 065c22645..000000000 --- a/src/layers/streetLevel/meshSampler.test.mjs +++ /dev/null @@ -1,405 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import * as Cesium from 'cesium'; -import { - MESH_CELL_DEG, - MESH_MISS_RETRY_MS, - MESH_REFRESH_MIN_M, - MESH_SAMPLE_BUDGET_MS, - MESH_STREAMING_WAIT_MS, - createMeshSampler, -} from './meshSampler.js'; -import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js'; -import { metresBetween } from './view.js'; - -const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms)); -const RAD = Math.PI / 180; - -/** A scene whose mesh is 100 m everywhere except where `holes` say. */ -function fakeViewer({ holes = () => false } = {}) { - const tileset = Object.create(Cesium.Cesium3DTileset.prototype); - const overlay = { name: 'lines' }; - const probes = []; - const scene = { - sampleHeightSupported: true, - primitives: { - length: 2, - get: (i) => [tileset, overlay][i], - }, - sampleHeight(carto, exclude) { - const lon = carto.longitude / RAD; - const lat = carto.latitude / RAD; - probes.push({ lon, lat, exclude }); - return holes(lon, lat) ? undefined : 100; - }, - }; - return { - probes, - overlay, - viewer: { - scene, - camera: { - positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD }, - }, - }, - }; -} - -test('cells are probed once, nearest first, with overlays excluded', async () => { - const { viewer, probes, overlay } = fakeViewer(); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.setEnabled(true); - const heard = []; - sampler.onSampled((batch) => heard.push(batch.length)); - sampler.request([ - [10.004, 50], - [10.001, 50], - [10.00101, 50.00001], // same ~11 m cell as the previous point - ]); - await settle(1000); - assert.equal(probes.length, 2); - assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first'); - assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit'); - assert.equal(sampler.meshAt(10.001, 50), 100); - assert.equal(sampler.meshAt(10.00101, 50.00001), 100); - assert.deepEqual(heard, [2]); - sampler.request([[10.001, 50]]); - await settle(); - assert.equal(probes.length, 2, 'a sampled cell is not probed again'); - sampler.destroy(); -}); - -test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => { - const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 }); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.request([[10.001, 50]]); - await settle(); - assert.equal(probes.length, 0, 'disabled: nothing queued'); - sampler.setEnabled(true); - sampler.request([ - [10.05, 50], // ~3.6 km away - [10.005, 50], // a hole: tiles not streamed - ]); - await settle(); - assert.equal(probes.length, 1); - assert.equal(sampler.meshAt(10.05, 50), undefined); - assert.equal(sampler.meshAt(10.005, 50), undefined); - assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG); - sampler.destroy(); -}); - -/** Probes run on a 16 ms timer headless; with mocked timers, run them now. */ -function probeNow(t) { - t.mock.timers.tick(20); -} - -test('a missed cell is probed again after the retry window, not before', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, probes } = fakeViewer({ holes: () => true }); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 1, 'probed, and missed'); - t.mock.timers.tick(MESH_MISS_RETRY_MS - 100); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 1, 'within the window: not probed'); - t.mock.timers.tick(100 + 1); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 2, 'after the window: probed again'); - sampler.destroy(); -}); - -test('remembered misses are bounded like the samples', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, probes } = fakeViewer({ holes: () => true }); - const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 }); - sampler.setEnabled(true); - for (const lon of [10.001, 10.002, 10.003]) { - sampler.request([[lon, 50]]); - probeNow(t); - } - assert.equal(probes.length, 3); - // The third miss overflowed the list, which was dropped: the first cell - // is asked about again rather than remembered forever. - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 4); - sampler.destroy(); -}); - -test('destroy forgets the samples', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer } = fakeViewer(); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(sampler.meshAt(10.001, 50), 100); - sampler.destroy(); - assert.equal(sampler.meshAt(10.001, 50), undefined); -}); - -/** - * A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs` - * and each read of the camera `cameraMs` (work done before any probe). - */ -function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const fake = fakeViewer({ holes }); - const clock = { now: 0 }; - const { scene, camera } = fake.viewer; - const probe = scene.sampleHeight; - scene.sampleHeight = (...args) => { - clock.now += probeMs; - return probe(...args); - }; - const position = { ...camera.positionCartographic }; - Object.defineProperty(camera, 'positionCartographic', { - get: () => { - clock.now += cameraMs; - return position; - }, - }); - const sampler = createMeshSampler({ - getViewer: () => fake.viewer, - now: () => clock.now, - }); - sampler.setEnabled(true); - /** Run one idle slice; its length on the fake clock. */ - function slice() { - const started = clock.now; - probeNow(t); - return clock.now - started; - } - /** Put the camera over a point. */ - function moveTo(lon, lat) { - position.longitude = lon * RAD; - position.latitude = lat * RAD; - } - return { ...fake, sampler, slice, moveTo }; -} - -/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */ -function grid(lon, lat, n, step) { - const points = []; - for (let i = 0; i < n; i++) - for (let j = 0; j < n; j++) - points.push([ - lon + (i - (n - 1) / 2) * step, - lat + (j - (n - 1) / 2) * step, - ]); - return points; -} - -test('cells out of range when asked are not queued, even once the camera reaches them', (t) => { - const { sampler, probes, slice, moveTo } = clocked(t); - // 400 cells ~3.6 km east, past the sampler's range. - sampler.request(grid(10.05, 50, 20, 0.0002)); - // The camera gets there before any idle slice runs. - moveTo(10.05, 50); - for (let i = 0; i < 5; i++) slice(); - assert.equal(probes.length, 0, 'nothing was queued for later'); - // Asked again from there (as a camera move does), they are probed. - sampler.request([[10.05, 50]]); - slice(); - assert.equal(probes.length, 1); - sampler.destroy(); -}); - -test('a slice keeps to its budget, counting the work before its first probe', (t) => { - const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 }); - sampler.request(grid(10.001, 50, 5, 0.0002)); - const lengths = []; - while (probes.length < 25 && lengths.length < 40) lengths.push(slice()); - assert.equal(probes.length, 25, 'every cell is probed in the end'); - for (const length of lengths) - assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`); - sampler.destroy(); -}); - -test('a large queue is ranked once, not sorted again every slice', (t) => { - const { sampler, probes, slice, moveTo } = clocked(t); - // 2,500 cells ~17 m apart around the camera, all within range. - const cells = grid(10, 50, 50, 0.00015); - sampler.request(cells); - const sort = Array.prototype.sort; - let sorted = 0; - t.after(() => (Array.prototype.sort = sort)); - Array.prototype.sort = function (...args) { - if (this.length >= 100) sorted += this.length; - return sort.apply(this, args); - }; - for (let i = 0; i < 10; i++) slice(); - const first = probes.map((probe) => - metresBetween(probe, { lon: 10, lat: 50 }), - ); - assert.ok(first.length >= 10, 'probes ran'); - first.forEach((distance, i) => - assert.ok( - !i || distance >= first[i - 1] - 0.01, - 'nearest the camera first, across slices', - ), - ); - Array.prototype.sort = sort; - assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`); - // The camera moves ~200 m east: the queue is ranked from there. - const done = probes.length; - moveTo(10.003, 50); - slice(); - slice(); - const after = probes - .slice(done) - .map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 })); - assert.ok(after.length > 0); - assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`); - sampler.destroy(); -}); - -test('the sampling range is measured the short way round the date line', (t) => { - const { sampler, probes, slice, moveTo } = clocked(t); - moveTo(179.9999, 0); - sampler.request([ - [-179.9999, 0], // ~22 m away, across the date line - [179.99, 0], // ~1.1 km away, past the range - ]); - for (let i = 0; i < 5; i++) slice(); - assert.equal(probes.length, 1, 'only the neighbour across ±180°'); - assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG); - sampler.destroy(); -}); - -/** A scene whose mesh height can change (finer tiles) and a camera that can move. */ -function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) { - const tileset = Object.create(Cesium.Cesium3DTileset.prototype); - const probes = []; - const viewer = { - surface, - ready, - scene: { - sampleHeightSupported: true, - primitives: { length: 1, get: () => tileset }, - sampleHeight(carto) { - probes.push(carto); - return viewer.surface; - }, - }, - camera: { - positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height }, - }, - }; - return { viewer, probes }; -} - -function liveSampler(viewer, options = {}) { - return createMeshSampler({ - getViewer: () => viewer, - groundAt: () => 75, - withinPrior: meshFloorSampleWithinPrior, - tilesReady: () => viewer.ready(), - ...options, - }); -} - -test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => { - const { viewer, probes } = liveViewer({ surface: 80, height: 700 }); - const sampler = liveSampler(viewer); - sampler.setEnabled(true); - const heard = []; - sampler.onSampled((batch) => heard.push(batch.length)); - sampler.request([[10.001, 50]]); - await settle(900); - assert.equal(sampler.meshAt(10.001, 50), 80); - // Finer tiles load: the surface is really 100 m. - viewer.surface = 100; - sampler.request([[10.001, 50]]); - await settle(); - assert.equal(probes.length, 1, 'no new probe from the same distance'); - assert.equal(sampler.meshAt(10.001, 50), 80); - viewer.camera.positionCartographic.height = 250; - sampler.request([[10.001, 50]]); - await settle(900); - assert.equal(probes.length, 2); - assert.equal(sampler.meshAt(10.001, 50), 100); - assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw'); - sampler.destroy(); -}); - -test('refreshing stops once a cell was probed from close by', async () => { - const { viewer, probes } = liveViewer({ surface: 80, height: 110 }); - viewer.camera.positionCartographic.longitude = 10.001 * RAD; - const sampler = liveSampler(viewer); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - await settle(); - viewer.camera.positionCartographic.height = 85; - sampler.request([[10.001, 50]]); - await settle(); - assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`); - sampler.destroy(); -}); - -test('probes wait while the visible tileset is still streaming', async () => { - let ready = false; - const { viewer, probes } = liveViewer({ ready: () => ready }); - const sampler = liveSampler(viewer); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - await settle(MESH_STREAMING_WAIT_MS * 2); - assert.equal(probes.length, 0); - ready = true; - await settle(MESH_STREAMING_WAIT_MS * 2); - assert.equal(probes.length, 1); - assert.equal(sampler.meshAt(10.001, 50), 80); - sampler.destroy(); -}); - -test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => { - const { viewer } = liveViewer({ surface: 300 }); - const unknown = liveSampler(viewer, { groundAt: () => null }); - unknown.setEnabled(true); - unknown.request([[10.001, 50]]); - const outside = liveSampler(viewer); - outside.setEnabled(true); - outside.request([[10.001, 50]]); - await settle(); - assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept'); - assert.equal( - outside.meshAt(10.001, 50), - undefined, - '225 m above it: not kept', - ); - unknown.destroy(); - outside.destroy(); -}); - -test('a failed refresh keeps the last good distance and is retried after the cooldown', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, probes } = liveViewer({ surface: 90, height: 700 }); - viewer.camera.positionCartographic.longitude = 10.001 * RAD; - const sampler = liveSampler(viewer); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(sampler.meshAt(10.001, 50), 90); - // The camera comes down to 20 m and the refresh misses (tiles in flux). - viewer.camera.positionCartographic.height = 110; - viewer.surface = undefined; - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 2); - assert.equal(sampler.meshAt(10.001, 50), 90, 'the old sample stays'); - viewer.surface = 100; - t.mock.timers.tick(MESH_MISS_RETRY_MS - 100); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 2, 'not before the cooldown'); - t.mock.timers.tick(100); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 3, 'retried after it'); - assert.equal(sampler.meshAt(10.001, 50), 100); - sampler.destroy(); -}); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index 705fa8524..0ca4e3eb2 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -6,19 +6,29 @@ import { import { imageSize } from './imageSize.js'; import { msg, t } from '../../i18n/index.js'; +/** + * EXPAND puts the viewer's wrap full screen, or (iPhone) opens it as a + * popover. Each is tried on its own: a browser that cannot parse one + * (Safari before 16.4) throws on the selector. + */ +const EXPANDED_SELECTORS = [ + '.sl-viewer-wrap:fullscreen', + '.sl-viewer-wrap:popover-open', +]; + /** A photo download (tens of kilobytes to a few megabytes). */ -export const IMAGE_TIMEOUT_MS = 15_000; +const IMAGE_TIMEOUT_MS = 15_000; /** A picture file or decoded side past these is refused (a photo is a few MB). */ export const MAX_IMAGE_BYTES = 48 * 1024 * 1024; export const MAX_IMAGE_SIDE = 16_384; /** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */ -export const MAX_IMAGE_PIXELS = 8_192 * 8_192; +const MAX_IMAGE_PIXELS = 8_192 * 8_192; /** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */ const HEADER_BYTES = 256 * 1024; /** Whether a picture this size is past what the viewer decodes. */ -export function tooManyPixels(width, height) { +function tooManyPixels(width, height) { return ( width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE || @@ -27,7 +37,7 @@ export function tooManyPixels(width, height) { } /** Panorama texture widths: enough for a docked viewer, and for an expanded one. */ -export const PANO_MID_SIZE = Object.freeze([2048, 1024]); +const PANO_MID_SIZE = Object.freeze([2048, 1024]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); /** @@ -145,7 +155,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { * A picture refused for its size (`tooLarge`): the viewer will not load * that file another way, an having no size limit at all. */ -export function oversized(failure) { +function oversized(failure) { return Object.assign(new Error(failure), { tooLarge: true }); } @@ -535,14 +545,14 @@ export function createPhotoViewer({ /** * Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its * caption) is the provider's before the panel's, which would collapse; - * expanded, the dialog's own Esc shrinks it. + * expanded, the panel's own Esc shrinks it. */ function onEscapeKey(event) { if ( event.key !== 'Escape' || event.shiftKey || event.defaultPrevented || - root?.closest?.('.sl-viewer-wrap-expanded') || + isExpanded() || !onEscape() ) return; @@ -739,9 +749,15 @@ export function createPhotoViewer({ } } - /** The viewer is the expanded dialog, not docked in the panel. */ + /** The viewer is expanded (full screen, or the iPhone popover), not docked in the panel. */ function isExpanded() { - return Boolean(root?.closest?.('.sl-viewer-wrap-expanded')); + return EXPANDED_SELECTORS.some((selector) => { + try { + return Boolean(root?.closest?.(selector)); + } catch { + return false; + } + }); } /** Another photo, or none: a sharper panorama still on its way is not needed. */ diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 09df91c2e..4f81f2490 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -160,7 +160,7 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th assert.equal(asked, 1); assert.equal(docked.prevented && docked.stopped, true); root.closest = (selector) => - selector.includes('sl-viewer-wrap-expanded') ? {} : null; + selector.includes('sl-viewer-wrap:fullscreen') ? {} : null; const expanded = press(); assert.equal(asked, 1, 'not asked while expanded'); assert.equal(expanded.prevented, false); @@ -536,3 +536,38 @@ test('an that turns out too large is refused and let go', async (t) => { await assert.rejects(aborted); assert.equal(images.at(-1).src, '', 'its download stops'); }); + +test('a browser without :fullscreen / :popover-open (Safari < 16.4) still opens a flat photo and hands Esc on', async () => { + let asked = 0; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + onEscape: () => ++asked > 0, + load: async (url) => ({ src: url }), + }); + const host = fakeHost(); + await viewer.mount(host); + const root = host.children[0]; + // Safari 16.0–16.3: neither pseudo-class parses, so closest() throws. + root.closest = (selector) => { + if (/:fullscreen|:popover-open/.test(selector)) + throw new SyntaxError(`'${selector}' is not a valid selector`); + return null; + }; + let failure = null; + try { + await viewer.open('f'); + } catch (error) { + failure = error; + } + assert.equal(failure, null, 'opening a flat photo does not throw'); + const event = { key: 'Escape' }; + event.preventDefault = () => {}; + event.stopPropagation = () => {}; + assert.doesNotThrow(() => root.fire('keydown', event), 'Esc does not throw'); + assert.equal(asked, 1, 'Esc reaches the provider'); +}); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index a5e95d7d8..4bf7f953d 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -5,10 +5,16 @@ const msg = (text) => text; /** Identity and shared tuning for the provider-neutral Street Level layer. */ export const STREET_LEVEL_LAYER_ID = 'street-level'; +/** + * The pixel window a click picks in: coverage lines are 2.5 px wide and there + * is no hover cue, so a click a few pixels off still finds the line. + */ +export const CLICK_PICK_WINDOW_PX = 9; + /** Pick id of the viewer position marker; the core owns it, not a provider. */ export const POSITION_PICK_ID = 'sl:pos'; -/** One colour per imagery source: chip, coverage lines, points, cones and legend. */ +/** One colour per imagery source: chip, coverage lines and cones. */ export const PROVIDER_COLORS = Object.freeze({ mapillary: '#05cb63', google: '#4285f4', @@ -31,17 +37,11 @@ export const MAX_SINCE_DAYS = 36_500; /** Filter every provider starts with: all imagery, any date. */ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); -/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */ +/** Nearest-image search radius in metres. */ export const NEAREST_RADIUS_M = 50; -/** - * The only map stack camera follow is offered on: elsewhere a camera at eye - * height looks at a smeared texture. - */ -export const FOLLOW_MAP_STACK_ID = 'photoreal'; - -/** Eye height above the sampled ground when the globe camera follows the viewer. */ -export const FOLLOW_EYE_HEIGHT_M = 2.4; +/** The Google 3D map stack: Street View may be shown only on it. */ +export const GOOGLE_3D_MAP_STACK_ID = 'photoreal'; /** Above this, a ground click is a misclick, not a street. */ export const GROUND_CLICK_MAX_HEIGHT_M = 3_000; @@ -50,3 +50,9 @@ export const GROUND_CLICK_MAX_HEIGHT_M = 3_000; export const AIM_AT_GROUND_MESSAGE = msg( 'Aim the middle of the view at a street to look for imagery there', ); + +/** The key gate's label for the pill and the layer list, or null when the key is fine. */ +export function keyStatusLabel({ keyRequired, keyRejected }) { + if (keyRejected) return msg('KEY REJECTED'); + return keyRequired ? msg('KEY REQUIRED') : null; +} diff --git a/src/layers/streetLevel/providers/google/index.js b/src/layers/streetLevel/providers/google/index.js index 5789a9321..d32ee95f6 100644 --- a/src/layers/streetLevel/providers/google/index.js +++ b/src/layers/streetLevel/providers/google/index.js @@ -1,4 +1,4 @@ -import { FOLLOW_MAP_STACK_ID } from '../../policy.js'; +import { GOOGLE_3D_MAP_STACK_ID } from '../../policy.js'; import { createMapsLoader } from './mapsLoader.js'; import { getLocale } from '../../../../i18n/index.js'; import { createGoogleViewer } from './viewer.js'; @@ -64,7 +64,7 @@ export function createGoogleProvider({ credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }), groundClick: true, // Google's terms forbid Street View beside non-Google maps. - requiresMapStack: FOLLOW_MAP_STACK_ID, + requiresMapStack: GOOGLE_3D_MAP_STACK_ID, // Every panorama is billed to the Maps key. defaultOn: false, // All 360°, and no coverage for a date filter to thin. @@ -114,8 +114,6 @@ export function createGoogleProvider({ stopAuthWatch(); }, - refreshCoverage() {}, - /** No filter applies to Street View (see `filters`). */ setFilter() {}, @@ -123,8 +121,6 @@ export function createGoogleProvider({ const keyRejected = loader.authFailed(); return { count: 0, - zoom: null, - kind: null, loading: false, hint: !active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT, diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js index bee93d76c..dabe821f1 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.js +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -40,7 +40,7 @@ export function tileSegments(payload) { * fourfold. * @param {Array>} segments * @param {{x: number, y: number, z: number}} address - * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>}} */ export function chainSegments(segments, { x, y, z }) { const key = ([lon, lat]) => `${lon},${lat}`; @@ -84,7 +84,7 @@ export function chainSegments(segments, { x, y, z }) { isPano: false, }); }); - return { sequences, overview: [] }; + return { sequences }; } /** Decode a KartaView coverage tile into the shared coverage's line records. */ diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs index 619719008..c96fb66a8 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -13,7 +13,7 @@ const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); const TILE = { x: 4412, y: 6061, z: 14 }; test('segments that meet end to start chain into one line', () => { - const { sequences, overview } = decodeCoverageTile( + const { sequences } = decodeCoverageTile( { lines: [ { track: [at(1, 1), at(2, 1)], matched: '1' }, @@ -23,7 +23,6 @@ test('segments that meet end to start chain into one line', () => { }, TILE, ); - assert.deepEqual(overview, []); assert.equal(sequences.length, 2); assert.deepEqual(sequences[0].parts, [ [ diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 1cf2513f3..6e75f967a 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -1,4 +1,4 @@ -import { createLineCoverage } from '../../lineCoverage.js'; +import { createLineCoverage, freshCoverage } from '../../lineCoverage.js'; import { createLru, createTileCache } from '../../tileCache.js'; export { createTileCache } from '../../tileCache.js'; @@ -49,10 +49,7 @@ const PAGE_CACHE_SIZE = 12; /** z14 near the street, z13 a little higher, nothing above. */ export function kartaViewZoomFor(heightM) { if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null; - return { - zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14, - kind: 'sequence', - }; + return heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14; } /** The 1-based page of a sequence that holds `index`, if no photo is missing. */ @@ -88,8 +85,6 @@ export function createKartaViewProvider({ filters: Object.freeze(['pano', 'since']), // SINCE is the upload date for KartaView: the panel notes it. sinceBasis: 'upload', - // Its lines are streets, not sequences: a selected drive shows as cones. - highlightsSequence: false, coverageLabel: Object.freeze({ noun: COVERAGE_NOUN, action: LINE_CLICK_HINT, @@ -100,30 +95,13 @@ export function createKartaViewProvider({ context, services: context.services, viewer: null, - keyRequired: false, - keyRejected: false, - statusKnown: true, - coverage: { - zoom: null, - tiles: new Map(), - stale: new Map(), - staleTimer: null, - pending: new Map(), - lastError: null, - holdUntil: 0, - holdTimer: null, - debounceTimer: null, - removeCameraListener: null, - terrainReady: null, - hint: '', - kind: null, - }, + coverage: freshCoverage({ keyless: true }), sequence: { selectedId: null, images: [], - cache: new Map(), collection: null, loading: false, + error: null, abort: null, }, }; @@ -349,6 +327,8 @@ export function createKartaViewProvider({ // Filtered on the way in (fetchTile): a line has no date to test. passes: () => true, }); + // Its lines are streets, not drives: a selected drive shows as cones + // only, so the coverage is never highlighted. const cones = createSequenceCones({ state, @@ -480,7 +460,7 @@ export function createKartaViewProvider({ deactivate() { coverage.detach(); coverage.clear(); - coverage.resetErrors(); + coverage.unblock(); cones.clearSelection(); cones.setVisible(false); drawnPage = null; @@ -492,17 +472,15 @@ export function createKartaViewProvider({ destroy(cesiumViewer) { coverage.detach(); coverage.clear(); - coverage.resetErrors(); + coverage.unblock(); cones.destroy(cesiumViewer); tiles.clear(); masks.clear(); }, - refreshCoverage: () => coverage.refresh(), - /** * The filter changed: the lines come from KartaView again under it - * (the old ones show meanwhile), the cones are drawn again. + * (cleared until they come), the cones are drawn again. */ setFilter() { const query = tileFilterQuery(context.getFilter()); @@ -513,29 +491,17 @@ export function createKartaViewProvider({ cones.rerender(); }, - setSurface() { - coverage.setSurface(); - cones.rerender(); - }, - + /** Keyless; an empty view that has loaded says there is nothing here. */ coverageStats() { - const { hint } = state.coverage; - const count = coverage.sequenceCount(); - const loading = state.coverage.pending.size > 0; + const stats = coverage.stats(); const empty = state.coverage.zoom !== null && - !loading && - count === 0 && - !state.coverage.lastError; + !stats.loading && + stats.count === 0 && + !stats.error; return { - count, - zoom: state.coverage.zoom, - kind: state.coverage.kind, - loading, - hint: hint || (empty ? NO_COVERAGE_HINT : ''), - error: state.coverage.lastError, - keyRequired: false, - keyRejected: false, + ...stats, + hint: stats.hint || (empty ? NO_COVERAGE_HINT : ''), }; }, @@ -553,7 +519,7 @@ export function createKartaViewProvider({ return false; }, - // Never from the cache: it is keyed by sequence, the cones by page. + // Always loaded afresh: the cones show the page of the photo open now. selectSequence: (sequenceId) => cones.select(sequenceId, { force: true }), clearSequence: () => { @@ -567,6 +533,7 @@ export function createKartaViewProvider({ counts.get(state.sequence.selectedId) ?? state.sequence.images.length, loading: state.sequence.loading, + error: state.sequence.error, }), /** diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 137b9cc78..76a0ea208 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -14,7 +14,7 @@ export const KARTAVIEW_LABEL = 'KARTAVIEW'; */ export const KARTAVIEW_API_HOST = 'https://api.kartaview.org'; /** KartaView's image proxy: resizes a photo given its base64url address. */ -export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; +const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; /** Stable id prefixes for picked primitives; every one starts with `kv:`. */ export const PICK_PREFIX = Object.freeze({ @@ -37,16 +37,15 @@ export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500; export const COVERAGE_MAX_HEIGHT_M = 6_000; export const COVERAGE_TUNING = Object.freeze({ maxTiles: 9, - overviewMaxTiles: 0, // Chained ways are short: a dense downtown z13 tile holds ~1,500, so each // tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn). maxSequences: 14_400, lineWidthPx: 2.5, - overviewPointPx: 2.5, moveDebounceMs: 320, viewRangeMinM: 2_500, viewRangePerHeight: 10, - viewNearM: 1_000, + // Lines carry no date to sort by: keep KartaView's order. + keepOrder: true, }); export const TOO_HIGH_HINT = msg('zoom in to see KartaView coverage'); export const NO_COVERAGE_HINT = msg('no KartaView photos in view'); @@ -141,8 +140,6 @@ export const MASK_SLOW_MESSAGE = msg( export const FILTERED_OUT_MESSAGE = msg( 'None of KartaView’s photos here pass the imagery filter', ); -/** Under the SINCE slider while KartaView is lit. */ -export const SINCE_NOTE = 'KartaView: by upload date'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { @@ -225,9 +222,32 @@ export function kartaViewTimeMs(value) { String(value ?? ''), ); if (!match) return null; - const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; - if (Number(y) < 1990) return null; - const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4] ?? 0); + const minutes = Number(match[5] ?? 0); + const seconds = Number(match[6] ?? 0); + if (year < 1990) return null; + if ( + month < 1 || + month > 12 || + day < 1 || + hours > 23 || + minutes > 59 || + seconds > 59 + ) + return null; + // Date.UTC rolls 31 February into March. A photo would then pass or fail + // the day filter on the wrong calendar day, so an impossible date is unknown. + const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds); + const parsed = new Date(ms); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) + return null; return Number.isFinite(ms) ? ms : null; } diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 82a68555e..540071f0b 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => kartaViewTimeMs('2022-03-21T06:42:59'), Date.UTC(2022, 2, 21, 6, 42, 59), ); + assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null); + assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null); + assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null); + assert.equal(kartaViewTimeMs('2022-04-31'), null); assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); assert.equal(kartaViewTimeMs(''), null); assert.equal(kartaViewTimeMs(null), null); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index a043063fa..d992313bb 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -6,6 +6,7 @@ import { kartaViewZoomFor, } from './index.js'; import { + COVERAGE_TUNING, FILTERED_OUT_MESSAGE, NO_COVERAGE_HINT, SEQUENCE_PAGE_SIZE, @@ -100,9 +101,6 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { services: {}, getFilter: () => actions.filter, isActive: () => true, - groundCaster: null, - meshSampler: null, - getSurface: () => 'draped', notify() {}, actions: { // As the core does: the provider's own viewer opens it. @@ -153,11 +151,6 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an 'upload', 'SINCE is KartaView’s upload date', ); - assert.equal( - definition.highlightsSequence, - false, - 'its lines are streets: a selected drive shows as cones', - ); assert.match( definition.credit.html, /KartaView<\/a> contributors, CC BY-SA 4\.0/, @@ -176,14 +169,10 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an }); test('coverage is drawn from z14 near the street and z13 a little higher; never from orbit', () => { - assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' }); - assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' }); - assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' }); - assert.deepEqual( - kartaViewZoomFor(6_000), - { zoom: 13, kind: 'sequence' }, - 'the top of the range still draws', - ); + assert.equal(kartaViewZoomFor(300), 14); + assert.equal(kartaViewZoomFor(2_500), 14); + assert.equal(kartaViewZoomFor(4_000), 13); + assert.equal(kartaViewZoomFor(6_000), 13, 'the top of the range still draws'); assert.equal(kartaViewZoomFor(6_001), null); assert.equal(kartaViewZoomFor(NaN), null); }); @@ -515,7 +504,7 @@ test('coverage tiles are kept for a while: panning back asks KartaView nothing', const tiles = createTileCache({ load: async (tile) => { asked.push(`${tile.z}/${tile.x}/${tile.y}`); - return { sequences: [], overview: [] }; + return { sequences: [] }; }, size: 2, ttlMs: 1000, @@ -789,6 +778,14 @@ function coverageSetup( }; } +/** The camera came to rest: coverage refreshes once its debounce is over. */ +async function moved(viewer) { + viewer.camera.moveEnd.raiseEvent(); + await new Promise((resolve) => + setTimeout(resolve, COVERAGE_TUNING.moveDebounceMs + 20), + ); +} + test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => { const { lon, lat } = tileCentre(-83.0458, 42.3314); // Across the tile's middle row (mask row ~256), and a quarter up (~130). @@ -811,15 +808,16 @@ test('a filter thins the lines to those its PNG mask draws; without one they all assert.ok(await until(() => env.instance.coverageStats().count === 2)); assert.equal(env.tiles.length, 1, 'the segments are kept'); // SINCE a day: uploaded since yesterday (UTC) — none here. The lines - // drawn now stay on the globe while KartaView draws the filtered tile. + // drawn now go while KartaView draws the filtered tile. const since = Date.now() - 86_400_000; const release = env.holdMasks(); env.actions.filter = { pano: 'all', sinceMs: since }; env.instance.setFilter(); assert.ok(await until(() => env.instance.coverageStats().loading)); - assert.ok( - env.viewer.scene.groundPrimitives.items.size > 0, - 'the unfiltered lines show meanwhile', + assert.equal( + env.viewer.scene.groundPrimitives.items.size, + 0, + 'the unfiltered lines are not shown as filtered ones', ); release(); assert.ok( @@ -834,10 +832,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all `startDate=${new Date(since).toISOString().slice(0, 10)}`, ); assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); - assert.ok( - await until(() => env.viewer.scene.groundPrimitives.items.size === 0), - 'and go once it is in', - ); + assert.equal(env.viewer.scene.groundPrimitives.items.size, 0); // The same filter again asks KartaView for nothing. env.instance.setFilter(); for (let i = 0; i < 5; i++) await settle(); @@ -851,7 +846,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty assert.deepEqual(env.tiles, [], 'nothing asked from up there'); const release = env.holdTiles(); env.viewer.view.height = 900; - env.instance.refreshCoverage(); + await moved(env.viewer); assert.ok(await until(() => env.instance.coverageStats().loading)); assert.equal(env.instance.coverageStats().hint, '', 'not while it loads'); release(); @@ -871,7 +866,7 @@ test('coverage tiles are kept while KartaView is on and dropped once it is switc }); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - env.instance.refreshCoverage(); + await moved(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.equal(env.tiles.length, 1, 'kept while on'); // Switched off: the decoded lines are not kept, so on again asks again. diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js deleted file mode 100644 index e08a578e4..000000000 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ /dev/null @@ -1,70 +0,0 @@ -import { - createLineCoverage, - sequenceIdFromPick as lineSequenceIdFromPick, -} from '../../lineCoverage.js'; -import { decodeCoverageTile } from './decode.js'; -import { - coverageZoomForHeight, - overviewZoomForHeight, -} from '../../tileMath.js'; -import { - COLORS, - COVERAGE_LINE_WIDTH_PX, - COVERAGE_MAX_SEQUENCES, - COVERAGE_MAX_TILES, - COVERAGE_MOVE_DEBOUNCE_MS, - COVERAGE_OVERVIEW_MAX_TILES, - COVERAGE_OVERVIEW_POINT_PX, - KEY_REJECTED_MESSAGE, - PICK_PREFIX, - RATE_LIMITED_MESSAGE, - SEQUENCE_VIEW_NEAR_M, - SEQUENCE_VIEW_RANGE_MIN_M, - SEQUENCE_VIEW_RANGE_PER_HEIGHT, -} from './policy.js'; - -export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js'; - -/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */ -export function mapillaryZoomFor(heightM) { - const sequence = coverageZoomForHeight(heightM); - if (sequence != null) return { zoom: sequence, kind: 'sequence' }; - const overview = overviewZoomForHeight(heightM); - return overview != null ? { zoom: overview, kind: 'overview' } : null; -} - -/** Sequence id for any part's pick id (`mly:seq:[~]`), else null. */ -export function sequenceIdFromPick(pickId) { - return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence); -} - -/** Mapillary's vector-tile coverage through the server proxy. */ -export function createCoverage({ state, source }) { - return createLineCoverage({ - state, - fetchTile: async (tile, { signal }) => - decodeCoverageTile( - await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }), - tile, - ), - zoomFor: mapillaryZoomFor, - colors: COLORS, - pickPrefix: PICK_PREFIX.sequence, - renderKey: 'mapillary-coverage', - tuning: { - maxTiles: COVERAGE_MAX_TILES, - overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES, - maxSequences: COVERAGE_MAX_SEQUENCES, - lineWidthPx: COVERAGE_LINE_WIDTH_PX, - overviewPointPx: COVERAGE_OVERVIEW_POINT_PX, - moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS, - viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M, - viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT, - viewNearM: SEQUENCE_VIEW_NEAR_M, - }, - messages: { - keyRejected: KEY_REJECTED_MESSAGE, - rateLimited: RATE_LIMITED_MESSAGE, - }, - }); -} diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs deleted file mode 100644 index 26c6b13fc..000000000 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ /dev/null @@ -1,989 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import * as Cesium from 'cesium'; -import { - SWAP_MAX_WAIT_MS, - createCoverage, - meshBoxInRange, -} from './coverage.js'; -import { encodeCoverageTile } from './coverageFixture.mjs'; -import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; -import { - COLORS, - COVERAGE_MAX_SEQUENCES, - COVERAGE_MAX_TILES, -} from './policy.js'; -import { createGroundCaster } from '../../groundCast.js'; -import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js'; - -const RAD = Math.PI / 180; -// Node has no WebGL context to report line-width limits; a browser's -// context sets these on startup, and appearances validate against them. -Cesium.ContextLimits._minimumAliasedLineWidth = 1; -Cesium.ContextLimits._maximumAliasedLineWidth = 10; -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -/** A scene collection that remembers what is on the globe. */ -function collection() { - const items = new Set(); - return { - items, - add(primitive) { - items.add(primitive); - return primitive; - }, - remove(primitive) { - return items.delete(primitive); - }, - }; -} - -/** The centre of the z14 tile holding a point, so a small view is one tile. */ -function tileCentre(lon, lat) { - const x = lonToTileX(lon, 14); - const y = latToTileY(lat, 14); - const { west, east, south, north } = tileBounds(x, y, 14); - return { - tile: { x, y, z: 14 }, - lon: (west + east) / 2, - lat: (south + north) / 2, - }; -} - -/** A viewer looking straight down on a 0.004° square around `view`. */ -function fakeViewer(view) { - const postRender = new Set(); - const preRender = new Set(); - return { - view, - postRender, - preRender, - scene: { - canvas: { clientWidth: 100, clientHeight: 100 }, - globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, - groundPrimitives: collection(), - primitives: collection(), - postRender: { - addEventListener(listener) { - postRender.add(listener); - return () => postRender.delete(listener); - }, - }, - preRender: { - addEventListener(listener) { - preRender.add(listener); - return () => preRender.delete(listener); - }, - }, - }, - camera: { - get positionCartographic() { - return { - longitude: view.lon * RAD, - latitude: view.lat * RAD, - height: view.height, - }; - }, - pickEllipsoid(point) { - return Cesium.Cartesian3.fromDegrees( - view.lon - 0.002 + (point.x / 100) * 0.004, - view.lat + 0.002 - (point.y / 100) * 0.004, - ); - }, - computeViewRectangle: () => undefined, - }, - }; -} - -/** A source whose tile requests the test resolves, one call at a time. */ -function deferredSource() { - const calls = []; - return { - calls, - getTile(layer, z, x, y, { signal } = {}) { - return new Promise((resolve, reject) => { - calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject }); - }); - }, - }; -} - -function setup({ - surface = 'draped', - groundCaster = null, - meshSampler = null, -} = {}) { - const centre = tileCentre(-121.4944, 38.5816); - const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 }); - const source = deferredSource(); - const state = { - viewer, - services: {}, - keyRequired: false, - context: { - filter: { pano: 'all', sinceMs: null }, - getFilter: () => state.context.filter, - isActive: () => true, - notify() {}, - getSurface: () => surface, - groundCaster, - meshSampler, - }, - coverage: { - zoom: null, - kind: null, - tiles: new Map(), - stale: new Map(), - staleTimer: null, - pending: new Map(), - lastError: null, - debounceTimer: null, - removeCameraListener: null, - // Skip Cesium's one-time terrain table download. - terrainReady: Promise.resolve(), - hint: '', - }, - sequence: { selectedId: null }, - }; - const coverage = createCoverage({ state, source }); - const bytes = encodeCoverageTile(centre.tile, { - sequences: [ - { - id: 'seq-1', - parts: [ - [ - [centre.lon - 0.001, centre.lat], - [centre.lon + 0.001, centre.lat], - ], - ], - }, - ], - }); - const tileKey = `14/${centre.tile.x}/${centre.tile.y}`; - const onGlobe = () => - viewer.scene.groundPrimitives.items.size + - viewer.scene.primitives.items.size; - return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre }; -} - -/** Run one frame's listeners, as Cesium's render loop would. */ -function frame(listeners) { - for (const listener of [...listeners]) listener(); -} - -test('a superseded tile request cannot strand lines on the globe', async () => { - const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup(); - const forTile = () => source.calls.filter((call) => call.key === tileKey); - - coverage.refresh(); // street zoom: request #1 for the tile - assert.equal(forTile().length, 1); - viewer.view.height = 100_000; // zoom out: retire() drops request #1 - coverage.refresh(); - viewer.view.height = 900; // back in before #1 settles: request #2 - coverage.refresh(); - assert.equal(forTile().length, 2); - await settle(); - - // The superseded request finishes late. It must not settle request #2's - // key, or the next refresh would start a duplicate load. - forTile()[0].resolve(bytes); - await settle(); - assert.equal( - state.coverage.pending.has(tileKey), - true, - '#2 still owns the key', - ); - assert.equal(state.coverage.pending.size, 1, 'still loading'); - coverage.refresh(); - assert.equal(forTile().length, 2, 'no duplicate request'); - - forTile()[1].resolve(bytes); - await settle(); - assert.equal(state.coverage.tiles.size, 1); - assert.ok(onGlobe() > 0, 'the tile drew its lines'); - - // Everything drawn can be removed: nothing is orphaned. - coverage.clear(); - assert.equal(onGlobe(), 0); -}); - -test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => { - let heightsReady = false; - const groundCaster = { - prepareLines: async () => { - heightsReady = true; - return true; - }, - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const { viewer, source, coverage, bytes, onGlobe } = setup({ - surface: 'terrain', - groundCaster, - }); - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - await settle(); - const { groundPrimitives, primitives } = viewer.scene; - // Both are on the globe while the cast lines build: no blink. - assert.equal(groundPrimitives.items.size, 1, 'draped lines kept'); - assert.equal(primitives.items.size, 1, 'cast lines added'); - assert.ok( - [...primitives.items][0] instanceof Cesium.Primitive, - 'cast lines are plain polylines at terrain height', - ); - // Leaving mid-swap removes both sets, not just the new one. - coverage.clear(); - assert.equal(onGlobe(), 0); - assert.equal(viewer.postRender.size, 0, 'the swap listener is gone'); -}); - -test('a tile whose lines cannot be cast is not rebuilt for nothing', async () => { - let prepares = 0; - const groundCaster = { - prepareLines: async () => { - prepares++; - return false; // terrain proxy down, or tile too big - }, - castLine: () => null, - }; - const { viewer, source, coverage, bytes } = setup({ - surface: 'terrain', - groundCaster, - }); - const added = []; - const add = viewer.scene.groundPrimitives.add.bind( - viewer.scene.groundPrimitives, - ); - viewer.scene.groundPrimitives.add = (primitive) => { - added.push(primitive); - return add(primitive); - }; - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - await settle(); - assert.equal(prepares, 1); - assert.equal(added.length, 1, 'drawn once, draped; no second draped build'); - assert.equal(viewer.scene.primitives.items.size, 0); - coverage.clear(); -}); - -test('panning off a tile that is still loading stops counting it as loading', async () => { - const { viewer, source, state, coverage } = setup(); - coverage.refresh(); - assert.equal(state.coverage.pending.size, 1); - // Same zoom, two tiles east: the first request is dropped, a new one starts. - viewer.view.lon += 0.05; - coverage.refresh(); - assert.equal(source.calls.length, 2); - assert.equal(source.calls[0].signal.aborted, true); - assert.equal( - state.coverage.pending.size, - 1, - 'only the live request counts as loading', - ); - source.calls[0].reject(new DOMException('aborted', 'AbortError')); - source.calls[1].resolve(new Uint8Array()); - await settle(); - assert.equal(state.coverage.pending.size, 0, 'LOADING clears'); - coverage.clear(); -}); - -test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => { - const { viewer, source, state, coverage } = setup(); - viewer.view.height = 20_000_000; - coverage.refresh(); - assert.equal(state.coverage.zoom, 0); - assert.equal(state.coverage.hint, ''); - assert.deepEqual( - source.calls.map((call) => call.key), - ['0/0/0'], - ); - coverage.clear(); -}); - -test('a rejected key stops coverage requests until the layer goes off', async () => { - const { viewer, source, state, coverage } = setup(); - coverage.refresh(); - const rejected = Object.assign( - new Error('Mapillary rejected the access token'), - { - keyRejected: true, - }, - ); - source.calls[0].reject(rejected); - await settle(); - assert.equal(state.keyRejected, true); - assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/); - viewer.view.lon += 0.05; - coverage.refresh(); - assert.equal(source.calls.length, 1, 'panning asks for nothing more'); - // Turning the provider off forgets the verdict; the next run asks again. - coverage.clear(); - coverage.resetErrors(); - assert.equal(state.keyRejected, false); - assert.equal(state.coverage.lastError, null); - coverage.refresh(); - assert.equal(source.calls.length, 2); - coverage.clear(); -}); - -test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, source, state, coverage } = setup(); - coverage.refresh(); - source.calls[0].reject( - Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), - ); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - assert.match(state.coverage.lastError, /rate-limited/); - viewer.view.lon += 0.05; - coverage.refresh(); - assert.equal(source.calls.length, 1, 'held: nothing is requested'); - t.mock.timers.tick(30_000); - assert.equal(source.calls.length, 2, 'one refresh once the wait is over'); - assert.equal(state.coverage.lastError, null); - coverage.clear(); - coverage.resetErrors(); -}); - -test('overview points behind the horizon are hidden, not drawn through the globe', async () => { - const { viewer, source, state, coverage } = setup(); - const { view } = viewer; - view.height = 20_000_000; - Object.defineProperty(viewer.camera, 'positionWC', { - get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height), - }); - coverage.refresh(); - // One dot under the camera (Sacramento), one near its antipode. - source.calls[0].resolve( - encodeCoverageTile( - { x: 0, y: 0, z: 0 }, - { - overview: [ - { id: 'near', lon: -121.5, lat: 38.6 }, - { id: 'far', lon: 58.5, lat: -38.6 }, - ], - }, - ), - ); - await settle(); - const [entry] = state.coverage.tiles.values(); - const points = [0, 1].map((i) => entry.primitive.get(i)); - const west = (point) => - Cesium.Cartographic.fromCartesian(point.position).longitude < 0; - const near = points.find(west); - const far = points.find((point) => !west(point)); - frame(viewer.preRender); - assert.equal(near.show, true, 'the near side stays visible'); - assert.equal(far.show, false, 'the far side does not show through'); - // Fly round to the other hemisphere: the two swap. - view.lon = 58.5; - view.lat = -38.6; - frame(viewer.preRender); - assert.equal(near.show, false); - assert.equal(far.show, true); - coverage.clear(); - assert.equal(viewer.preRender.size, 0, 'the cull listener is gone'); -}); - -test('a cast tile whose heights were evicted is cast again, not left draped', async () => { - const terrain = { - calls: 0, - async resolveEllipsoidalGround(coords) { - this.calls++; - return coords.map(() => ({ ellipsoid: 20, source: 'reearth' })); - }, - }; - // Room for one tile's corners at a time. - const groundCaster = createGroundCaster({ - terrain, - maxCorners: 16, - cacheMax: 20, - }); - let sampled = null; - const meshSampler = { - onSampled: (listener) => (sampled = listener), - request() {}, - meshAt: () => undefined, - }; - const { source, state, coverage, bytes, centre } = setup({ - surface: 'terrain', - groundCaster, - meshSampler, - }); - const isCast = () => { - const [entry] = state.coverage.tiles.values(); - return ( - entry.primitives.length > 0 && - entry.primitives.every(({ onGround }) => !onGround) - ); - }; - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - await settle(); - assert.ok(isCast(), 'cast once the heights are in'); - // Another region fills the cache and evicts this tile's corners... - await groundCaster.prepare([[centre.lon + 0.05, centre.lat + 0.05]]); - assert.equal(groundCaster.groundAt(centre.lon - 0.001, centre.lat), null); - // ...then mesh samples redraw the tile, which can only drape now. - sampled([[centre.lon, centre.lat]]); - await new Promise((resolve) => setTimeout(resolve, 5)); - coverage.refresh(); - await settle(); - await settle(); - assert.ok(isCast(), 'the tile is cast again'); - coverage.clear(); -}); - -test('a tile left draped by a failed terrain lookup is cast on the next refresh', async () => { - let up = false; - let prepares = 0; - let heightsReady = false; - const groundCaster = { - prepareLines: async () => { - prepares++; - heightsReady = up; - return up; - }, - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const { viewer, source, coverage, bytes } = setup({ - surface: 'terrain', - groundCaster, - }); - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - await settle(); - assert.equal(prepares, 1); - assert.equal(viewer.scene.primitives.items.size, 0, 'draped'); - await settle(); - assert.equal(prepares, 1, 'no retry loop while the proxy is down'); - up = true; // the proxy is back - coverage.refresh(); - await settle(); - assert.equal(prepares, 2, 'the next refresh tries again'); - assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines added'); - coverage.clear(); -}); - -test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => { - const { state, source, coverage, centre } = setup(); - const line = (n) => [ - [ - [centre.lon - 0.001, centre.lat + n * 1e-6], - [centre.lon + 0.001, centre.lat + n * 1e-6], - ], - ]; - // 700 newer flat sequences, then 300 older 360° ones. - const sequences = []; - for (let i = 0; i < 700; i++) - sequences.push({ - id: `flat-${i}`, - parts: line(i), - capturedAt: 2_000_000_000_000 + i, - }); - for (let i = 0; i < 300; i++) - sequences.push({ - id: `pano-${i}`, - parts: line(i), - capturedAt: 1_000_000_000_000 + i, - isPano: true, - }); - state.context.filter = { pano: 'pano', sinceMs: null }; - coverage.refresh(); - source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); - await settle(); - const [entry] = state.coverage.tiles.values(); - const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES); - assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn'); - assert.equal(coverage.sequenceCount(), Math.min(300, cap)); - // Picking and recolouring look sequences up among the drawn lines. - assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up'); - assert.equal(entry.sequences.has('flat-699'), false); - state.context.filter = { pano: 'all', sinceMs: null }; - coverage.rebuild(); - assert.equal(entry.count, cap, 'all imagery is capped as before'); - assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn'); - assert.equal(entry.sequences.has('pano-299'), false, 'over the cap'); - coverage.clear(); -}); - -/** The colour attribute value of a line, selected or not. */ -function colourValue(selected) { - return Array.from( - Cesium.ColorGeometryInstanceAttribute.toValue( - selected - ? Cesium.Color.fromCssColorString(COLORS.selected) - : Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92), - ), - ); -} - -/** Take over a drawn primitive's readiness and seq-1's colour attribute. */ -function controlled(record, ready) { - const control = { ready, attributes: { color: undefined } }; - Object.defineProperty(record.primitive, 'ready', { - get: () => control.ready, - }); - record.primitive.getGeometryInstanceAttributes = (id) => - id === 'mly:seq:seq-1' ? control.attributes : undefined; - return control; -} - -test('a selection made or cleared while a tile builds is applied once it is ready', async () => { - const { viewer, source, state, coverage, bytes } = setup(); - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - const [entry] = state.coverage.tiles.values(); - // Selected while its line is still building: nothing to recolour yet... - const first = controlled(entry.primitives[0], false); - state.sequence.selectedId = 'seq-1'; - coverage.recolorSequence('seq-1', true); - assert.equal(first.attributes.color, undefined); - // ...so the highlight lands once it is ready. - first.ready = true; - frame(viewer.postRender); - assert.deepEqual(Array.from(first.attributes.color), colourValue(true)); - - // Rebuilt with the highlight baked in, then cleared mid-build: not stuck. - coverage.rebuild(); - const second = controlled(entry.primitives[0], false); - coverage.recolorSequence('seq-1', false); - state.sequence.selectedId = null; - second.ready = true; - frame(viewer.postRender); - assert.deepEqual(Array.from(second.attributes.color), colourValue(false)); - coverage.clear(); - assert.equal(viewer.postRender.size, 0, 'the selection watch is gone'); -}); - -test('a sequence that crosses a tile edge is counted once', async () => { - const { viewer, source, coverage, centre } = setup(); - // Look straight down on the edge between this tile and its east neighbour. - const { east } = tileBounds(centre.tile.x, centre.tile.y, 14); - viewer.view.lon = east; - coverage.refresh(); - assert.equal(source.calls.length, 2, 'two tiles in view'); - const across = [ - [ - [east - 0.001, centre.lat], - [east + 0.001, centre.lat], - ], - ]; - for (const call of source.calls) { - const [, x, y] = call.key.split('/').map(Number); - call.resolve( - encodeCoverageTile( - { x, y, z: 14 }, - { - sequences: [ - { id: 'across', parts: across }, - { id: `only-${x}`, parts: across }, - ], - }, - ), - ); - } - await settle(); - assert.equal(coverage.sequenceCount(), 3, 'across, plus one per tile'); - coverage.clear(); -}); - -/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */ -async function midSwap() { - // Drawn draped first; castLine only answers once the heights are in. - let heightsReady = false; - const groundCaster = { - prepareLines: async () => (heightsReady = true), - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const context = setup({ surface: 'terrain', groundCaster }); - context.coverage.refresh(); - context.source.calls[0].resolve(context.bytes); - await settle(); - await settle(); - const { groundPrimitives, primitives } = context.viewer.scene; - assert.equal(groundPrimitives.items.size, 1, 'draped lines kept'); - assert.equal(primitives.items.size, 1, 'cast lines building'); - const [cast] = primitives.items; - const control = { ready: false }; - Object.defineProperty(cast, 'ready', { get: () => control.ready }); - return { ...context, control }; -} - -test('the draped lines go once the cast lines are ready', async () => { - const { viewer, coverage, control } = await midSwap(); - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building'); - control.ready = true; - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed'); - assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay'); - assert.equal(viewer.postRender.size, 0, 'nothing left watching'); - coverage.clear(); -}); - -test('the draped lines go after the wait even if the cast lines never get ready', async (t) => { - t.mock.timers.enable({ apis: ['Date'] }); - const { viewer, coverage } = await midSwap(); - t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1); - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting'); - t.mock.timers.tick(2); - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed'); - assert.equal(viewer.scene.primitives.items.size, 1); - coverage.clear(); -}); - -/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */ -function tiltedCamera({ lon, lat, height, heading, pitch }) { - return rayCamera({ - lon, - lat, - altitude: height, - heading, - pitch, - width: 100, - height: 100, - fovX: 60, - fovY: 40, - }); -} - -/** - * Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east - * at ground `aheadM` metres out. Only the bare-earth caster knows the ground. - */ -function denverStreetView(aheadM) { - const z = 14; - const x = lonToTileX(-104.99, z); - const y = latToTileY(39.74, z); - const tile = tileBounds(x, y, z); - const lon = (tile.west + tile.east) / 2; - const lat = (tile.south + tile.north) / 2; - const ground = 1600; - const above = 300; - const context = setup({ groundCaster: { groundAt: () => ground } }); - const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD); - context.viewer.camera = tiltedCamera({ - lon, - lat, - height: ground + above, - heading: 90, - pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD, - }); - context.coverage.refresh(); - const keys = context.source.calls.map((call) => call.key); - /** Requested tiles as [dx, dy] from the tile under the camera. */ - const offsets = keys.map((k) => { - const [, tx, ty] = k.split('/').map(Number); - return [tx - x, ty - y]; - }); - return { - ...context, - keys, - offsets, - key: (dx, dy) => `${z}/${x + dx}/${y + dy}`, - }; -} - -test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => { - // The screen centre meets the ground at the centre of the next tile east. - const { state, coverage, keys, offsets, key } = denverStreetView( - (tileWidthM) => tileWidthM, - ); - assert.equal(state.coverage.zoom, 14, 'height measured from the ground'); - assert.deepEqual( - keys.slice(0, 2), - [key(0, 0), key(1, 0)], - 'the tile under the camera, then the one the screen centre looks at', - ); - assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too'); - for (const [dx, dy] of offsets) - assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`); - coverage.clear(); -}); - -test('a street view toward the horizon ranks only the ground within range', () => { - // The screen centre meets the ground 6 km out, past the 3 km range: the - // tiles are ranked around the camera, not along a line to the horizon. - const { coverage, keys, offsets, key } = denverStreetView(() => 6000); - assert.equal(keys[0], key(0, 0), 'the tile under the camera first'); - // Behind and ahead are equally near the camera (ties keep row order); a - // line of sight to the far ground would rank the tile ahead first. - assert.ok( - keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)), - 'the tile ahead is not pulled forward', - ); - for (const [dx, dy] of offsets) - assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`); - coverage.clear(); -}); - -test('a selection cleared while the old zoom is still shown uncolours its lines', async () => { - const { viewer, source, state, coverage, bytes } = setup(); - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - const [entry] = state.coverage.tiles.values(); - const line = controlled(entry.primitives[0], true); - state.sequence.selectedId = 'seq-1'; - coverage.recolorSequence('seq-1', true); - assert.deepEqual(Array.from(line.attributes.color), colourValue(true)); - // Zoom out: the street tile stays on screen while the new zoom loads... - viewer.view.height = 100_000; - coverage.refresh(); - assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept'); - // ...and the selection is cleared meanwhile. - state.sequence.selectedId = null; - coverage.recolorSequence('seq-1', false); - assert.deepEqual( - Array.from(line.attributes.color), - colourValue(false), - 'the retained line is not left cyan', - ); - coverage.clear(); -}); - -test('a selection made while the old zoom is still building is applied once it is ready', async () => { - const { viewer, source, state, coverage, bytes } = setup(); - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - const [entry] = state.coverage.tiles.values(); - const line = controlled(entry.primitives[0], false); - viewer.view.height = 100_000; - coverage.refresh(); - assert.equal(state.coverage.stale.size, 1); - frame(viewer.postRender); - state.sequence.selectedId = 'seq-1'; - coverage.recolorSequence('seq-1', true); - line.ready = true; - frame(viewer.postRender); - assert.deepEqual( - line.attributes.color && Array.from(line.attributes.color), - colourValue(true), - 'the highlight lands on the retained line', - ); - coverage.clear(); - assert.equal(viewer.postRender.size, 0, 'the selection watch is gone'); -}); - -test('only lines within the mesh sampler range of the camera are asked for', async () => { - let heightsReady = false; - const groundCaster = { - prepareLines: async () => (heightsReady = true), - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const requested = []; - const meshSampler = { - onSampled() {}, - request: (points) => requested.push(...points), - meshAt: () => undefined, - }; - const { source, coverage, centre } = setup({ - surface: 'terrain', - groundCaster, - meshSampler, - }); - const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14); - coverage.refresh(); - source.calls[0].resolve( - encodeCoverageTile(centre.tile, { - sequences: [ - { - id: 'here', - parts: [ - [ - [centre.lon - 0.001, centre.lat], - [centre.lon + 0.001, centre.lat], - ], - ], - }, - // ~1.1 km from the camera, in the tile's north-east corner. - { - id: 'corner', - parts: [ - [ - [east - 0.002, north - 0.002], - [east - 0.0005, north - 0.0005], - ], - ], - }, - ], - }), - ); - await settle(); - await settle(); - assert.ok(requested.length > 0, 'the near line is asked for'); - const farthest = Math.max( - ...requested.map(([lon, lat]) => - Math.hypot( - (lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD), - (lat - centre.lat) * 110_540, - ), - ), - ); - assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`); - coverage.clear(); -}); - -test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => { - const { viewer, source, state, coverage, bytes } = setup(); - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - const [old] = viewer.scene.groundPrimitives.items; - assert.ok(old, 'the z14 lines are drawn'); - viewer.view.height = 3000; // zoom out to z13 - coverage.refresh(); - assert.equal(state.coverage.zoom, 13); - const fresh = source.calls.slice(1); - assert.ok(fresh.length > 0, 'the new zoom is loading'); - assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile'); - assert.ok(viewer.scene.groundPrimitives.items.has(old)); - for (const call of fresh) { - assert.ok( - viewer.scene.groundPrimitives.items.has(old), - 'kept while a new tile is still loading', - ); - call.resolve(new Uint8Array()); - await settle(); - } - // The last new tile is in: the old lines go now, long before the wait. - assert.equal(state.coverage.pending.size, 0); - assert.equal(state.coverage.stale.size, 0); - assert.equal(viewer.scene.groundPrimitives.items.has(old), false); - assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled'); - coverage.clear(); -}); - -test('a rate limit holds every refresh inside its wait, not only the first', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, source, coverage } = setup(); - coverage.refresh(); - source.calls[0].reject( - Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), - ); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - t.mock.timers.tick(1000); - viewer.view.lon += 0.05; - coverage.refresh(); - assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait'); - t.mock.timers.tick(28_000); - viewer.view.lon += 0.05; - coverage.refresh(); - assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait'); - t.mock.timers.tick(1000); - assert.ok(source.calls.length > 1, 'asked again once the wait is over'); - coverage.clear(); - coverage.resetErrors(); -}); - -test('a line across the date line from the camera is in mesh range', () => { - // A z14 tile's width of lines just past -180, the camera 111 m short of it. - const camera = { lon: 179.999, lat: 0 }; - const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 }; - assert.equal(meshBoxInRange(east, camera), true); - // The mirror case, from the other side. - const west = { west: 179.978, east: 180, south: -0.001, north: 0.001 }; - assert.equal(meshBoxInRange(west, { lon: -179.999, lat: 0 }), true); - // ~2.2 km away across the line: out of range either way. - const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 }; - assert.equal(meshBoxInRange(far, camera), false); -}); - -test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => { - const { source, state, coverage, tileKey } = setup(); - let terrainLoaded; - state.coverage.terrainReady = new Promise((resolve) => { - terrainLoaded = resolve; - }); - const unhandled = []; - const onUnhandled = (reason) => unhandled.push(reason); - process.on('unhandledRejection', onUnhandled); - try { - coverage.refresh(); - source.calls - .find((call) => call.key === tileKey) - .reject(new Error('Mapillary tiles HTTP 500')); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500'); - assert.deepEqual(unhandled, []); - } finally { - terrainLoaded(); - await settle(); - process.off('unhandledRejection', onUnhandled); - } -}); - -test('lines a partial terrain lookup left draped are cast once the terrain recovers', async () => { - // Two lines: the west one's heights arrive, the east one's fail at first. - let eastUp = false; - let prepares = 0; - const groundCaster = { - prepareLines: async () => { - prepares++; - return eastUp; - }, - castLine: (coords) => - coords[0][0] < centreLon || eastUp - ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) - : null, - }; - const { state, source, coverage, centre } = setup({ - surface: 'terrain', - groundCaster, - }); - const centreLon = centre.lon; - const line = (from, to) => [ - [ - [from, centre.lat], - [to, centre.lat], - ], - ]; - coverage.refresh(); - source.calls[0].resolve( - encodeCoverageTile(centre.tile, { - sequences: [ - { id: 'west', parts: line(centre.lon - 0.002, centre.lon - 0.001) }, - { id: 'east', parts: line(centre.lon + 0.001, centre.lon + 0.002) }, - ], - }), - ); - await settle(); - await settle(); - const [entry] = state.coverage.tiles.values(); - assert.equal(prepares, 1); - assert.deepEqual([entry.castLines, entry.drapedLines], [1, 1], 'partial'); - await settle(); - assert.equal(prepares, 1, 'no retry loop while the lookup fails'); - - // A refresh while still failing asks again but rebuilds nothing. - const drawn = entry.primitives; - coverage.refresh(); - await settle(); - assert.equal(prepares, 2); - assert.equal(entry.primitives, drawn, 'no rebuild without new heights'); - - eastUp = true; // the terrain proxy recovers - coverage.refresh(); - await settle(); - assert.equal(prepares, 3, 'the next refresh tries the draped line again'); - assert.deepEqual([entry.castLines, entry.drapedLines], [2, 0], 'all cast'); - coverage.clear(); -}); diff --git a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs index 9f9f0fa2f..9c506ea45 100644 --- a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs @@ -3,7 +3,7 @@ import { PbfWriter } from 'pbf'; export const zigzag = (value) => (value << 1) ^ (value >> 31); /** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */ -export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) { +function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) { const n = 2 ** z; const rad = (lat * Math.PI) / 180; const tx = ((lon + 180) / 360) * n; @@ -86,11 +86,11 @@ export function lineGeometry(parts) { * Encode a `mly1_public`-shaped tile from lon/lat features, for tests and * the hermetic QA gate. * @param {{x: number, y: number, z: number}} tile - * @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content] + * @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array>}>, extent?: number}} [content] */ export function encodeCoverageTile( tile, - { sequences = [], overview = [], extent = 4096 } = {}, + { sequences = [], extent = 4096 } = {}, ) { const pbf = new PbfWriter(); if (sequences.length) { @@ -111,17 +111,5 @@ export function encodeCoverageTile( ); pbf.writeMessage(3, (_, out) => layer.write(out), null); } - if (overview.length) { - const layer = layerWriter('overview', extent); - for (const point of overview) { - const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent); - layer.add(1, [9, zigzag(px), zigzag(py)], { - id: point.id, - captured_at: point.capturedAt ?? Date.now(), - is_pano: point.isPano === true, - }); - } - pbf.writeMessage(3, (_, out) => layer.write(out), null); - } return new Uint8Array(pbf.finish()); } diff --git a/src/layers/streetLevel/providers/mapillary/decode.js b/src/layers/streetLevel/providers/mapillary/decode.js index b4381dbad..ac2538b34 100644 --- a/src/layers/streetLevel/providers/mapillary/decode.js +++ b/src/layers/streetLevel/providers/mapillary/decode.js @@ -3,13 +3,13 @@ import { VectorTile } from '@mapbox/vector-tile'; import { tileLocalToLonLat } from '../../tileMath.js'; /** - * Decode a `mly1_public` coverage tile into sequences and (z0–5) overview - * points. The z14 `image` layer, which can hold >150k points, is skipped. + * Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14 + * `image` layer, which can hold >150k points, is skipped. * @param {Uint8Array} bytes * @param {{x:number,y:number,z:number}} address */ export function decodeCoverageTile(bytes, address) { - const result = { sequences: [], overview: [] }; + const result = { sequences: [] }; if (!bytes || !bytes.length) return result; const tile = new VectorTile(new PbfReader(bytes)); const { x, y, z } = address; @@ -30,41 +30,11 @@ export function decodeCoverageTile(bytes, address) { if (!parts.length) continue; result.sequences.push({ id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`), - imageId: props.image_id != null ? String(props.image_id) : null, capturedAt: Number(props.captured_at) || 0, isPano: props.is_pano === true, - onFoot: props.foot === true, - quality: Number.isFinite(props.quality_score) - ? props.quality_score - : null, parts, }); } } - const overviewLayer = tile.layers.overview; - if (overviewLayer) { - for (let i = 0; i < overviewLayer.length; i++) { - const feature = overviewLayer.feature(i); - const props = feature.properties || {}; - const point = feature.loadGeometry()?.[0]?.[0]; - if (!point) continue; - const [lon, lat] = tileLocalToLonLat( - point.x, - point.y, - overviewLayer.extent, - x, - y, - z, - ); - result.overview.push({ - id: String(props.id ?? feature.id ?? i), - lon, - lat, - capturedAt: Number(props.captured_at) || 0, - isPano: props.is_pano === true, - sequenceId: props.sequence_id ? String(props.sequence_id) : null, - }); - } - } return result; } diff --git a/src/layers/streetLevel/providers/mapillary/decode.test.mjs b/src/layers/streetLevel/providers/mapillary/decode.test.mjs index eeb1f0261..0c0873535 100644 --- a/src/layers/streetLevel/providers/mapillary/decode.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/decode.test.mjs @@ -17,7 +17,10 @@ function linePart(points) { return out; } -/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */ +/** + * A minimal mly1_public-shaped tile: one sequence line, plus one point in the + * z14 `image` layer the decoder skips. + */ function sampleTile({ geometry = null } = {}) { const writer = new PbfWriter(); writer.writeMessage( @@ -70,7 +73,7 @@ function sampleTile({ geometry = null } = {}) { 3, (_, layer) => { layer.writeVarintField(15, 2); - layer.writeStringField(1, 'overview'); + layer.writeStringField(1, 'image'); layer.writeVarintField(5, 4096); layer.writeStringField(3, 'is_pano'); layer.writeMessage( @@ -94,7 +97,7 @@ function sampleTile({ geometry = null } = {}) { return new Uint8Array(writer.finish()); } -test('sequences and overview points decode into lon/lat records', () => { +test('sequences decode into lon/lat records; image points are skipped', () => { const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 }); assert.equal(decoded.sequences.length, 1); const [sequence] = decoded.sequences; @@ -109,19 +112,13 @@ test('sequences and overview points decode into lon/lat records', () => { 'west half of tile 0/0 at z1', ); assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south'); - assert.equal(decoded.overview.length, 1); - assert.equal(decoded.overview[0].id, '3'); - assert.equal(decoded.overview[0].isPano, false); - assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre'); + assert.deepEqual(Object.keys(decoded), ['sequences']); }); test('an empty tile decodes to empty lists', () => { assert.deepEqual( decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }), - { - sequences: [], - overview: [], - }, + { sequences: [] }, ); }); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 1cbe887a6..928922195 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -1,70 +1,67 @@ -import { createCoverage, sequenceIdFromPick } from './coverage.js'; -import { createSequences } from './sequences.js'; +import { + createLineCoverage, + freshCoverage, + sequenceIdFromPick, +} from '../../lineCoverage.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { coverageZoomForHeight } from '../../tileMath.js'; +import { decodeCoverageTile } from './decode.js'; import { createMapillaryViewer } from './viewer.js'; -import { passesImageryFilter } from '../../filter.js'; -import { metresBetween } from '../../view.js'; import { COLORS, + COVERAGE_TUNING, + KEY_REJECTED_MESSAGE, MAPILLARY_CREDIT_HTML, MAPILLARY_KEY_ID, MAPILLARY_LABEL, MAPILLARY_NAME, MAPILLARY_PROVIDER_ID, MAPILLARY_SOURCE_METHODS, - NEAREST_LIMIT, - NEAREST_RADIUS_M, PICK_PREFIX, + RATE_LIMITED_MESSAGE, } from './policy.js'; -/** Per-provider mutable state, created once per `create()`. */ +/** Normalize a graph image record into the shape the cones use. */ +function normalizeSequenceImage(record) { + const coordinates = record?.geometry?.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) return null; + return { + id: String(record.id), + lon: Number(coordinates[0]), + lat: Number(coordinates[1]), + compassAngle: Number(record.compass_angle) || 0, + capturedAt: Number(record.captured_at) || 0, + isPano: record.is_pano === true, + }; +} + +/** Per-provider mutable state; coverage and cones share it. */ function createProviderState(context) { return { context, services: context.services, viewer: null, - keyRequired: false, - /** Coverage waits for the first status: no tile requests without a key. */ - statusKnown: false, - /** Mapillary refused the token: request nothing until re-enabled. */ - keyRejected: false, - status: null, - coverage: { - zoom: null, - /** Current zoom's tiles by `z/x/y` key. */ - tiles: new Map(), - /** Previous zoom's tiles, kept on screen until replacements land. */ - stale: new Map(), - staleTimer: null, - /** Tile key → its request's controller; any entry means LOADING. */ - pending: new Map(), - lastError: null, - /** While Mapillary rate-limits: no requests before this time (ms). */ - holdUntil: 0, - holdTimer: null, - debounceTimer: null, - removeCameraListener: null, - terrainReady: null, - hint: '', - kind: null, - }, + coverage: freshCoverage(), sequence: { selectedId: null, images: [], - /** Recent sequences' thinned images, by sequence id. */ - cache: new Map(), collection: null, loading: false, + /** Why the selected sequence's images could not load, until it goes. */ + error: null, abort: null, }, }; } /** - * Mapillary Street Level provider: tile coverage, Graph API image cones and - * the MapillaryJS viewer. `source` is ./source.js or a stand-in. + * The Mapillary provider: coverage lines from vector tiles, image cones from + * the Graph API and the MapillaryJS viewer. + * @param {{source: object, photoViewer?: object}} options `source` is + * source.js or a stand-in; `photoViewer` stands in for the viewer adapter. * @returns {import('../../registry.js').StreetLevelProvider} */ -export function createMapillaryProvider({ source }) { +export function createMapillaryProvider({ source, photoViewer = null }) { if ( !MAPILLARY_SOURCE_METHODS.every( (method) => typeof source?.[method] === 'function', @@ -84,86 +81,97 @@ export function createMapillaryProvider({ source }) { create(context) { const state = createProviderState(context); const parts = {}; - parts.coverage = createCoverage({ state, source, parts }); - parts.sequences = createSequences({ state, source, parts }); - const viewer = createMapillaryViewer({ - source, - render: context.services?.render, + parts.coverage = createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile( + await source.getTile('coverage', tile.z, tile.x, tile.y, { + signal, + }), + tile, + ), + zoomFor: coverageZoomForHeight, + colors: COLORS, + pickPrefix: PICK_PREFIX.sequence, + renderKey: 'mapillary-coverage', + tuning: COVERAGE_TUNING, + messages: { + keyRejected: KEY_REJECTED_MESSAGE, + rateLimited: RATE_LIMITED_MESSAGE, + }, + }); + parts.sequences = createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const records = await source.getSequenceImages(sequenceId, { + signal, + }); + return thinImages( + records + .map(normalizeSequenceImage) + .filter(Boolean) + .sort((a, b) => a.capturedAt - b.capturedAt), + ); + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:mapillary-cones', + renderKey: 'mapillary-sequence', + onHighlight: (sequenceId) => parts.coverage.highlight(sequenceId), }); return { - async status() { + /** Coverage waits for the answer: no tile requests without a key. */ + async status({ signal } = {}) { + let configured; try { - state.status = await source.getStatus(); - state.keyRequired = state.status?.configured !== true; + configured = + (await source.getStatus({ signal }))?.configured === true; } catch { - state.status = null; - state.keyRequired = !source.hasToken(); + configured = source.hasToken?.() === true; } - const first = !state.statusKnown; - state.statusKnown = true; - // Coverage held back until now: draw it if the layer is on. - if (first) parts.coverage.refresh(); - context.notify(); - return { configured: !state.keyRequired }; + parts.coverage.setKeyStatus(configured); + return { configured }; }, - init(cesiumViewer) { - state.viewer = cesiumViewer; - parts.sequences.ensureCollections(cesiumViewer); + init(viewer) { + state.viewer = viewer; + parts.sequences.ensureCollections(viewer); parts.sequences.setVisible(false); }, - activate(cesiumViewer) { - state.viewer = cesiumViewer; + activate(viewer) { + state.viewer = viewer; parts.sequences.setVisible(true); - parts.coverage.attach(cesiumViewer); + parts.coverage.attach(viewer); }, + /** Clear lines and cones, forget refusals. */ deactivate() { parts.coverage.detach(); parts.coverage.clear(); - parts.coverage.resetErrors(); + parts.coverage.unblock(); parts.sequences.clearSelection(); parts.sequences.setVisible(false); }, - destroy(cesiumViewer) { + destroy(viewer) { parts.coverage.detach(); parts.coverage.clear(); - parts.coverage.resetErrors(); - parts.sequences.destroy(cesiumViewer); + parts.sequences.destroy(viewer); }, - refreshCoverage: () => parts.coverage.refresh(), - /** The filter changed: redraw. Each draw reads it from the context. */ setFilter() { parts.coverage.rebuild(); parts.sequences.rerender(); }, - /** Core surface mode changed: redraw lines and cones draped or cast. */ - setSurface() { - parts.coverage.setSurface(); - parts.sequences.rerender(); - }, - - coverageStats() { - return { - count: parts.coverage.sequenceCount(), - zoom: state.coverage.zoom, - kind: state.coverage.kind, - loading: state.coverage.pending.size > 0, - hint: state.coverage.hint, - error: state.coverage.lastError, - keyRequired: state.keyRequired, - keyRejected: state.keyRejected, - }; - }, + coverageStats: () => parts.coverage.stats(), + /** A line selects its sequence; a cone opens its image. */ handlePick(pickId) { - const sequenceId = sequenceIdFromPick(pickId); + const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence); if (sequenceId) { parts.sequences.select(sequenceId); return true; @@ -181,39 +189,12 @@ export function createMapillaryProvider({ source }) { selectedId: state.sequence.selectedId, images: state.sequence.images.length, loading: state.sequence.loading, + error: state.sequence.error, }), - /** - * Nearest image that passes the imagery filter, or null. The core - * aborts `signal` once a newer request overtakes the lookup. - */ - async nearestImage({ lat, lon }, { signal } = {}) { - const images = await source.nearestImages( - { lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT }, - { signal }, - ); - // The API returns the images in the radius in no particular order. - const metres = (record) => { - const [lon2, lat2] = (record.computed_geometry || record.geometry) - ?.coordinates || [NaN, NaN]; - const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 }); - return Number.isFinite(d) ? d : Infinity; - }; - const byDistance = [...images].sort((a, b) => metres(a) - metres(b)); - const filter = context.getFilter(); - const hit = byDistance.find((record) => - passesImageryFilter( - { - isPano: record.is_pano === true, - capturedAt: Number(record.captured_at) || 0, - }, - filter, - ), - ); - return hit ? String(hit.id) : null; - }, - - viewer, + viewer: + photoViewer || + createMapillaryViewer({ source, render: context.services?.render }), }; }, }); diff --git a/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs new file mode 100644 index 000000000..a0f96138f --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mapillaryImageUrl } from './policy.js'; +import { sequenceIdFromPick } from '../../lineCoverage.js'; +import { thinImages } from '../../sequenceCones.js'; + +test('image deep links match the mapillary.com share format', () => { + assert.equal( + mapillaryImageUrl(1814275685699406), + 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', + ); + assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/'); +}); + +test('a sequence line pick names its sequence; other picks do not', () => { + assert.equal(sequenceIdFromPick('mly:seq:abc', 'mly:seq:'), 'abc'); + assert.equal(sequenceIdFromPick('mly:img:abc', 'mly:seq:'), null); + assert.equal(sequenceIdFromPick(null, 'mly:seq:'), null); +}); + +test('cone thinning across the date line drops images metres apart', () => { + const image = (id, lon) => ({ id, lon, lat: 0 }); + // A sequence driving east over ±180° with images ~1 m apart, then ~11 m on. + const kept = thinImages( + [ + image('a', 179.99999), + image('b', -179.99999), // ~2 m from a, across the date line + image('c', -179.9999), // ~11 m from a + ], + 3, + ); + assert.deepEqual( + kept.map(({ id }) => id), + ['a', 'c'], + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 4689f3688..183082934 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -4,8 +4,6 @@ import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; // i18n import: the messages stay English data, translated where shown. const msg = (text) => text; -export { NEAREST_RADIUS_M } from '../../policy.js'; - /** Identity and tuning for the Mapillary street-level provider. */ export const MAPILLARY_PROVIDER_ID = 'mapillary'; export const MAPILLARY_NAME = 'Mapillary'; @@ -13,12 +11,11 @@ export const MAPILLARY_LABEL = 'MAPILLARY'; export const MAPILLARY_KEY_ID = 'mapillary'; export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com'; -/** Methods a source (./source.js or a stand-in) must have for the provider. */ +/** What the provider needs from its source (source.js or a stand-in). */ export const MAPILLARY_SOURCE_METHODS = Object.freeze([ 'getStatus', 'getTile', 'getSequenceImages', - 'nearestImages', ]); /** Stable id prefixes for picked primitives; every one starts with `mly:`. */ @@ -28,17 +25,12 @@ export const PICK_PREFIX = Object.freeze({ image: 'mly:img:', }); -/** Mapillary green for lines, points and cones; GEV cyan for selection. */ +/** Mapillary green for lines and cones; GEV cyan for the selection. */ export const COLORS = Object.freeze({ coverage: PROVIDER_COLORS.mapillary, selected: SHARED_COLORS.selected, }); -/** Camera-driven coverage refresh. */ -export const COVERAGE_MOVE_DEBOUNCE_MS = 320; -export const COVERAGE_MAX_TILES = 9; -/** Overview (z0–5) coverage points seen from orbit. */ -export const COVERAGE_OVERVIEW_MAX_TILES = 16; /** Shown when Mapillary refuses the server's token (401/403). */ export const KEY_REJECTED_MESSAGE = msg( 'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings', @@ -47,24 +39,25 @@ export const KEY_REJECTED_MESSAGE = msg( export const RATE_LIMITED_MESSAGE = msg( 'Coverage requests are being rate-limited — they resume on their own', ); -/** - * Street-zoom coverage reaches this far from the camera: camera height times - * the factor, never below the floor, even when a tilted view sees further. - */ -export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500; -export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10; -/** Ground always covered around the camera at street zooms, in metres. */ -export const SEQUENCE_VIEW_NEAR_M = 1_000; -export const COVERAGE_OVERVIEW_POINT_PX = 2.5; -export const COVERAGE_MAX_SEQUENCES = 6000; -export const COVERAGE_LINE_WIDTH_PX = 2.5; + +/** Coverage tuning (see LineCoverageTuning in lineCoverage.js). */ +export const COVERAGE_TUNING = Object.freeze({ + maxTiles: 9, + maxSequences: 6000, + lineWidthPx: 2.5, + moveDebounceMs: 320, + /** + * Street-zoom coverage reaches this far from the camera: camera height + * times the factor, never below the floor, even when a tilted view sees + * further. + */ + viewRangeMinM: 2_500, + viewRangePerHeight: 10, +}); /** Per-sequence image cones after a sequence is selected. */ export const SEQUENCE_IMAGES_LIMIT = 2000; -/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */ -export const NEAREST_LIMIT = 8; - /** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */ export const MAPILLARY_CREDIT_HTML = 'Street Level: imagery © Mapillary contributors, CC BY-SA 4.0'; @@ -76,8 +69,6 @@ export function mapillaryImageUrl(imageId) { return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`; } -/** Fields requested from the graph API. */ -export const IMAGE_FIELDS = - 'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score'; +/** Fields requested for a sequence's images. */ export const SEQUENCE_IMAGE_FIELDS = - 'id,captured_at,compass_angle,geometry,is_pano,computed_altitude'; + 'id,captured_at,compass_angle,geometry,is_pano'; diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index 35e8593bf..ce2f0bb0c 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -1,44 +1,30 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import * as Cesium from 'cesium'; import { createMapillaryProvider } from './index.js'; import { mapillaryImageUrl } from './policy.js'; -import { sequenceIdFromPick } from './coverage.js'; -import { thinImages } from './sequences.js'; +import { sequenceIdFromPick } from '../../lineCoverage.js'; +import { thinImages } from '../../sequenceCones.js'; import { validateProviders } from '../../registry.js'; -import { PROVIDER_COLORS } from '../../policy.js'; -import { resolveFilter } from '../../filter.js'; -import * as Cesium from 'cesium'; -import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; - -function fakeSource({ nearest = [] } = {}) { - return { - hasToken: () => true, - getStatus: async () => ({ configured: true }), - getTile: async () => new Uint8Array(0), - getSequenceImages: async () => [], - nearestImages: async () => nearest, - }; -} +import { + fakeMapillarySource, + rayCamera, +} from '../../../../testSupport/streetLevelFakes.mjs'; function fakeContext(filter = { pano: 'all', sinceMs: null }) { const opened = []; return { opened, services: {}, - getFilter: typeof filter === 'function' ? filter : () => filter, + getFilter: () => filter, isActive: () => true, notify: () => {}, - actions: { - openImage: (id) => { - opened.push(id); - }, - reportError: () => {}, - }, + actions: { openImage: (id) => opened.push(id) }, }; } test('the definition satisfies the Street Level provider contract', () => { - const def = createMapillaryProvider({ source: fakeSource() }); + const def = createMapillaryProvider({ source: fakeMapillarySource() }); assert.doesNotThrow(() => validateProviders([def])); assert.equal(def.id, 'mapillary'); assert.equal(def.label, 'MAPILLARY'); @@ -46,9 +32,7 @@ test('the definition satisfies the Street Level provider contract', () => { assert.equal(def.pickPrefix, 'mly:'); assert.match(def.credit.html, /CC BY-SA 4\.0/); // One colour per source: Mapillary is green everywhere it draws. - assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']); - assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary); - assert.equal(PROVIDER_COLORS.mapillary, '#05cb63'); + assert.equal(def.colors.coverage, '#05cb63'); assert.throws(() => createMapillaryProvider({ source: {} }), /source/); }); @@ -62,9 +46,9 @@ test('image deep links match the mapillary.com share format', () => { test('picks route sequences to selection and images to the core opener', () => { const context = fakeContext(); - const instance = createMapillaryProvider({ source: fakeSource() }).create( - context, - ); + const instance = createMapillaryProvider({ + source: fakeMapillarySource(), + }).create(context); assert.equal(instance.handlePick('mly:img:77'), true); assert.deepEqual(context.opened, ['77']); assert.equal(instance.handlePick('cctv:1'), false); @@ -72,99 +56,14 @@ test('picks route sequences to selection and images to the core opener', () => { selectedId: null, images: 0, loading: false, + error: null, }); }); -test('nearestImage honours the imagery filter and reports key status', async () => { - const nearest = [ - { id: 1, is_pano: false, captured_at: 10 }, - { id: 2, is_pano: true, captured_at: 20 }, - ]; - const source = fakeSource({ nearest }); - const flat = createMapillaryProvider({ source }).create(fakeContext()); - assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1'); - const pano = createMapillaryProvider({ source }).create( - fakeContext({ pano: 'pano', sinceMs: null }), - ); - assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2'); - const none = createMapillaryProvider({ source }).create( - fakeContext({ pano: 'all', sinceMs: 100 }), - ); - assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null); - assert.deepEqual(await flat.status(), { configured: true }); - assert.equal(flat.coverageStats().keyRequired, false); -}); - -test('any part of a multi-part sequence picks the whole sequence', () => { - assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc'); - assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc'); - assert.equal(sequenceIdFromPick('mly:img:abc'), null); - assert.equal(sequenceIdFromPick(null), null); -}); - -test('nearestImage picks the closest image, not the first the API returned', async () => { - const at = (id, lon, lat) => ({ - id, - is_pano: false, - captured_at: 10, - geometry: { type: 'Point', coordinates: [lon, lat] }, - }); - const source = fakeSource({ - nearest: [ - at('far', 2.0004, 1), // ~45 m east - at('near', 2.00005, 1), // ~5 m east - at('mid', 2, 1.0002), // ~22 m north - ], - }); - const instance = createMapillaryProvider({ source }).create(fakeContext()); - assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near'); -}); - -test('a "since N days" window follows the clock in a long-open tab', async (t) => { - t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) }); - const day = 86_400_000; - const capturedAt = Date.now() - day / 2; - const source = fakeSource({ - nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }], - }); - // The core's context resolves the stored filter whenever it is asked. - const instance = createMapillaryProvider({ source }).create( - fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })), - ); - assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent'); - t.mock.timers.tick(2 * day); - assert.equal( - await instance.nearestImage({ lat: 1, lon: 2 }), - null, - 'two days on, the image is older than the window', - ); -}); - -test('nearestImage measures the short way round the date line', async () => { - const at = (id, lon, lat) => ({ - id, - is_pano: false, - captured_at: 10, - geometry: { type: 'Point', coordinates: [lon, lat] }, - }); - const source = fakeSource({ - nearest: [ - at('same-side', 179.9995, 0), // ~45 m west, on the camera's side - at('across', -179.9999, 0), // ~11 m east, across ±180° - ], - }); - const instance = createMapillaryProvider({ source }).create(fakeContext()); - assert.equal( - await instance.nearestImage({ lat: 0, lon: 179.9999 }), - 'across', - ); - // And from the other side. - const west = createMapillaryProvider({ - source: fakeSource({ - nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)], - }), - }).create(fakeContext()); - assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across'); +test('a line pick names its sequence; anything else is not a sequence', () => { + assert.equal(sequenceIdFromPick('mly:seq:abc', 'mly:seq:'), 'abc'); + assert.equal(sequenceIdFromPick('mly:img:abc', 'mly:seq:'), null); + assert.equal(sequenceIdFromPick(null, 'mly:seq:'), null); }); test('cone thinning across the date line drops images metres apart', () => { @@ -220,19 +119,24 @@ function streetViewer() { }; } -test('coverage asks for no tiles before the key status is known', async () => { +test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => { + // Skip Cesium's one-time terrain table download for draped lines. + t.mock.method( + Cesium.GroundPolylinePrimitive, + 'initializeTerrainHeights', + async () => {}, + ); for (const configured of [false, true]) { let answer; const tiles = []; - const source = { - ...fakeSource(), + const source = fakeMapillarySource({ hasToken: () => configured, getStatus: () => new Promise((resolve) => (answer = resolve)), getTile: (...args) => { tiles.push(args); return new Promise(() => {}); }, - }; + }); const instance = createMapillaryProvider({ source }).create(fakeContext()); const viewer = streetViewer(); instance.init(viewer); @@ -240,7 +144,7 @@ test('coverage asks for no tiles before the key status is known', async () => { instance.activate(viewer); assert.equal(tiles.length, 0, 'nothing before the status answers'); answer({ configured }); - await status; + assert.deepEqual(await status, { configured }); if (configured) assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known'); else { @@ -250,3 +154,19 @@ test('coverage asks for no tiles before the key status is known', async () => { instance.destroy(viewer); } }); + +test('a status the server never answers falls back to the bundled token once the core gives up', async () => { + const source = fakeMapillarySource({ + hasToken: () => false, + getStatus: ({ signal } = {}) => + new Promise((_, reject) => + signal?.addEventListener('abort', () => reject(signal.reason)), + ), + }); + const instance = createMapillaryProvider({ source }).create(fakeContext()); + const controller = new AbortController(); + const status = instance.status({ signal: controller.signal }); + controller.abort(new Error('timed out')); + assert.deepEqual(await status, { configured: false }); + assert.equal(instance.coverageStats().keyRequired, true); +}); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js deleted file mode 100644 index b10902a2b..000000000 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ /dev/null @@ -1,43 +0,0 @@ -import { createSequenceCones, thinImages } from '../../sequenceCones.js'; -import { COLORS, PICK_PREFIX } from './policy.js'; - -export { thinImages } from '../../sequenceCones.js'; - -/** Normalize a graph image record into the shape the cones use. */ -export function normalizeSequenceImage(record) { - const coordinates = record?.geometry?.coordinates; - if (!Array.isArray(coordinates) || coordinates.length < 2) return null; - return { - id: String(record.id), - lon: Number(coordinates[0]), - lat: Number(coordinates[1]), - compassAngle: Number(record.compass_angle) || 0, - capturedAt: Number(record.captured_at) || 0, - isPano: record.is_pano === true, - altitude: Number.isFinite(record.computed_altitude) - ? record.computed_altitude - : null, - }; -} - -/** Image cones for the selected Mapillary sequence, its line highlighted. */ -export function createSequences({ state, source, parts }) { - return createSequenceCones({ - state, - async loadImages(sequenceId, { signal }) { - const records = await source.getSequenceImages(sequenceId, { signal }); - return thinImages( - records - .map(normalizeSequenceImage) - .filter(Boolean) - .sort((a, b) => a.capturedAt - b.capturedAt), - ); - }, - colors: COLORS, - pickPrefix: PICK_PREFIX.image, - spriteId: 'street-level:mapillary-cones', - renderKey: 'mapillary-sequence', - onHighlight: (sequenceId, selected) => - parts.coverage.recolorSequence(sequenceId, selected), - }); -} diff --git a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs deleted file mode 100644 index e29f05a92..000000000 --- a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs +++ /dev/null @@ -1,325 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import * as Cesium from 'cesium'; -import { MESH_CELL_DEG } from '../../meshSampler.js'; -import { createSequences } from './sequences.js'; - -// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op. -const noop = () => {}; -const context2d = new Proxy({}, { get: () => noop, set: () => true }); -globalThis.document ??= { - createElement: () => ({ getContext: () => context2d }), -}; - -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -/** A billboard collection that remembers the cones on the globe. */ -function cones() { - const items = []; - return { - items, - show: true, - add(options) { - items.push(options); - return options; - }, - removeAll() { - items.length = 0; - }, - }; -} - -/** A source whose sequence lookups the test answers, one call at a time. */ -function deferredSource() { - const calls = []; - return { - calls, - getSequenceImages(sequenceId, { signal } = {}) { - return new Promise((resolve, reject) => { - calls.push({ sequenceId, signal, resolve, reject }); - }); - }, - /** The latest lookup for a sequence. */ - last(sequenceId) { - return calls.filter((call) => call.sequenceId === sequenceId).at(-1); - }, - }; -} - -/** Graph image records for a sequence, a few metres apart. */ -function records(sequenceId, count = 2) { - return Array.from({ length: count }, (_, i) => ({ - id: `${sequenceId}-${i}`, - geometry: { coordinates: [-121.49 + i * 0.001, 38.58] }, - compass_angle: 90, - captured_at: 1_700_000_000_000 + i, - })); -} - -function setup() { - const source = deferredSource(); - const colours = []; - const errors = []; - const state = { - services: {}, - viewer: {}, - context: { - notify() {}, - getFilter: () => ({ pano: 'all', sinceMs: null }), - getSurface: () => 'draped', - groundCaster: null, - meshSampler: null, - actions: { reportError: (message) => errors.push(message) }, - }, - sequence: { - selectedId: null, - images: [], - cache: new Map(), - collection: cones(), - loading: false, - abort: null, - }, - }; - const parts = { - coverage: { - recolorSequence: (id, selected) => colours.push([id, selected]), - }, - }; - const sequences = createSequences({ state, source, parts }); - const drawn = () => - state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1)); - return { state, source, sequences, colours, errors, drawn }; -} - -test('a failed sequence load leaves no stale cones and can be retried', async () => { - const { state, source, sequences, colours, errors, drawn } = setup(); - sequences.select('A'); - source.last('A').resolve(records('A')); - await settle(); - assert.deepEqual(drawn(), ['A-0', 'A-1']); - - sequences.select('B'); - assert.deepEqual(drawn(), [], "A's cones go while B loads"); - source.last('B').reject(new Error('Sequence images unavailable')); - await settle(); - assert.deepEqual(drawn(), [], 'nothing of A is left to click'); - assert.equal(state.sequence.loading, false); - assert.equal(state.sequence.selectedId, null, 'B is not left highlighted'); - assert.deepEqual(colours.at(-1), ['B', false]); - assert.deepEqual(errors, [null, 'Sequence images unavailable']); - - // Clicking B again asks again. - sequences.select('B'); - assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2); - source.last('B').resolve(records('B')); - await settle(); - assert.deepEqual(drawn(), ['B-0', 'B-1']); - assert.equal(state.sequence.selectedId, 'B'); - assert.equal(errors.at(-1), null, 'the error is cleared once B loads'); -}); - -test('a late answer for a superseded sequence neither draws nor is cached', async () => { - const { state, source, sequences, drawn } = setup(); - sequences.select('A'); - sequences.select('B'); - assert.equal(source.last('A').signal.aborted, true, 'A was cancelled'); - source.last('B').resolve(records('B')); - await settle(); - source.last('A').resolve(records('A')); - await settle(); - assert.deepEqual(drawn(), ['B-0', 'B-1']); - assert.equal(state.sequence.selectedId, 'B'); - assert.equal(state.sequence.cache.has('A'), false); - assert.equal(state.sequence.loading, false); -}); - -test('a second click on a loading sequence does not start another lookup', async () => { - const { source, sequences } = setup(); - sequences.select('A'); - sequences.select('A'); - assert.equal(source.calls.length, 1); - assert.equal(source.calls[0].signal.aborted, false); -}); - -test('a cached sequence is re-selected without a lookup', async () => { - const { state, source, sequences, drawn } = setup(); - sequences.select('A'); - source.last('A').resolve(records('A')); - await settle(); - sequences.select('B'); - source.last('B').resolve(records('B')); - await settle(); - sequences.select('A'); - assert.equal(source.calls.length, 2, 'A came from the cache'); - assert.deepEqual(drawn(), ['A-0', 'A-1'], 'drawn at once'); - assert.equal(state.sequence.loading, false); - assert.equal(state.sequence.selectedId, 'A'); -}); - -test('clearSelection cancels the lookup and uncolours the sequence', async () => { - const { state, source, sequences, colours, errors, drawn } = setup(); - sequences.select('A'); - sequences.clearSelection(); - assert.equal(source.last('A').signal.aborted, true); - assert.deepEqual(colours, [ - ['A', true], - ['A', false], - ]); - assert.equal(state.sequence.selectedId, null); - assert.equal(state.sequence.loading, false); - assert.equal(errors.at(-1), null, 'an error about it goes too'); - source.last('A').resolve(records('A')); - await settle(); - assert.deepEqual(drawn(), [], 'the late answer draws nothing'); -}); - -/** - * Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and - * cones that log every change made to them. - */ -function terrainSetup() { - const context = setup(); - const { state } = context; - const mesh = new Map(); - const cell = (lon, lat) => - `${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`; - let announce = null; - state.context.getSurface = () => 'terrain'; - state.context.groundCaster = { - groundAt: () => 10, - prepare: async () => true, - }; - state.context.meshSampler = { - request() {}, - meshAt: (lon, lat) => mesh.get(cell(lon, lat)), - onSampled: (listener) => { - announce = listener; - return () => (announce = null); - }, - }; - const changes = []; - const items = []; - state.sequence.collection = { - items, - show: true, - add(options) { - changes.push(['add', options.id]); - const cone = new Proxy(options, { - set(target, key, value) { - changes.push([key, target.id]); - target[key] = value; - return true; - }, - }); - items.push(cone); - return cone; - }, - removeAll() { - changes.push(['removeAll']); - items.length = 0; - }, - }; - // Created again, so it listens to this sampler. - const sequences = createSequences({ - state, - source: context.source, - parts: { coverage: { recolorSequence() {} } }, - }); - /** Sample the mesh under a point, then announce it as the sampler does. */ - function sample(lon, lat, height) { - const centre = [ - Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG, - Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG, - ]; - mesh.set(cell(lon, lat), height); - announce([centre]); - } - const heightOf = (cone) => - Cesium.Cartographic.fromCartesian(cone.position).height; - return { ...context, sequences, changes, items, sample, heightOf }; -} - -test('a mesh sample away from the selected sequence changes no cone', async () => { - const { source, sequences, changes, items, sample } = terrainSetup(); - sequences.select('A'); - source.last('A').resolve(records('A', 3)); - await settle(); - assert.equal(items.length, 3); - changes.length = 0; - sample(-121.3, 38.7, 12); // a street elsewhere in the tile - assert.deepEqual(changes, [], 'nothing rebuilt or moved'); -}); - -test('a mesh sample under a cone moves that cone in place, and no other', async () => { - const { source, sequences, changes, items, sample, heightOf } = - terrainSetup(); - sequences.select('A'); - source.last('A').resolve(records('A', 3)); - await settle(); - const before = items.map(heightOf); - changes.length = 0; - // The road under the second image is 2.5 m above the bare earth. - sample(-121.489, 38.58, 12.5); - assert.ok( - changes.every(([, id]) => id === 'mly:img:A-1'), - `only A-1 changed: ${JSON.stringify(changes)}`, - ); - assert.ok( - changes.some(([key]) => key === 'position'), - 'A-1 moved', - ); - assert.equal( - changes.some(([key]) => key === 'removeAll' || key === 'add'), - false, - 'the collection is not rebuilt', - ); - const after = items.map(heightOf); - assert.ok( - Math.abs(after[1] - 13.5) < 0.01, - `A-1 stands on the road (${after[1]})`, - ); - assert.deepEqual([after[0], after[2]], [before[0], before[2]]); -}); - -test('cones behind the globe are hidden, and show again from the other side', async () => { - const { state, source, sequences } = setup(); - const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; - const preRender = new Set(); - state.viewer = { - camera: { - get positionWC() { - return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); - }, - }, - scene: { - preRender: { - addEventListener(listener) { - preRender.add(listener); - return () => preRender.delete(listener); - }, - }, - }, - }; - sequences.select('A'); - // One image under the camera, one near its antipode. - source.last('A').resolve([ - ...records('A', 1), - { - id: 'A-far', - geometry: { coordinates: [58.51, -38.58] }, - captured_at: 1_800_000_000_000, - }, - ]); - await settle(); - const [near, far] = state.sequence.collection.items; - assert.equal(near.show, true, 'the near side stays visible'); - assert.equal(far.show, false, 'the far side does not show through'); - // Fly round to the other hemisphere: the two swap. - view.lon = 58.51; - view.lat = -38.58; - for (const listener of [...preRender]) listener(); - assert.equal(near.show, false); - assert.equal(far.show, true); - sequences.clearSelection(); - assert.equal(preRender.size, 0, 'the cull listener is gone'); -}); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 8871f3173..38a8b9c39 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -1,8 +1,5 @@ import { - IMAGE_FIELDS, MAPILLARY_GRAPH_HOST, - NEAREST_LIMIT, - NEAREST_RADIUS_M, SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGES_LIMIT, } from './policy.js'; @@ -39,16 +36,29 @@ async function readJsonOrThrow(response, label) { return payload; } +/** `endpoint` with exactly one trailing slash, or '' when unset. */ +function endpointBase(endpoint) { + const text = String(endpoint || '').trim(); + return text ? text.replace(/\/*$/, '/') : ''; +} + /** * Mapillary network access: tiles via the local proxy, which adds the token * and caches; Graph API lookups direct with the client token, as MapillaryJS. - * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options + * With `graphEndpoint` (a Graph API base that adds the token itself, such as + * a same-origin proxy), the source's Graph lookups go there through + * `fetchImpl` instead, and the photo viewer sends its own lookups there too; + * no token is sent or needed. + * @param {{token?: string, fetchImpl?: Function, endpoints?: object, + * graphEndpoint?: string}} options */ export function createMapillarySource({ token = '', fetchImpl = (...args) => globalThis.fetch(...args), endpoints = {}, + graphEndpoint = '', } = {}) { + const graphBase = endpointBase(graphEndpoint); const urls = { status: '/api/mapillary/status', tiles: '/api/mapillary/tiles', @@ -57,6 +67,13 @@ export function createMapillarySource({ }; async function graph(path, params = {}, { signal } = {}) { + if (graphBase) { + const search = new URLSearchParams(params); + const response = await fetchImpl(`${graphBase}${path}?${search}`, { + signal, + }); + return readJsonOrThrow(response, 'Mapillary graph'); + } if (!token) throw new MapillarySourceError('Mapillary token not configured', { status: 503, @@ -73,6 +90,10 @@ export function createMapillarySource({ get token() { return token; }, + /** The Graph API base that adds the token itself, or '' (direct). */ + get graphEndpoint() { + return graphBase; + }, hasToken: () => Boolean(token), async getStatus({ signal } = {}) { @@ -126,23 +147,5 @@ export function createMapillarySource({ ); return Array.isArray(payload?.data) ? payload.data : []; }, - - async nearestImages( - { lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT }, - { signal } = {}, - ) { - const payload = await graph( - 'images', - { - lat: String(lat), - lng: String(lon), - radius: String(Math.min(50, Math.max(1, radius))), - limit: String(Math.min(100, Math.max(1, limit))), - fields: IMAGE_FIELDS, - }, - { signal }, - ); - return Array.isArray(payload?.data) ? payload.data : []; - }, }; } diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index 784254185..b502a7fd8 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -62,21 +62,6 @@ test('graph lookups call graph.mapillary.com with the client token and requested assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc'); assert.equal(url.searchParams.get('sequence_ids'), 'seq-1'); assert.match(url.searchParams.get('fields'), /geometry/); - - const nearest = await source.nearestImages({ - lat: 38.5, - lon: -121.5, - radius: 500, - limit: 500, - }); - assert.equal(nearest.length, 1); - const nearestUrl = new URL(calls[1].url); - assert.equal( - nearestUrl.searchParams.get('radius'), - '50', - 'radius is clamped to the API maximum', - ); - assert.equal(nearestUrl.searchParams.get('limit'), '100'); }); test('graph calls without a token fail fast as keyRequired without a request', async () => { @@ -104,12 +89,6 @@ test('graph API errors carry the upstream message and status', async () => { ); }); -test('the source exposes only imagery lookups', () => { - const source = createMapillarySource({ token: 'MLY|1|abc' }); - for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl']) - assert.equal(source[gone], undefined, gone); -}); - test('a rejected key and a rate limit are told apart from other tile errors', async () => { const rejectedSource = createMapillarySource({ token: 'MLY|1|abc', @@ -146,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as 'the Retry-After header wins over the body', ); }); + +test('with a Graph endpoint, sequence lookups go there without a token', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph', + fetchImpl, + }); + assert.equal(source.graphEndpoint, '/api/mapillary/graph/'); + assert.equal(source.hasToken(), false); + const images = await source.getSequenceImages('seq-1'); + assert.equal(images.length, 1); + const url = new URL(calls[0].url, 'http://localhost'); + assert.equal(url.pathname, '/api/mapillary/graph/images'); + assert.equal(url.searchParams.get('sequence_ids'), 'seq-1'); + assert.equal(url.searchParams.has('access_token'), false); +}); + +test('Graph endpoint errors keep their status', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ error: 'no_key', keyRequired: true }, 503), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph/', + fetchImpl, + }); + await assert.rejects( + () => source.getSequenceImages('1'), + (error) => error.status === 503 && error.keyRequired === true, + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index a5fb0ed30..4ec2654e4 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -1,24 +1,50 @@ -import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js'; +import { mapillaryImageUrl } from './policy.js'; /** - * Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a - * provider-neutral pose on every image, view or position change. - * @returns {import('../../registry.js').ViewerAdapter} + * @typedef {object} StreetPose + * @property {string} imageId + * @property {{lon: number, lat: number}} position + * @property {number|null} bearing + * @property {number|null} altitude + * @property {boolean} isPano + * @property {number|null} capturedAt Epoch milliseconds. + * @property {string|null} creator + * @property {string|null} sequenceId + * @property {string} externalUrl Deep link to the image on mapillary.com. + */ + +/** + * The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS + * resolves its lookup paths against it; '' when the source has none. + */ +export function graphEndpointUrl(source) { + if (!source?.graphEndpoint) return ''; + const url = new URL( + source.graphEndpoint, + globalThis.location?.href || 'http://localhost/', + ); + url.pathname = url.pathname.replace(/\/*$/, '/'); + return url.href; +} + +/** + * The panel's photo viewer: loads MapillaryJS on the first `mount` and emits + * a pose on every image, view or position change. */ export function createMapillaryViewer({ source, render } = {}) { let viewer = null; let Library = null; let container = null; + /** The viewer being built for a host: a second mount joins it, not a twin. */ + let building = null; let pendingOpen = null; - let prewarming = null; - /** In-flight viewer construction, so pre-warm and open never build two. */ - let creating = null; /** Bumped by `unmount`, so a construction it overtook builds nothing. */ let generation = 0; let renderMode = 'letterbox'; /** Metadata of the image on screen; pov/position events reuse it. */ let current = null; - const listeners = new Set(); + /** The pose listener (the viewer host); one at a time. */ + let poseListener = null; function requestRender() { render?.governorRequestRender?.('mapillary-viewer'); @@ -43,16 +69,14 @@ export function createMapillaryViewer({ source, render } = {}) { } function emit(pose) { - for (const listener of [...listeners]) { - try { - listener(pose); - } catch { - /* listener errors are the core's to log */ - } + try { + poseListener?.(pose); + } catch { + /* listener errors are the core's to log */ } } - /** Read MapillaryJS image metadata into the neutral pose shape. */ + /** Read MapillaryJS image metadata into the pose shape. */ function describe(image) { return { imageId: String(image.id), @@ -81,11 +105,9 @@ export function createMapillaryViewer({ source, render } = {}) { if (image) current = describe(image); if (!current || !viewer) return; emit({ - providerId: MAPILLARY_PROVIDER_ID, ...current, position: { lon: lngLat.lng, lat: lngLat.lat }, bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null, - tilt: Number.isFinite(pov?.tilt) ? pov.tilt : 0, externalUrl: mapillaryImageUrl(current.imageId), }); requestRender(); @@ -95,24 +117,30 @@ export function createMapillaryViewer({ source, render } = {}) { } function ensureViewer(host) { - if (viewer && container === host) return viewer; - if (creating?.container === host) return creating.promise; - const promise = createViewer(host).finally(() => { - if (creating?.promise === promise) creating = null; + if (viewer && container === host) return Promise.resolve(viewer); + if (building?.host === host) return building.promise; + const promise = buildViewer(host).finally(() => { + if (building?.promise === promise) building = null; }); - creating = { container: host, promise }; + building = { host, promise }; return promise; } - async function createViewer(host) { + async function buildViewer(host) { const built = generation; destroyViewer(); - const { Viewer } = await ensureLibrary(); + const { Viewer, GraphDataProvider } = await ensureLibrary(); // Unmounted (layer off) while the library loaded: a viewer built now // would hold a WebGL context nobody releases. if (built !== generation) throw new Error('Mapillary viewer was unmounted'); + const endpoint = graphEndpointUrl(source); viewer = new Viewer({ - accessToken: source.token, + // A source with its own Graph endpoint (which adds the token itself) + // answers the viewer's lookups, sent without a token; otherwise + // MapillaryJS calls the Graph API with the client token. + ...(endpoint + ? { dataProvider: new GraphDataProvider({ endpoint }) } + : { accessToken: source.token }), container: host, component: { cover: false, @@ -178,9 +206,9 @@ export function createMapillaryViewer({ source, render } = {}) { unmount() { pendingOpen = null; - // The next mount builds afresh rather than joining a stale build. + // A build still waiting for the library gives up when it arrives. generation++; - creating = null; + building = null; destroyViewer(); }, @@ -192,26 +220,6 @@ export function createMapillaryViewer({ source, render } = {}) { } }, - /** Build the viewer ahead of the first image. Safe to call repeatedly. */ - async prewarm(host) { - if (!host) return; - // An in-flight prewarm may give up (layer toggled mid-download): wait - // for it, then build if it did not. - if (prewarming) await prewarming; - if (viewer) return; - const run = (async () => { - try { - await ensureLibrary(); - if (!viewer) await ensureViewer(host); - } catch { - /* the real open reports errors */ - } - })(); - prewarming = run; - await run; - if (prewarming === run) prewarming = null; - }, - /** Show the whole image ('letterbox') or crop it to the frame ('fill'). */ setRenderMode(mode) { renderMode = mode === 'fill' ? 'fill' : 'letterbox'; @@ -224,8 +232,10 @@ export function createMapillaryViewer({ source, render } = {}) { }, onPose(listener) { - listeners.add(listener); - return () => listeners.delete(listener); + poseListener = listener; + return () => { + if (poseListener === listener) poseListener = null; + }; }, }; } diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index b8cbfeacc..288433d46 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -3,7 +3,6 @@ import { VectorTile } from '@mapbox/vector-tile'; import { tileLocalToLonLat } from '../../tileMath.js'; import { DECODE_SLICE_MS, - OVERVIEW_BINS_PER_AXIS, SIMPLIFY_TOLERANCE_UNITS, panoramaxTimeMs, } from './policy.js'; @@ -63,16 +62,14 @@ function dayMs(value) { * - `sequences` (z7–15): one line per sequence, its parts kept apart so no * segment is drawn across a gap, with its day and camera type; longest * first, so a cap on lines drawn drops the shortest. - * - `grid` (z0–6): picture-density cells, as overview points binned to at - * most OVERVIEW_BINS_PER_AXIS² a tile (a z0 tile holds ~70,000 cells). A - * cell has 360° and flat counts but no date. + * - `grid` (z0–6, density cells) is not read: nothing is drawn from orbit. * - `pictures` (z15) is left for `decodePictures`. * * In one go; `decodeCoverageTileSliced` gives the page a turn between * slices. * @param {Uint8Array} bytes * @param {{x: number, y: number, z: number}} address - * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array<{id: string, lon: number, lat: number, capturedAt: null, isPano: boolean, hasPano: boolean, hasFlat: boolean, weight: number}>}} + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>}} */ export function decodeCoverageTile(bytes, address) { const steps = coverageSteps(bytes, address); @@ -123,7 +120,7 @@ export async function decodeCoverageTileSliced( /** The decode, a step a feature (see `decodeCoverageTile`). */ function* coverageSteps(bytes, address) { - const result = { sequences: [], overview: [] }; + const result = { sequences: [] }; if (!bytes || !bytes.length) return result; const tile = new VectorTile(new PbfReader(bytes)); const { x, y, z } = address; @@ -174,56 +171,6 @@ function* coverageSteps(bytes, address) { result.sequences = measured.map(({ sequence }) => sequence); } - const grid = tile.layers.grid; - if (grid) { - const bins = new Map(); - const size = grid.extent / OVERVIEW_BINS_PER_AXIS; - for (let i = 0; i < grid.length; i++) { - const feature = grid.feature(i); - const props = feature.properties || {}; - const point = feature.loadGeometry()?.[0]?.[0]; - if (i % 64 === 63) yield; - if (!point) continue; - const pano = Number(props.nb_360_pictures) || 0; - const flat = Number(props.nb_flat_pictures) || 0; - const weight = Number(props.nb_pictures) || pano + flat; - if (weight <= 0) continue; - const key = `${Math.floor(point.x / size)}:${Math.floor(point.y / size)}`; - const bin = bins.get(key); - if (!bin) { - bins.set(key, { point, weight, pano, flat, top: weight }); - continue; - } - // The bin sits on its busiest cell and counts them all. - bin.weight += weight; - bin.pano += pano; - bin.flat += flat; - if (weight > bin.top) { - bin.top = weight; - bin.point = point; - } - } - for (const [key, bin] of bins) { - const [lon, lat] = tileLocalToLonLat( - bin.point.x, - bin.point.y, - grid.extent, - x, - y, - z, - ); - result.overview.push({ - id: `${z}/${x}/${y}/${key}`, - lon, - lat, - capturedAt: null, - isPano: bin.pano > bin.flat, - hasPano: bin.pano > 0, - hasFlat: bin.flat > 0, - weight: bin.weight, - }); - } - } return result; } diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index 7caa25a63..b4beb1b88 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -6,7 +6,6 @@ import { decodePictures, simplifyLine, } from './decode.js'; -import { OVERVIEW_BINS_PER_AXIS } from './policy.js'; import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs'; import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs'; @@ -42,8 +41,7 @@ test('sequence lines keep their parts apart, with their day and camera type', () }, ], }); - const { sequences, overview } = decodeCoverageTile(bytes, tile); - assert.equal(overview.length, 0); + const { sequences } = decodeCoverageTile(bytes, tile); assert.equal(sequences.length, 2); const pano = sequences.find((s) => s.id === 'pano-seq'); const flat = sequences.find((s) => s.id === 'flat-seq'); @@ -72,33 +70,13 @@ test('sequence lines keep their parts apart, with their day and camera type', () assert.equal(untyped.isPano, null, 'no camera type: neither'); }); -test('density cells bin to a bounded number of points that keep the counts and kinds', () => { +test('the density grid is not read: nothing is drawn from orbit', () => { const tile = { x: 0, y: 0, z: 0 }; - // Two cells in one bin (3.75° wide at z0) far enough apart to tell - // which it sits on, one far away, one empty. - const grid = [ - { lon: 0.3, lat: 48.85, pano: 0, flat: 5 }, - { lon: 3.4, lat: 48.85, pano: 30, flat: 0 }, - { lon: -73.9, lat: 40.7, pano: 1, flat: 0 }, - { lon: 10, lat: 10, pano: 0, flat: 0 }, - ]; - const { overview } = decodeCoverageTile( - encodePanoramaxTile(tile, { grid }), - tile, + const grid = [{ lon: 2.35, lat: 48.85, pano: 3, flat: 5 }]; + assert.deepEqual( + decodeCoverageTile(encodePanoramaxTile(tile, { grid }), tile), + { sequences: [] }, ); - assert.equal(overview.length, 2, 'Paris cells share a bin; empty cells go'); - const paris = overview.find((p) => p.lon > 0); - assert.equal(paris.weight, 35); - assert.equal(paris.hasPano, true); - assert.equal(paris.hasFlat, true); - assert.equal(paris.isPano, true, 'mostly 360°'); - assert.equal(paris.capturedAt, null, 'cells are undated'); - assert.ok( - near(paris.lon, 3.4, 0.2), - `sits on the busiest cell: ${paris.lon}`, - ); - const nyc = overview.find((p) => p.lon < 0); - assert.deepEqual([nyc.hasPano, nyc.hasFlat, nyc.weight], [true, false, 1]); }); test('lines come longest first, so a cap on lines drawn drops the shortest', () => { @@ -124,25 +102,6 @@ test('lines come longest first, so a cap on lines drawn drops the shortest', () ); }); -test('a dense grid tile never draws more than the bins allow', () => { - const tile = { x: 0, y: 0, z: 0 }; - const grid = []; - for (let i = 0; i < 300; i++) - for (let j = 0; j < 60; j++) - grid.push({ lon: -170 + i * 1.1, lat: -60 + j * 2 }); - const { overview } = decodeCoverageTile( - encodePanoramaxTile(tile, { grid }), - tile, - ); - assert.ok(overview.length <= OVERVIEW_BINS_PER_AXIS ** 2); - assert.ok(overview.length > 1000); - assert.equal( - overview.reduce((sum, p) => sum + p.weight, 0), - grid.length, - 'every picture is still counted', - ); -}); - /** A tile with many lines and a dense grid, for the sliced decode. */ function busyTile() { const { tile, lon, lat } = tileCentre(2.35, 48.85); @@ -212,7 +171,6 @@ test('an empty tile decodes to nothing', () => { for (const bytes of [new Uint8Array(0), null, undefined]) assert.deepEqual(decodeCoverageTile(bytes, { x: 0, y: 0, z: 0 }), { sequences: [], - overview: [], }); assert.deepEqual( decodePictures(new Uint8Array(0), { x: 0, y: 0, z: 15 }, 's'), diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index d3a3c7922..e855c9c72 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -1,12 +1,11 @@ -import { createLineCoverage, sequenceIdFromPick } from '../../lineCoverage.js'; +import { + createLineCoverage, + freshCoverage, + sequenceIdFromPick, +} from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { passesImageryFilter } from '../../filter.js'; -import { - lonToTileX, - latToTileY, - overviewZoomForHeight, - tileBounds, -} from '../../tileMath.js'; +import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js'; import { metresBetween } from '../../view.js'; import { createLru, createTileCache } from '../../tileCache.js'; import { decodeCoverageTileSliced, decodePictures } from './decode.js'; @@ -29,7 +28,6 @@ import { NEAREST_RADIUS_M, FILTERED_HINT, NO_COVERAGE_HINT, - OVERVIEW_HINT, PANORAMAX_CREDIT_HTML, PANORAMAX_LABEL, PANORAMAX_NAME, @@ -39,11 +37,10 @@ import { PICTURE_TILE_ZOOM, RATE_LIMITED_MESSAGE, UNREADABLE_TILE_MESSAGE, + ZOOM_IN_HINT, normalizeItem, } from './policy.js'; -export { PANORAMAX_PROVIDER_ID } from './policy.js'; - /** * A tile's bytes decoded by `decode`; a body that is not a vector tile * (an HTML error page, a cut-off answer) reads as such, not as a parser's @@ -59,15 +56,6 @@ async function readable(decode) { } } -/** - * A density dot's size: busier places draw bigger (2.5 px for one picture, - * 8 px from a few hundred thousand), so a city does not read like farmland. - */ -export function overviewPointSize(point) { - const weight = Math.max(1, Number(point?.weight) || 1); - return Math.min(8, 2.5 + Math.log10(weight)); -} - /** Pictures kept in memory by id (lookups and ‹ › fill it). */ const PHOTO_CACHE_SIZE = 2000; /** Sequences whose cone centre is remembered. */ @@ -78,44 +66,31 @@ const COVERAGE_TILE_CACHE_SIZE = 16; const PICTURE_LIST_CACHE_SIZE = 32; /** - * Sequence lines at z10–14 below 60 km; above, the density grid at z5 and - * coarser. + * Sequence lines at z10–14 below 60 km; above, nothing is drawn (the hint + * says to zoom in). */ export function panoramaxZoomFor(heightM) { - if (!Number.isFinite(heightM)) return null; - if (heightM <= COVERAGE_LINES_BELOW_M) { - const zoom = - heightM <= COVERAGE_Z14_BELOW_M - ? 14 - : heightM <= COVERAGE_Z13_BELOW_M - ? 13 - : heightM <= COVERAGE_Z12_BELOW_M - ? 12 - : heightM <= COVERAGE_Z11_BELOW_M - ? 11 - : 10; - return { zoom, kind: 'sequence' }; - } - return { - zoom: Math.min(5, overviewZoomForHeight(heightM) ?? 5), - kind: 'overview', - }; + if (!Number.isFinite(heightM) || heightM > COVERAGE_LINES_BELOW_M) + return null; + return heightM <= COVERAGE_Z14_BELOW_M + ? 14 + : heightM <= COVERAGE_Z13_BELOW_M + ? 13 + : heightM <= COVERAGE_Z12_BELOW_M + ? 12 + : heightM <= COVERAGE_Z11_BELOW_M + ? 11 + : 10; } /** - * The imagery filter for lines and grid cells. A cell (undated, with 360° - * and flat counts) stays while it holds pictures of the kind asked for. A - * line is dated by its sequence's first day, so a sequence still growing - * may hide under "since" while its newer pictures can be opened; a line - * with no camera type or no day is kept (a click finds out). + * The imagery filter for lines. A line is dated by its sequence's first + * day, so a sequence still growing may hide under "since" while its newer + * pictures can be opened; a line with no camera type or no day is kept (a + * click finds out). */ export function passesCoverage(record, filter) { if (!filter) return true; - if ('hasPano' in record) { - if (filter.pano === 'pano') return record.hasPano; - if (filter.pano === 'flat') return record.hasFlat; - return true; - } return passesImageryFilter(record, { pano: typeof record.isPano === 'boolean' ? filter.pano : 'all', sinceMs: record.capturedAt ? filter.sinceMs : null, @@ -159,8 +134,8 @@ const tileKey = ({ x, y, z }) => `${z}/${x}/${y}`; /** * Panoramax Street Level provider: the federated, keyless, open - * street-level commons. Coverage from its vector tiles (density points - * from orbit, sequence lines near the ground); a click on a line opens that + * street-level commons. Coverage from its vector tiles (sequence lines + * near the ground, nothing from orbit); a click on a line opens that * sequence's picture nearest the click, with cones along the sequence * around it and ‹ › to step through it. Flat pictures show as photos, 360° * ones on the shared WebGL panorama. @@ -190,30 +165,13 @@ export function createPanoramaxProvider({ context, services: context.services, viewer: null, - keyRequired: false, - keyRejected: false, - statusKnown: true, - coverage: { - zoom: null, - tiles: new Map(), - stale: new Map(), - staleTimer: null, - pending: new Map(), - lastError: null, - holdUntil: 0, - holdTimer: null, - debounceTimer: null, - removeCameraListener: null, - terrainReady: null, - hint: '', - kind: null, - }, + coverage: freshCoverage({ keyless: true }), sequence: { selectedId: null, images: [], - cache: new Map(), collection: null, loading: false, + error: null, abort: null, }, }; @@ -354,9 +312,8 @@ export function createPanoramaxProvider({ pickPrefix: PICK_PREFIX.sequence, renderKey: 'panoramax-coverage', tuning: COVERAGE_TUNING, - messages: { rateLimited: RATE_LIMITED_MESSAGE }, + messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: ZOOM_IN_HINT }, passes: passesCoverage, - overviewPointSize, }); const cones = createSequenceCones({ @@ -406,8 +363,7 @@ export function createPanoramaxProvider({ pickPrefix: PICK_PREFIX.image, spriteId: 'street-level:panoramax-cones', renderKey: 'panoramax-sequence', - onHighlight: (sequenceId, selected) => - coverage.recolorSequence(sequenceId, selected), + onHighlight: (sequenceId) => coverage.highlight(sequenceId), }); /** @@ -500,7 +456,7 @@ export function createPanoramaxProvider({ deactivate() { coverage.detach(); coverage.clear(); - coverage.resetErrors(); + coverage.unblock(); cones.clearSelection(); cones.setVisible(false); drawn = null; @@ -513,55 +469,30 @@ export function createPanoramaxProvider({ destroy(cesiumViewer) { coverage.detach(); coverage.clear(); - coverage.resetErrors(); + coverage.unblock(); cones.destroy(cesiumViewer); }, - refreshCoverage: () => coverage.refresh(), - /** The filter changed: redraw. Each draw reads it from the context. */ setFilter() { coverage.rebuild(); cones.rerender(); }, - setSurface() { - coverage.setSurface(); - cones.rerender(); - }, - coverageStats() { - const { kind } = state.coverage; - const lines = kind === 'sequence'; - const shown = coverage.sequenceCount(); - // Density dots are not sequences: they are not counted as such. - const count = lines ? shown : 0; - const loading = state.coverage.pending.size > 0; - const settled = !loading && !state.coverage.lastError; + const stats = coverage.stats(); let fallback = ''; - // Tiles that held something the filter hid all of. - const filteredOut = [...state.coverage.tiles.values()].some( - (tile) => tile.total > 0, - ); - if (kind === 'overview') - fallback = - shown > 0 || !settled - ? OVERVIEW_HINT - : filteredOut - ? FILTERED_HINT - : NO_COVERAGE_HINT; - else if (lines && settled && count === 0) + // Lines drawn for the camera (a zoom), all settled, none shown. + const settled = + state.coverage.zoom != null && !stats.loading && !stats.error; + if (settled && stats.count === 0) { + // Tiles that held lines the filter hid all of. + const filteredOut = [...state.coverage.tiles.values()].some( + (tile) => tile.total > 0, + ); fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT; - return { - count, - zoom: state.coverage.zoom, - kind: state.coverage.kind, - loading, - hint: state.coverage.hint || fallback, - error: state.coverage.lastError, - keyRequired: false, - keyRejected: false, - }; + } + return { ...stats, hint: stats.hint || fallback }; }, /** @@ -599,6 +530,7 @@ export function createPanoramaxProvider({ selectedId: state.sequence.selectedId, images: state.sequence.images.length, loading: state.sequence.loading, + error: state.sequence.error, }), /** diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index 55036cb32..670772c6a 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -15,7 +15,7 @@ export const PANORAMAX_LABEL = 'PANORAMAX'; */ export const PANORAMAX_API = 'https://api.panoramax.xyz/api'; /** The meta-catalogue's web viewer, where the caption link goes. */ -export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; +const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; /** Stable id prefixes for picked primitives; every one starts with `pnx:`. */ export const PICK_PREFIX = Object.freeze({ @@ -32,9 +32,7 @@ export const COLORS = Object.freeze({ /** * Sequence lines come from z7–15 tiles; z10–14 are drawn below 60 km, as * Mapillary's are (a dense tile over Paris: z10 ~2.1 MB and 8,600 - * sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up, the z0–5 `grid` layer's - * density cells (0.1°, ~10 km) draw as points: lower down they would be a - * handful of dots over a whole city, which reads as no coverage. + * sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up nothing is drawn. */ export const COVERAGE_Z14_BELOW_M = 1_800; export const COVERAGE_Z13_BELOW_M = 5_000; @@ -43,23 +41,21 @@ export const COVERAGE_Z11_BELOW_M = 30_000; export const COVERAGE_LINES_BELOW_M = 60_000; export const COVERAGE_TUNING = Object.freeze({ maxTiles: 9, - overviewMaxTiles: 6, // Each tile draws at most 1,600 lines, its longest first (as decoded, // `keepOrder`): a dense z11 // tile over Paris holds 3,800, and a lower cap left the city centre // looking emptier than its suburbs. (KartaView draws as many.) maxSequences: 14_400, keepOrder: true, - // Points drawn at most, over every tile (each becomes ~8 ground-line - // vertices, ~1.4 kB of geometry): the simplified Paris z10 tile alone - // holds 57,000, unsimplified 750,000, which hung the tab. + // Points drawn at most, over every tile, a ninth of it a tile (each + // becomes ~8 ground-line vertices, ~1.4 kB of geometry): the simplified + // Paris z10 tile alone holds 57,000, unsimplified 750,000, which hung + // the tab. maxPoints: 90_000, lineWidthPx: 2.5, - overviewPointPx: 4, moveDebounceMs: 320, viewRangeMinM: 2_500, viewRangePerHeight: 10, - viewNearM: 1_000, }); /** * Lines are simplified (Douglas–Peucker) to this many tile units of a @@ -74,12 +70,6 @@ export const SIMPLIFY_TOLERANCE_UNITS = 4; */ export const DECODE_SLICE_MS = 8; -/** - * Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells - * (1.9 MB), drawn as this many binned points. - */ -export const OVERVIEW_BINS_PER_AXIS = 96; - export const RATE_LIMITED_MESSAGE = msg( 'Panoramax is busy — coverage resumes on its own', ); @@ -90,8 +80,8 @@ export const UNREADABLE_TILE_MESSAGE = msg( 'Panoramax sent a map tile that could not be read', ); export const GONE_MESSAGE = msg('This Panoramax photo is no longer available'); -/** From high up the dots are where pictures are, not sequences to click. */ -export const OVERVIEW_HINT = msg('zoom in to see Panoramax coverage'); +/** Above the line ceiling nothing is drawn. */ +export const ZOOM_IN_HINT = msg('zoom in to see Panoramax coverage'); /** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */ export const PICTURE_TILE_ZOOM = 15; diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 4bee380ae..628805ce1 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createPanoramaxProvider, - overviewPointSize, panoramaxZoomFor, passesCoverage, pictureTilesAround, @@ -10,8 +9,8 @@ import { import { FILTERED_HINT, NO_COVERAGE_HINT, - OVERVIEW_HINT, PICK_PREFIX, + ZOOM_IN_HINT, } from './policy.js'; import { validateProviders } from '../../registry.js'; import { tileBounds } from '../../tileMath.js'; @@ -95,9 +94,6 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { services: {}, getFilter: () => filter, isActive: () => true, - groundCaster: null, - meshSampler: null, - getSurface: () => 'draped', notify() {}, actions: { openImage: async (id, options) => { @@ -152,19 +148,17 @@ test('Panoramax registers keyless and off by default, with both filters and its assert.doesNotThrow(() => validateProviders([definition])); }); -test('lines at z10–14 below 60 km, as Mapillary’s; density points from z5 down to z0 above', () => { - assert.deepEqual(panoramaxZoomFor(500), { zoom: 14, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(1_800), { zoom: 14, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(4_000), { zoom: 13, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(12_000), { zoom: 12, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(25_000), { zoom: 11, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(30_000), { zoom: 11, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 10, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(60_000), { zoom: 10, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(60_001), { zoom: 5, kind: 'overview' }); - assert.deepEqual(panoramaxZoomFor(100_000), { zoom: 5, kind: 'overview' }); - assert.deepEqual(panoramaxZoomFor(2_000_000), { zoom: 3, kind: 'overview' }); - assert.deepEqual(panoramaxZoomFor(20_000_000), { zoom: 0, kind: 'overview' }); +test('lines at z10–14 below 60 km, as Mapillary’s; nothing above', () => { + assert.equal(panoramaxZoomFor(500), 14); + assert.equal(panoramaxZoomFor(1_800), 14); + assert.equal(panoramaxZoomFor(4_000), 13); + assert.equal(panoramaxZoomFor(12_000), 12); + assert.equal(panoramaxZoomFor(25_000), 11); + assert.equal(panoramaxZoomFor(30_000), 11); + assert.equal(panoramaxZoomFor(30_001), 10); + assert.equal(panoramaxZoomFor(60_000), 10); + assert.equal(panoramaxZoomFor(60_001), null); + assert.equal(panoramaxZoomFor(20_000_000), null); assert.equal(panoramaxZoomFor(NaN), null); }); @@ -182,7 +176,7 @@ test('a line with no camera type or no day passes the filters: a click finds out ); }); -test('the imagery filter: lines by type and day, density cells by what they hold', () => { +test('the imagery filter: lines by type and day', () => { const old = Date.UTC(2020, 0, 1); const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) }; assert.equal( @@ -199,11 +193,7 @@ test('the imagery filter: lines by type and day, density cells by what they hold ), true, ); - const cell = { hasPano: false, hasFlat: true, capturedAt: null }; - assert.equal(passesCoverage(cell, { pano: 'pano', sinceMs: null }), false); - assert.equal(passesCoverage(cell, { pano: 'flat', sinceMs: null }), true); - assert.equal(passesCoverage(cell, since), true, 'cells are undated: kept'); - assert.equal(passesCoverage(cell, null), true); + assert.equal(passesCoverage({ isPano: false, capturedAt: old }, null), true); }); test('cones read the z15 tile around a picture, and its neighbours near an edge', () => { @@ -249,7 +239,7 @@ test('a click on a line opens that sequence’s picture nearest the click, not a ]; const { instance, actions, source } = setup({ items }); assert.equal( - instance.handlePick(`${PICK_PREFIX.sequence}clicked~1`, { + instance.handlePick(`${PICK_PREFIX.sequence}clicked`, { groundPoint: () => east(1), }), true, @@ -569,8 +559,13 @@ test('a broken neighbour z15 tile only thins the cones; an unreadable home tile await withCones(t, env2); await env2.instance.viewer.open('c-0'); await env2.instance.selectSequence('c'); - assert.ok(await until(() => env2.actions.errors.some(Boolean))); - assert.match(env2.actions.errors.filter(Boolean).at(-1), /could not be read/); + assert.ok(await until(() => env2.instance.sequenceStats().error)); + assert.match(env2.instance.sequenceStats().error, /could not be read/); + assert.deepEqual( + env2.actions.errors.filter(Boolean), + [], + 'a sequence error is the sequence’s, not the viewer’s', + ); }); test('cones re-centred into tiles already read do not read them again', async (t) => { @@ -629,16 +624,13 @@ test('the cones re-centre when › walks into another z15 tile', async (t) => { }); /** The provider drawing coverage on a stand-in viewer above one z14 tile. */ -function coverageSetup( - t, - { sequences = [], grid = [], filter, height = 900 } = {}, -) { +function coverageSetup(t, { sequences = [], filter, height = 900 } = {}) { const centre = tileCentre(ORIGIN.lon, ORIGIN.lat); let hold = null; const env = setup( { tiles: (tile) => { - const bytes = encodePanoramaxTile(tile, { sequences, grid }); + const bytes = encodePanoramaxTile(tile, { sequences }); return hold ? hold.then(() => bytes) : bytes; }, }, @@ -682,8 +674,8 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a }); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 2)); - assert.equal(env.instance.coverageStats().kind, 'sequence'); assert.equal(env.instance.coverageStats().keyRequired, false); + assert.equal(env.instance.coverageStats().error, null); env.instance.deactivate(); const filtered = coverageSetup(t, { @@ -697,6 +689,18 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a assert.ok(await until(() => filtered.instance.coverageStats().count === 1)); }); +test('the selected sequence’s line is drawn again over the coverage, and goes with it', async (t) => { + const env = coverageSetup(t, { sequences: [line('a'), line('b')] }); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 2)); + const ground = env.viewer.scene.groundPrimitives.items; + const drawn = ground.size; + await env.instance.selectSequence('a'); + assert.equal(ground.size, drawn + 1, 'one highlight over the lines'); + env.instance.clearSequence(); + assert.equal(ground.size, drawn); +}); + test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => { const env = coverageSetup(t); const release = env.holdTiles(); @@ -708,19 +712,16 @@ test('coverage hints: nothing while loading, “no pictures” once an empty vie assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); }); -test('from high up the density grid draws points, not counted as sequences, and the panel says to zoom in', async (t) => { - const env = coverageSetup(t, { - height: 200_000, - grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 2, flat: 3 }], - }); +test('from high up nothing is drawn or fetched, and the panel says to zoom in', async (t) => { + const env = coverageSetup(t, { height: 200_000, sequences: [line('a')] }); env.instance.activate(env.viewer); assert.ok( - await until(() => env.instance.coverageStats().kind === 'overview'), + await until(() => env.instance.coverageStats().hint === ZOOM_IN_HINT), ); - assert.ok(await until(() => !env.instance.coverageStats().loading)); - assert.equal(env.instance.coverageStats().zoom, 5); - assert.equal(env.instance.coverageStats().count, 0, 'dots are not sequences'); - assert.equal(env.instance.coverageStats().hint, OVERVIEW_HINT); + assert.equal(env.instance.coverageStats().count, 0); + assert.equal(env.instance.coverageStats().loading, false); + assert.deepEqual(env.source.calls.tiles, []); + assert.equal(env.viewer.scene.groundPrimitives.items.size, 0); }); test('switching off clears the cones and forgets a pending line click', async (t) => { @@ -747,41 +748,12 @@ test('lines in view that the filter hides all of say so, not that there is nothi sequences: [line('flat-only')], }); env.instance.activate(env.viewer); - assert.ok( - await until( - () => - !env.instance.coverageStats().loading && - env.instance.coverageStats().zoom !== null, - ), - ); assert.ok( await until(() => env.instance.coverageStats().hint === FILTERED_HINT), ); assert.equal(env.instance.coverageStats().count, 0); }); -test('from high up over empty sea the panel says there is nothing, not to zoom in', async (t) => { - const env = coverageSetup(t, { height: 200_000, grid: [] }); - env.instance.activate(env.viewer); - assert.ok( - await until(() => env.instance.coverageStats().kind === 'overview'), - ); - assert.ok( - await until(() => env.instance.coverageStats().hint === NO_COVERAGE_HINT), - ); -}); - -test('density dots grow with the pictures they stand for', () => { - assert.equal(overviewPointSize({ weight: 1 }), 2.5); - assert.equal(overviewPointSize({ weight: 100 }), 4.5); - assert.equal(overviewPointSize({ weight: 1e9 }), 8); - assert.equal(overviewPointSize({}), 2.5); - assert.ok( - overviewPointSize({ weight: 50_000 }) > - overviewPointSize({ weight: 50 }) + 2, - ); -}); - test('Esc in the docked viewer clears the selected sequence before the panel can collapse', async (t) => { const items = sequence('s', 3); const env = await mounted({ items, tiles: pictureTiles(items) }); @@ -890,42 +862,7 @@ test('cones re-centre on a picture that opens while they are still loading', asy ); }); -test('a filter that hides every density dot says so', async (t) => { - const env = coverageSetup(t, { - height: 200_000, - filter: { pano: 'pano', sinceMs: null }, - grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 5 }], - }); - env.instance.activate(env.viewer); - assert.ok( - await until(() => env.instance.coverageStats().hint === FILTERED_HINT), - ); -}); - -test('dots are drawn at their own sizes, and the busiest is the biggest', async (t) => { - const env = coverageSetup(t, { - height: 200_000, - grid: [ - { lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 1 }, - { lon: ORIGIN.lon + 3, lat: ORIGIN.lat, pano: 30_000, flat: 20_000 }, - ], - }); - env.instance.activate(env.viewer); - const sizes = () => { - const out = []; - for (const primitive of env.viewer.scene.primitives.items) - for (let i = 0; i < (primitive.length ?? 0); i++) - if (primitive.get(i).pixelSize) out.push(primitive.get(i).pixelSize); - return out.sort((a, b) => a - b); - }; - assert.ok(await until(() => sizes().length === 2)); - assert.deepEqual(sizes(), [ - overviewPointSize({ weight: 1 }), - overviewPointSize({ weight: 50_000 }), - ]); -}); - -test('lines keep the decoded (longest-first) order under a budget of points', async () => { +test('lines keep the decoded (longest-first) order under a cap on points', async () => { const { COVERAGE_TUNING } = await import('./policy.js'); assert.equal(COVERAGE_TUNING.keepOrder, true); assert.ok(COVERAGE_TUNING.maxPoints > 0); @@ -943,15 +880,16 @@ test('a coverage tile decoded a moment ago is not fetched again; switched off, t assert.ok(await until(() => env.instance.coverageStats().count === 1)); const z14 = asked.length; // Up a zoom band and back down: the z14 tile comes from memory. + // (Activating again refreshes at once, without the camera debounce.) env.viewer.view.height = 3_000; - env.instance.refreshCoverage(); - assert.ok(await until(() => env.instance.coverageStats().zoom === 13)); - assert.ok(await until(() => !env.instance.coverageStats().loading)); + env.instance.activate(env.viewer); + assert.ok(await until(() => asked.some((key) => key.startsWith('13/')))); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); const withZ13 = asked.length; env.viewer.view.height = 900; - env.instance.refreshCoverage(); - assert.ok(await until(() => env.instance.coverageStats().zoom === 14)); + env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); + await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(asked.length, withZ13, 'nothing fetched again'); assert.ok(z14 > 0); // Off and on: the memory went back, so the tile is fetched again. @@ -1240,7 +1178,7 @@ test('a busy or timed-out tile keeps its own message; only a body that is not a await withCones(t, env); await env.instance.viewer.open('s-0'); await env.instance.selectSequence('s'); - assert.ok(await until(() => env.actions.errors.some(Boolean))); - assert.equal(env.actions.errors.filter(Boolean).at(-1), failure.message); + assert.ok(await until(() => env.instance.sequenceStats().error)); + assert.equal(env.instance.sequenceStats().error, failure.message); } }); diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs index 3f8ae4789..9f5df8363 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -160,7 +160,7 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09 assert.equal(loads.length, 1, 'docked: still SD only'); const root = canvas.parentNode; root.closest = (selector) => - selector.includes('sl-viewer-wrap-expanded') ? {} : null; + selector.includes('sl-viewer-wrap:fullscreen') ? {} : null; // EXPAND resizes more than once while the panel settles. viewer.resize(); viewer.resize(); @@ -280,7 +280,7 @@ test('a picture whose SD fails falls back to the thumbnail; the original only fo // Expanded, the original is worth its megabytes. tried.length = 0; parts(host).root.closest = (selector) => - selector.includes('sl-viewer-wrap-expanded') ? {} : null; + selector.includes('sl-viewer-wrap:fullscreen') ? {} : null; await viewer.open('flat'); assert.deepEqual(tried, [ PHOTOS.flat.urls.sd, diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index ce7640137..5e51929a6 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,6 +1,6 @@ import { encodeIdSwitches } from '../../data/idSwitches.js'; -export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); +const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); export function filtersOf(provider) { return provider?.filters ?? IMAGERY_FILTERS; @@ -11,7 +11,7 @@ export function filtersOf(provider) { * * @typedef {object} StreetLevelProvider * @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key. - * @property {string} name Human name for captions and legends, e.g. 'Mapillary'. + * @property {string} name Human name for captions, e.g. 'Mapillary'. * @property {string} label Chip text and link label, e.g. 'MAPILLARY'. * @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless. * @property {string} pickPrefix Every primitive id the provider creates starts with it. @@ -19,54 +19,44 @@ export function filtersOf(provider) { * @property {{html: string}} credit On-globe attribution while the provider is active. * @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image. * @property {string} [requiresMapStack] Usable only on this map stack. - * @property {boolean} [defaultOn] Default true. + * @property {boolean} [defaultOn] Default true; the first that defaults on is lit. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). - * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). - * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. + * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' notes "(by upload date)" beside the SINCE value while the provider is lit. * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. - * @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it). + * @property {object} services Scene services: picking, input, render, sprites, scenePick (ground clicks need it). * @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up. * @property {() => boolean} isActive Layer enabled and this provider switched on. - * @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth. - * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. - * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. * @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise, openNearest: (point: {lon: number, lat: number}) => Promise, reportError: (message: string|null) => void}} actions + * `openNearest` opens this provider's image nearest a point the user picked + * (needs `nearestImage`); `reportError` shows a viewer or key error in the + * panel, or withdraws this provider's own (null). * * @typedef {object} ProviderInstance - * @property {() => Promise<{configured: boolean}>} status + * @property {(options?: {signal?: AbortSignal}) => Promise<{configured: boolean}>} status Whether a usable key is set; the core gives up on a hung answer through `signal`. * @property {(viewer: object) => void} init Create collections, hidden. * @property {(viewer: object) => void} activate Start drawing coverage for the camera. * @property {() => void} deactivate Detach, clear coverage and selection, hide. * @property {(viewer: object) => void} destroy - * @property {() => void} refreshCoverage * @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`). - * @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode. - * @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats + * @property {() => {count: number, loading: boolean, hint: string, error: string|null, keyRequired: boolean, keyRejected?: boolean}} coverageStats * @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground. * @property {(sequenceId: string) => Promise} [selectSequence] * @property {() => void} [clearSequence] - * @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats] - * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise} nearestImage An aborted lookup gives up (it may reject with an AbortError). + * @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats] + * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). * @property {ViewerAdapter} viewer * - * @typedef {object} GroundCaster - * @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise} prepare Fetch the heights around [lon, lat] points; true when all are known. - * @property {(lines: Array>, options?: {signal?: AbortSignal}) => Promise} prepareLines - * @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height. - * @property {(coords: Array<[number, number]>) => Array|null} castLine Densified [lon, lat, height, ...] or null. - * * @typedef {object} ViewerAdapter * @property {(host: HTMLElement) => Promise} mount Idempotent; may lazy-load a library. * @property {(imageId: string) => Promise} open Resolves once the image is on screen and a pose was emitted. * @property {() => void} close Drop the image, keep the instance warm. * @property {() => void} unmount Destroy the instance (host handed to another provider). * @property {() => void} resize - * @property {(host: HTMLElement) => Promise} [prewarm] * @property {(mode: 'letterbox'|'fill') => void} [setRenderMode] * @property {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide. * @property {(listener: (pose: StreetPose) => void) => () => void} onPose diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index e3710dd8e..c76c3d9f5 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -1,33 +1,31 @@ import * as Cesium from 'cesium'; -import { STREET_LEVEL_LAYER_ID } from './policy.js'; +import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js'; -/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */ -const HOVER_PICK_INTERVAL_MS = 120; - -/** One click/hover handler for every provider's coverage and image cones. */ +/** One click handler for every provider's lines and cones, and the marker. */ export function createSelection({ state, parts }) { const { picking, input } = state.services; - function ownsPick(pickedId) { - return parts.router.ownsPick(pickedId); - } - + /** The id carries a provider's prefix: that provider handles the pick. */ function onClick(click) { const viewer = state.viewer; if (!viewer || !state.enabled) return; if (input?.isPointerFree && !input.isPointerFree()) return; - const picked = viewer.scene.pick(click.position); + const picked = viewer.scene.pick( + click.position, + CLICK_PICK_WINDOW_PX, + CLICK_PICK_WINDOW_PX, + ); const id = picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id; const route = parts.router.resolve(id); // Not ours: the ground handler decides whether it is the map itself. - if (!route?.instance) { + if (!route) { parts.openAtGround?.(click.position, picked); return; } - const entry = state.providers.get(route.providerId); - if (!entry?.on) return; + // The position marker is the core's own: nothing to select. + if (!route.instance) return; route.instance.handlePick(route.id, { groundPoint: () => groundPointAt(viewer, click.position), }); @@ -68,106 +66,6 @@ export function createSelection({ state, parts }) { parts.clearSequences(); } - let hoverQueued = false; - let hoverCursor = false; - let hoverPosition = null; - let hoverLastPickAt = -Infinity; - let hoverTimer = null; - let pointerButtons = 0; - let removeHoverWatchers = null; - - function setHoverCursor(canvas, hit) { - if (hit && !hoverCursor) { - canvas.style.cursor = 'pointer'; - hoverCursor = true; - } else if (!hit && hoverCursor) { - canvas.style.cursor = ''; - hoverCursor = false; - } - } - - function pickHover() { - hoverQueued = false; - const viewer = state.viewer; - // Switched off (or torn down) since the frame was queued. - if (!viewer || !state.enabled || !state.clickHandler) return; - // A drag in progress: the pointer is the camera's. - if (pointerButtons !== 0) return; - const canvas = viewer.scene?.canvas; - if (!canvas || viewer.isDestroyed?.()) return; - hoverLastPickAt = Date.now(); - let hit = false; - try { - const picked = viewer.scene.pick(hoverPosition); - const id = picking?.resolvePickId - ? picking.resolvePickId(picked) - : picked?.id; - hit = ownsPick(id); - } catch { - hit = false; - } - setHoverCursor(canvas, hit); - } - - /** - * Pointer cursor over anything this layer owns. Throttled; a move inside - * the interval is picked when it ends, so the cursor never sticks. - */ - function onMove(movement) { - const viewer = state.viewer; - if (!viewer || !state.enabled) return; - hoverPosition = movement.endPosition; - queueHoverPick(); - } - - /** Pick `hoverPosition` on a coming frame, within the throttle. */ - function queueHoverPick() { - if (hoverQueued) return; - hoverQueued = true; - const wait = hoverLastPickAt + HOVER_PICK_INTERVAL_MS - Date.now(); - if (wait <= 0) { - requestAnimationFrame(pickHover); - return; - } - hoverTimer = setTimeout(() => { - hoverTimer = null; - requestAnimationFrame(pickHover); - }, wait); - } - - /** - * Track held buttons (a drag picks nothing) and re-pick when the camera - * rests. Hover keeps running while it moves: a tracking camera never stops. - */ - function watchHover(viewer) { - const canvas = viewer.scene.canvas; - const camera = viewer.camera; - const onButtons = (event) => { - pointerButtons = event.buttons ?? 0; - }; - for (const type of ['pointerdown', 'pointermove', 'pointerup']) - canvas.addEventListener?.(type, onButtons); - const removeEnd = camera?.moveEnd?.addEventListener(() => { - if (hoverPosition) queueHoverPick(); - }); - removeHoverWatchers = () => { - for (const type of ['pointerdown', 'pointermove', 'pointerup']) - canvas.removeEventListener?.(type, onButtons); - removeEnd?.(); - }; - } - - function unwatchHover() { - removeHoverWatchers?.(); - removeHoverWatchers = null; - clearTimeout(hoverTimer); - hoverTimer = null; - hoverQueued = false; - hoverPosition = null; - hoverLastPickAt = -Infinity; - pointerButtons = 0; - } - function install(viewer) { if (state.clickHandler) return; state.clickHandler = new Cesium.ScreenSpaceEventHandler( @@ -177,29 +75,18 @@ export function createSelection({ state, parts }) { onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK, ); - state.clickHandler.setInputAction( - onMove, - Cesium.ScreenSpaceEventType.MOUSE_MOVE, - ); - watchHover(viewer); document.addEventListener('keydown', onKeyDown); - picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick); + picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick); } function uninstall() { // Nothing to undo for a layer that was never enabled. if (!state.clickHandler) return; - unwatchHover(); - if (hoverCursor && state.viewer?.scene?.canvas) { - state.viewer.scene.canvas.style.cursor = ''; - hoverCursor = false; - } - if (state.clickHandler && !state.clickHandler.isDestroyed()) - state.clickHandler.destroy(); + if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy(); state.clickHandler = null; document.removeEventListener('keydown', onKeyDown); picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID); } - return { install, uninstall, ownsPick }; + return { install, uninstall, ownsPick: parts.router.ownsPick }; } diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index 72e9e6e0c..9fb0362a1 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -1,26 +1,12 @@ import assert from 'node:assert/strict'; -import test, { mock } from 'node:test'; +import test from 'node:test'; import * as Cesium from 'cesium'; import { createSelection } from './selection.js'; +import { createPickRouter } from './pickRouter.js'; -/** - * The selection installed on a stand-in viewer. Animation frames queue until - * `frame()` runs them; timers and Date.now() are mocked so a burst of moves - * has a known timeline. - */ -function harness({ - selected = false, - router = null, - providers = new Map(), - services = {}, -} = {}) { - const saved = { - document: globalThis.document, - requestAnimationFrame: globalThis.requestAnimationFrame, - }; - mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const frames = []; - globalThis.requestAnimationFrame = (task) => frames.push(task); +/** The selection installed on a stand-in viewer, with what its clicks reach. */ +function harness({ selected = false, services = {}, providers = [] } = {}) { + const saved = globalThis.document; globalThis.document = new EventTarget(); // The document's keydown listeners, so a test can hand them any target. const keyListeners = new Set(); @@ -38,62 +24,64 @@ function harness({ if (type === 'keydown') keyListeners.delete(listener); removeListener(type, listener, options); }; - const sequences = { selected, cleared: 0 }; + const sequences = { selected, cleared: 0, selects: [] }; + const opened = []; const groundClicks = []; - const picks = []; - /** What the pointer is over: a line this layer owns, or nothing. */ + /** What the pointer is over: a pick id, or nothing. */ const scene = { under: null }; const canvas = Object.assign(new EventTarget(), { - style: {}, // Keep Cesium's handler on the canvas; there is no real document here. disableRootEvents: true, onwheel: null, }); - const camera = { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() }; const viewer = { - scene: { - canvas, - pick(position) { - picks.push(position); - return scene.under && { id: scene.under }; - }, - }, - camera, - isDestroyed: () => false, + scene: { canvas, pick: () => scene.under && { id: scene.under } }, }; const state = { viewer, enabled: true, clickHandler: null, - providers, services: { picking: null, input: null, ...services }, + providers: new Map([ + [ + 'mapillary', + { + def: { id: 'mapillary', pickPrefix: 'mly:' }, + instance: { + handlePick(id) { + if (id.startsWith('mly:seq:')) + sequences.selects.push(id.slice(8)); + else if (id.startsWith('mly:img:')) opened.push(id.slice(8)); + return true; + }, + }, + }, + ], + ...providers, + ]), }; - const selection = createSelection({ - state, - parts: { - router: router || { - ownsPick: (id) => id === 'mly:seq:1', - resolve: () => null, - }, - hasSelectedSequence: () => sequences.selected, - openAtGround: (position, picked) => - groundClicks.push({ position, picked }), - clearSequences() { - sequences.cleared++; - sequences.selected = false; - }, + const parts = { + router: createPickRouter(() => state.providers.values(), { + positionId: 'sl:pos', + }), + hasSelectedSequence: () => sequences.selected, + openAtGround: (position, picked) => groundClicks.push({ position, picked }), + clearSequences() { + sequences.cleared++; + sequences.selected = false; }, - }); + }; + const selection = createSelection({ state, parts }); selection.install(viewer); - const onMove = state.clickHandler.getInputAction( - Cesium.ScreenSpaceEventType.MOUSE_MOVE, - ); - const onClick = state.clickHandler.getInputAction( - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ); + const action = (type) => + state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]); return { state, sequences, + opened, + groundClicks, + scene, + click: (x = 10) => action('LEFT_CLICK')({ position: { x, y: 10 } }), /** A keydown reaching the document from `target`; the event as handled. */ key(key, target, { defaultPrevented = false } = {}) { const event = { @@ -107,114 +95,32 @@ function harness({ for (const listener of [...keyListeners]) listener(event); return event; }, - scene, - canvas, - camera, - picks, - move: (x) => onMove({ endPosition: { x, y: 10 } }), - click: (x) => onClick({ position: { x, y: 10 } }), - groundClicks, - frame() { - for (const task of frames.splice(0)) task(); - }, - /** Let `ms` pass, running the frames that come due on the way. */ - wait(ms) { - this.frame(); - mock.timers.tick(ms); - this.frame(); - }, - buttons(type, buttons) { - const event = new Event(type); - Object.defineProperty(event, 'buttons', { value: buttons }); - canvas.dispatchEvent(event); - }, restore() { selection.uninstall(); - mock.timers.reset(); - Object.assign(globalThis, saved); + globalThis.document = saved; }, }; } -test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => { +test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => { const h = harness(); try { - // One second of a pointer moving at 60 Hz. - for (let i = 0; i < 60; i++) { - h.move(i); - h.wait(16); + h.scene.under = 'mly:seq:abc'; + h.click(); + assert.deepEqual(h.sequences.selects, ['abc']); + h.scene.under = 'mly:img:42'; + h.click(); + assert.deepEqual(h.opened, ['42']); + for (const under of [null, 'cctv:7', 'sl:pos']) { + h.scene.under = under; + h.click(); } - assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`); - assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`); - h.wait(200); - assert.deepEqual(h.picks.at(-1), { x: 59, y: 10 }, 'the resting position'); - const settled = h.picks.length; - h.wait(1000); - assert.equal(h.picks.length, settled, 'a still pointer costs nothing'); - } finally { - h.restore(); - } -}); - -test('hover does not pick while a mouse button is held', () => { - const h = harness(); - try { - h.buttons('pointerdown', 1); - h.move(1); - h.wait(200); - assert.equal(h.picks.length, 0, 'a drag in progress'); - h.buttons('pointerup', 0); - h.move(2); - h.wait(200); - assert.equal(h.picks.length, 1); - } finally { - h.restore(); - } -}); - -test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => { - const h = harness(); - try { - // An orbiting or tracking camera raises moveStart and never moveEnd. - h.camera.moveStart.raiseEvent(); - for (let i = 0; i < 60; i++) { - h.move(i); - h.wait(16); - } - assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`); - assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`); - } finally { - h.restore(); - } -}); - -test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => { - const h = harness(); - try { - h.scene.under = 'mly:seq:1'; - h.move(5); - h.wait(200); - assert.equal(h.canvas.style.cursor, 'pointer', 'over a line'); - // The camera moves the line out from under a still pointer. - h.camera.moveStart.raiseEvent(); - h.scene.under = null; - h.camera.moveEnd.raiseEvent(); - h.wait(200); - assert.equal(h.picks.length, 2, 'one pick when the camera rests'); - assert.deepEqual(h.picks.at(-1), { x: 5, y: 10 }); - assert.equal(h.canvas.style.cursor, '', 'the pointer cursor is gone'); - } finally { - h.restore(); - } -}); - -test('a hover frame queued before the layer went off does not pick', () => { - const h = harness(); - try { - h.move(1); - h.state.enabled = false; - h.wait(200); - assert.equal(h.picks.length, 0); + assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]); + // The marker is ours; nothing and another layer's pick go to the ground. + assert.deepEqual( + h.groundClicks.map(({ picked }) => picked?.id), + [undefined, 'cctv:7'], + ); } finally { h.restore(); } @@ -274,6 +180,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () => } }); +test('a click on the position marker neither selects nor opens at the ground', () => { + const h = harness(); + try { + h.scene.under = 'sl:pos'; + h.click(); + assert.deepEqual(h.groundClicks, []); + assert.deepEqual(h.opened, []); + assert.deepEqual(h.sequences.selects, []); + } finally { + h.restore(); + } +}); + test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => { const h = harness(); try { @@ -294,16 +213,19 @@ test('a click on nothing of ours goes to the ground handler with the pick, which test('a click on a provider line hands it the ground under the click, measured only if asked', () => { const handled = []; - const instance = { - handlePick: (id, click) => handled.push({ id, click }), - }; const groundPicks = []; const h = harness({ - router: { - ownsPick: () => true, - resolve: (id) => ({ providerId: 'kartaview', instance, id }), - }, - providers: new Map([['kartaview', { on: true }]]), + providers: [ + [ + 'kartaview', + { + def: { id: 'kartaview', pickPrefix: 'kv:' }, + instance: { + handlePick: (id, click) => handled.push({ id, click }), + }, + }, + ], + ], services: { scenePick: { pickGroundPosition: (viewer, position) => { diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index 8eb79b29f..b6c068d49 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -1,16 +1,12 @@ import * as Cesium from 'cesium'; import { imageConeGlyph } from './glyphs.js'; import { passesImageryFilter } from './filter.js'; -import { refineHeights } from './groundCast.js'; import { createHorizonCull, metresBetween } from './view.js'; -import { meshCellKey } from './meshSampler.js'; - -/** How many recently viewed sequences keep their image list in memory. */ -const SEQUENCE_CACHE_SIZE = 40; +import { msg } from '../../i18n/index.js'; /** Cones closer than this to the previous kept one are dropped. */ -export const IMAGE_CONE_MIN_SPACING_M = 3; -export const IMAGE_CONE_SIZE_PX = 26; +const IMAGE_CONE_MIN_SPACING_M = 3; +const IMAGE_CONE_SIZE_PX = 26; /** * @typedef {object} ConeImage One image of a sequence, as the cones draw it. @@ -36,8 +32,10 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { } /** - * Image cones for one selected sequence, any provider's. A cone's pick id is - * `pickPrefix` + the image id; 360° images draw as rings. + * Image cones for one selected sequence, any provider's, clamped to the + * ground. A cone's pick id is `pickPrefix` + the image id; 360° images draw + * as rings. Why a sequence could not load stays in `state.sequence.error` + * until it goes. * * @param {object} options * @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`. @@ -47,8 +45,8 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { * @param {string} options.pickPrefix Pick-id prefix of the cones. * @param {string} options.spriteId Sprite-order id of the cone collection. * @param {string} options.renderKey Render-governor reason. - * @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight] - * Highlight (or stop highlighting) the sequence's coverage line. + * @param {(sequenceId: string|null) => void} [options.onHighlight] + * Highlight the sequence's coverage line (null: none). * @param {(image: ConeImage, filter: object) => boolean} [options.passes] * Whether the imagery filter keeps a cone. */ @@ -72,14 +70,13 @@ export function createSequenceCones({ state.context.notify(); } - const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); - /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ - let drawn = nothingDrawn(); + /** Drawn cone billboards. */ + let cones = []; // The cones skip the depth test: hide the ones behind the globe. const horizon = createHorizonCull({ getViewer: () => state.viewer, - items: () => drawn.cones.map((cone) => cone.billboard), + items: () => cones, onChange: requestRender, }); @@ -95,58 +92,15 @@ export function createSequenceCones({ function clearCones() { state.sequence.collection?.removeAll(); state.sequence.images = []; - drawn = nothingDrawn(); + cones = []; horizon.stop(); } - /** Where a cone stands: at `height`, or clamped to the ground when null. */ - function placement(image, height) { - return { - position: Cesium.Cartesian3.fromDegrees( - image.lon, - image.lat, - height ?? 0, - ), - heightReference: - height === null - ? Cesium.HeightReference.CLAMP_TO_GROUND - : Cesium.HeightReference.NONE, - }; - } - - /** - * Terrain-mode cone heights on the street, not on roofs: bare earth refined - * by mesh samples. Null when draped or while terrain heights are fetched. - */ - function coneHeights(images, { request = true } = {}) { - const ground = state.context.groundCaster; - if (state.context.getSurface?.() !== 'terrain' || !ground) return null; - const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); - if (dems.includes(null)) { - ground - .prepare(images.map((image) => [image.lon, image.lat])) - .then((ready) => { - if (ready && state.sequence.images === images) renderCones(images); - }); - return null; - } - const sampler = state.context.meshSampler; - if (request) - sampler?.request(images.map((image) => [image.lon, image.lat])); - return refineHeights( - images.map((image, i) => ({ - dem: dems[i], - mesh: sampler?.meshAt(image.lon, image.lat), - })), - ); - } - function renderCones(images) { const collection = state.sequence.collection; if (!collection) return; collection.removeAll(); - drawn = { ...nothingDrawn(), images }; - const heights = coneHeights(images); + cones = []; // One colour per source; 360° images keep the ring shape. const cone = imageConeGlyph({ size: 32, color: colors.coverage }); const ring = imageConeGlyph({ @@ -156,12 +110,12 @@ export function createSequenceCones({ }); // Resolved now, so a "since N days" window keeps up with the clock. const filter = state.context.getFilter(); - images.forEach((image, index) => { - if (!passes(image, filter)) return; - const height = heights?.[index] ?? null; + for (const image of images) { + if (!passes(image, filter)) continue; const billboard = collection.add({ id: `${pickPrefix}${image.id}`, - ...placement(image, height), + position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, image: image.isPano ? ring : cone, imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`, width: IMAGE_CONE_SIZE_PX, @@ -172,52 +126,12 @@ export function createSequenceCones({ verticalOrigin: Cesium.VerticalOrigin.CENTER, scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), }); - drawn.cones.push({ index, billboard, height }); - drawn.cells.add(meshCellKey(image.lon, image.lat)); - }); + cones.push(billboard); + } horizon.update(); requestRender(); } - /** - * Re-place cones whose height new mesh samples changed. A sample can move - * a neighbour too, so every height is recomputed but only moved cones touched. - */ - function onMeshSampled(batch) { - if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) - return; - if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) - return; - const { images, cones } = drawn; - // Already asked for when drawn; a missing terrain cell redraws them all. - const heights = coneHeights(images, { request: false }); - if (!heights) return; - const moved = []; - for (const cone of cones) { - const height = heights[cone.index] ?? null; - if (height === cone.height) continue; - cone.height = height; - const { position, heightReference } = placement( - images[cone.index], - height, - ); - cone.billboard.heightReference = heightReference; - cone.billboard.position = position; - moved.push(cone.billboard); - } - if (!moved.length) return; - horizon.update(moved); - requestRender(); - } - - function remember(sequenceId, images) { - const { cache } = state.sequence; - cache.delete(sequenceId); - cache.set(sequenceId, images); - while (cache.size > SEQUENCE_CACHE_SIZE) - cache.delete(cache.keys().next().value); - } - /** * Select a sequence: highlight its line and load its image cones. `force` * reloads a selected one (a provider that loads part of a sequence moved on). @@ -231,21 +145,9 @@ export function createSequenceCones({ (state.sequence.images.length || state.sequence.abort) ) return; - if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) - onHighlight(state.sequence.selectedId, false); state.sequence.abort?.abort(); state.sequence.selectedId = sequenceId; - onHighlight(sequenceId, true); - const cached = force ? null : state.sequence.cache.get(sequenceId); - if (cached) { - state.sequence.abort = null; - state.sequence.loading = false; - state.sequence.images = cached; - renderCones(cached); - state.context.actions.reportError(null); - notify(); - return; - } + onHighlight(sequenceId); // The previous sequence's cones must not stay clickable under the new // highlight while this one loads, nor after its load fails. clearCones(); @@ -253,24 +155,24 @@ export function createSequenceCones({ const controller = new AbortController(); state.sequence.abort = controller; state.sequence.loading = true; + // An earlier sequence's error does not belong to this one. + state.sequence.error = null; notify(); try { const images = await loadImages(sequenceId, { signal: controller.signal, }); if (controller.signal.aborted) return; - remember(sequenceId, images); state.sequence.images = images; renderCones(images); - state.context.actions.reportError(null); + state.sequence.error = null; } catch (error) { if (!controller.signal.aborted) { // Nothing to show: drop the highlight, so a click asks again. - onHighlight(sequenceId, false); + onHighlight(null); state.sequence.selectedId = null; - state.context.actions.reportError( - error?.message || 'Sequence images unavailable', - ); + state.sequence.error = + error?.message || msg('Sequence images unavailable'); } } finally { if (state.sequence.abort === controller) { @@ -284,13 +186,12 @@ export function createSequenceCones({ function clearSelection() { state.sequence.abort?.abort(); state.sequence.abort = null; - if (state.sequence.selectedId) - onHighlight(state.sequence.selectedId, false); + if (state.sequence.selectedId) onHighlight(null); state.sequence.selectedId = null; state.sequence.loading = false; clearCones(); // An error about the sequence goes with it. - state.context.actions.reportError(null); + state.sequence.error = null; requestRender(); notify(); } @@ -300,9 +201,6 @@ export function createSequenceCones({ if (state.sequence.images.length) renderCones(state.sequence.images); } - // New mesh samples may move the cones onto (or off) the road surface. - const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); - function setVisible(visible) { if (state.sequence.collection) state.sequence.collection.show = visible; requestRender(); @@ -311,8 +209,7 @@ export function createSequenceCones({ function destroy(viewer) { state.sequence.abort?.abort(); horizon.stop(); - drawn = nothingDrawn(); - stopMeshListener?.(); + cones = []; const collection = state.sequence.collection; if (collection) { sprites?.unregisterSpriteCollection?.(spriteId, collection); diff --git a/src/layers/streetLevel/sequenceCones.test.mjs b/src/layers/streetLevel/sequenceCones.test.mjs new file mode 100644 index 000000000..0e301cffc --- /dev/null +++ b/src/layers/streetLevel/sequenceCones.test.mjs @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { resolveFilter } from './filter.js'; +import { createSequenceCones, thinImages } from './sequenceCones.js'; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** A billboard collection that remembers the cones on the globe. */ +function cones() { + const items = []; + return { + items, + show: true, + add(options) { + items.push(options); + return options; + }, + removeAll() { + items.length = 0; + }, + }; +} + +/** A source whose sequence lookups the test answers, one call at a time. */ +function deferredSource() { + const calls = []; + return { + calls, + getSequenceImages(sequenceId, { signal } = {}) { + return new Promise((resolve, reject) => { + calls.push({ sequenceId, signal, resolve, reject }); + }); + }, + /** The latest lookup for a sequence. */ + last(sequenceId) { + return calls.filter((call) => call.sequenceId === sequenceId).at(-1); + }, + }; +} + +/** Graph image records for a sequence, a few metres apart. */ +function records(sequenceId, count = 2) { + return Array.from({ length: count }, (_, i) => ({ + id: `${sequenceId}-${i}`, + geometry: { coordinates: [-121.49 + i * 0.001, 38.58] }, + compass_angle: 90, + captured_at: 1_700_000_000_000 + i, + })); +} + +function setup() { + const source = deferredSource(); + /** Every highlight asked of the coverage: a sequence id, or null for none. */ + const highlights = []; + const state = { + services: {}, + viewer: {}, + filter: { pano: 'all', sinceDays: 0 }, + context: { + getFilter: () => resolveFilter(state.filter), + notify() {}, + }, + sequence: { + selectedId: null, + images: [], + collection: cones(), + loading: false, + error: null, + abort: null, + }, + }; + const sequences = createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const list = await source.getSequenceImages(sequenceId, { signal }); + return thinImages( + list.map((record) => ({ + id: String(record.id), + lon: record.geometry.coordinates[0], + lat: record.geometry.coordinates[1], + compassAngle: Number(record.compass_angle) || 0, + capturedAt: Number(record.captured_at) || 0, + isPano: record.is_pano === true, + })), + ); + }, + colors: { coverage: '#05cb63' }, + pickPrefix: 'mly:img:', + spriteId: 'street-level:test-cones', + renderKey: 'test-sequence', + onHighlight: (id) => highlights.push(id), + }); + const drawn = () => + state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1)); + return { state, source, sequences, highlights, drawn }; +} + +test('a failed sequence load leaves no stale cones and can be retried', async () => { + const { state, source, sequences, highlights, drawn } = setup(); + sequences.select('A'); + source.last('A').resolve(records('A')); + await settle(); + assert.deepEqual(drawn(), ['A-0', 'A-1']); + + sequences.select('B'); + assert.deepEqual(drawn(), [], "A's cones go while B loads"); + source.last('B').reject(new Error('Sequence images unavailable')); + await settle(); + assert.deepEqual(drawn(), [], 'nothing of A is left to click'); + assert.equal(state.sequence.loading, false); + assert.equal(state.sequence.selectedId, null, 'B is not left highlighted'); + assert.equal(highlights.at(-1), null); + assert.equal(state.sequence.error, 'Sequence images unavailable'); + + // Clicking B again asks again, without the old error. + sequences.select('B'); + assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2); + assert.equal(state.sequence.error, null, 'not shown while B loads again'); + source.last('B').resolve(records('B')); + await settle(); + assert.deepEqual(drawn(), ['B-0', 'B-1']); + assert.equal(state.sequence.selectedId, 'B'); + assert.equal(state.sequence.error, null, 'cleared once B loads'); +}); + +test('a late answer for a superseded sequence draws nothing', async () => { + const { state, source, sequences, drawn } = setup(); + sequences.select('A'); + sequences.select('B'); + assert.equal(source.last('A').signal.aborted, true, 'A was cancelled'); + source.last('B').resolve(records('B')); + await settle(); + source.last('A').resolve(records('A')); + await settle(); + assert.deepEqual(drawn(), ['B-0', 'B-1']); + assert.equal(state.sequence.selectedId, 'B'); + assert.equal(state.sequence.loading, false); +}); + +test('a second click on a loading sequence does not start another lookup', async () => { + const { source, sequences } = setup(); + sequences.select('A'); + sequences.select('A'); + assert.equal(source.calls.length, 1); + assert.equal(source.calls[0].signal.aborted, false); +}); + +test('clearSelection cancels the lookup and drops the highlight', async () => { + const { state, source, sequences, highlights, drawn } = setup(); + sequences.select('A'); + sequences.clearSelection(); + assert.equal(source.last('A').signal.aborted, true); + assert.deepEqual(highlights, ['A', null]); + assert.equal(state.sequence.selectedId, null); + assert.equal(state.sequence.loading, false); + assert.equal(state.sequence.error, null, 'an error about it goes too'); + source.last('A').resolve(records('A')); + await settle(); + assert.deepEqual(drawn(), [], 'the late answer draws nothing'); +}); + +test('cones are clamped to the ground and turned to their compass angle', async () => { + const { state, source, sequences } = setup(); + sequences.select('A'); + source.last('A').resolve(records('A', 2)); + await settle(); + const items = state.sequence.collection.items; + assert.equal(items.length, 2); + for (const cone of items) { + assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND); + assert.equal(cone.rotation, -Cesium.Math.toRadians(90)); + } +}); + +test('cones behind the globe are hidden, and show again from the other side', async () => { + const { state, source, sequences } = setup(); + const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; + const preRender = new Set(); + state.viewer = { + camera: { + get positionWC() { + return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); + }, + }, + scene: { + preRender: { + addEventListener(listener) { + preRender.add(listener); + return () => preRender.delete(listener); + }, + }, + }, + }; + sequences.select('A'); + // One image under the camera, one near its antipode. + source.last('A').resolve([ + ...records('A', 1), + { + id: 'A-far', + geometry: { coordinates: [58.51, -38.58] }, + captured_at: 1_800_000_000_000, + }, + ]); + await settle(); + const [near, far] = state.sequence.collection.items; + assert.equal(near.show, true, 'the near side stays visible'); + assert.equal(far.show, false, 'the far side does not show through'); + // Fly round to the other hemisphere: the two swap. + view.lon = 58.51; + view.lat = -38.58; + for (const listener of [...preRender]) listener(); + assert.equal(near.show, false); + assert.equal(far.show, true); + sequences.clearSelection(); + assert.equal(preRender.size, 0, 'the cull listener is gone'); +}); diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index a7bf85e37..f9e5e4c88 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -2,12 +2,11 @@ import { FILTER_DEFAULT } from './policy.js'; /** * The per-image fields of `state.street` with no image open; closing resets - * exactly these (host, render mode and follow availability outlive an image). + * exactly these (host and render mode outlive an image). */ export function freshStreet() { return { open: false, - follow: false, providerId: null, providerName: null, providerLabel: null, @@ -39,19 +38,15 @@ export function createState({ services }) { services, viewer: null, enabled: false, - initialized: false, - destroyed: false, listeners: new Set(), notify: null, /** Shared imagery filter, in the stored (relative-days) form. */ filter: { ...FILTER_DEFAULT }, - /** @type {Map} */ + /** @type {Map} */ providers: new Map(), street: { host: null, - /** Whether the active map stack allows following (Google 3D only). */ - followAvailable: false, /** 'letterbox' shows the whole image; 'fill' crops it to the frame. */ renderMode: 'letterbox', ...freshStreet(), @@ -60,9 +55,6 @@ export function createState({ services }) { /** Whether the camera is low enough for a ground click to open an image. */ groundClickReady: false, - /** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */ - surface: 'draped', - marker: { collection: null, billboard: null, look: null }, clickHandler: null, }; diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index 52e7b6aea..169d14e28 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -2,30 +2,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { tilesForBbox } from './tileMath.js'; -test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => { - // coverage uses the actual view bbox above zoom 1; polar views can wrap. - const bbox = [-10, 60, -20, 85]; - const result = tilesForBbox(bbox, 3); - const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); +const keysOf = (tiles) => tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + +test('a polar view across the date line ranks 24 distinct tiles', () => { + // The halves of [-10, 60, -20, 85] share a column at z3. + const keys = keysOf(tilesForBbox([-10, 60, -20, 85], 3)); assert.equal(keys.length, 24); assert.equal(new Set(keys).size, 24); - assert.equal(result.total, 24); - assert.equal(result.truncated, false); - const overview = tilesForBbox(bbox, 3, { limit: 16 }); - assert.equal(overview.total, 24); - assert.equal(overview.tiles.length, 16); - assert.equal( - new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size, - 16, - ); - assert.equal(overview.truncated, true); }); -test('a limit that fits every distinct tile is not spent on repeats', () => { - // Before de-duplication this box yielded 27 entries for 24 tiles, so a - // 24-slot overview held repeats and still reported itself truncated. - const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 }); - const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); - assert.equal(new Set(keys).size, 24); - assert.equal(result.truncated, false); +test('a limit is not spent on repeats', () => { + const keys = keysOf(tilesForBbox([-10, 60, -20, 85], 3, { limit: 16 })); + assert.equal(keys.length, 16); + assert.equal(new Set(keys).size, 16); }); diff --git a/src/layers/streetLevel/tileCache.js b/src/layers/streetLevel/tileCache.js index d6c0a91e5..6ee0897ef 100644 --- a/src/layers/streetLevel/tileCache.js +++ b/src/layers/streetLevel/tileCache.js @@ -1,6 +1,6 @@ /** Decoded coverage tiles kept, and for how long. */ -export const TILE_CACHE_SIZE = 24; -export const TILE_CACHE_TTL_MS = 10 * 60_000; +const TILE_CACHE_SIZE = 24; +const TILE_CACHE_TTL_MS = 10 * 60_000; /** Least-recently-used map: reading an entry renews it. */ export function createLru(limit) { diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index 350188200..65dfb9cb9 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -1,4 +1,4 @@ -/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */ +/** Web-Mercator tile math for Street Level coverage, without Cesium. */ const MAX_LAT = 85.05112878; @@ -58,15 +58,10 @@ export function tileBounds(x, y, z) { }; } -/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */ +/** A [west, south, east, north] array as finite, ordered bounds; null if unusable. */ export function normalizeBbox(input) { - const values = Array.isArray(input) - ? input - : input && typeof input === 'object' - ? [input.west, input.south, input.east, input.north] - : null; - if (!values || values.length !== 4) return null; - const [w, s, e, n] = values.map(Number); + if (!Array.isArray(input) || input.length !== 4) return null; + const [w, s, e, n] = input.map(Number); if (![w, s, e, n].every(Number.isFinite)) return null; const west = Math.max(-180, Math.min(w, e)); const east = Math.min(180, Math.max(w, e)); @@ -77,97 +72,55 @@ export function normalizeBbox(input) { } /** - * Squared distance (in tiles) from tile (x, y)'s centre to the segment - * `from` → `to` at zoom z, measured the short way round the date line. + * Tiles at zoom z covering a [west, south, east, north] box (west > east + * crosses the date line), nearest to `from` first (default: the box centre), + * capped at `limit`. Distances are measured the short way round ±180°. + * @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options] + * @returns {Array<{x:number,y:number,z:number}>} */ -function segmentDistance2(tile, from, to, z) { - const n = 2 ** z; - const px = tile.x + 0.5; - const py = tile.y + 0.5; - const ax = tileXAt(from.lon, z); - const ay = tileYAt(from.lat, z); - let bx = to ? tileXAt(to.lon, z) : ax; - const by = to ? tileYAt(to.lat, z) : ay; - if (bx - ax > n / 2) bx -= n; - else if (ax - bx > n / 2) bx += n; - let best = Infinity; - for (const shift of [-n, 0, n]) { - const x = px + shift; - const vx = bx - ax; - const vy = by - ay; - const len2 = vx * vx + vy * vy; - const t = len2 - ? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2)) - : 0; - const dx = x - (ax + t * vx); - const dy = py - (ay + t * vy); - best = Math.min(best, dx * dx + dy * dy); - } - return best; -} - -/** - * Tiles at zoom z covering a bbox (west > east crosses the date line), from - * the centre outwards, capped at `limit`. With `focus`, tiles rank by distance - * from the segment `from` (ground under the camera) to `to` (screen centre): - * a tilted view's box centre can sit kilometres ahead of both. - * @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options] - * @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}} - */ -export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { - const n = 2 ** z; - const tiles = []; - let centre; - if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) { - // A box across the date line comes as west > east: split it at ±180°. - const [west, south, east, north] = bbox.map(Number); - for (const half of [ - [west, south, 180, north], - [-180, south, east, north], - ]) { - const box = normalizeBbox(half); - if (box) tiles.push(...tileGrid(box, z).tiles); - } - centre = { - x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n, - y: (latToTileY(north, z) + latToTileY(south, z)) / 2, - }; - } else { - const box = normalizeBbox(bbox); - if (!box) return { tiles: [], truncated: false, total: 0 }; - const grid = tileGrid(box, z); - tiles.push(...grid.tiles); - centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 }; - } - const distance2 = focus?.from - ? (tile) => segmentDistance2(tile, focus.from, focus.to, z) - : (tile) => { - // Measured the short way round the date line. - const dx = Math.abs(tile.x - centre.x); - return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; - }; - // The date-line halves share a column when the gap between east and west is - // narrower than a tile; count that column once so a capped overview does not - // spend its slots on repeats. Each tile's distance is computed once; ties keep - // their row order. - const unique = [ +export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) { + const [west, south, east, north] = Array.isArray(bbox) + ? bbox.map(Number) + : []; + const across = west > east; + // A box across the date line is split at ±180°. + const halves = across + ? [ + [west, south, 180, north], + [-180, south, east, north], + ] + : [bbox]; + // The halves share a column when the gap between east and west is + // narrower than a tile: count it once, so a capped set holds no repeats. + const grid = [ ...new Map( - tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), + halves + .map(normalizeBbox) + .filter(Boolean) + .flatMap((box) => tileGrid(box, z)) + .map((tile) => [`${tile.x}/${tile.y}`, tile]), ).values(), ]; - const ranked = unique - .map((tile) => ({ tile, d: distance2(tile) })) + const n = 2 ** z; + const centre = from || { + lon: wrapLon(west + (east - west + (across ? 360 : 0)) / 2), + lat: (south + north) / 2, + }; + const cx = tileXAt(wrapLon(centre.lon), z); + const cy = tileYAt(centre.lat, z); + // Each tile's distance is computed once; ties keep their row order. + const ranked = grid + .map((tile) => { + const dx = Math.abs(tile.x + 0.5 - cx); + const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2; + return { tile, d }; + }) .sort((a, b) => a.d - b.d) .map(({ tile }) => tile); - const truncated = ranked.length > limit; - return { - tiles: truncated ? ranked.slice(0, limit) : ranked, - truncated, - total: ranked.length, - }; + return ranked.slice(0, limit); } -/** The tiles at zoom z in a normalized box, row by row, and their range. */ +/** The tiles at zoom z in a normalized box, row by row. */ function tileGrid(box, z) { const x0 = lonToTileX(box.west, z); const x1 = lonToTileX(box.east, z); @@ -176,7 +129,7 @@ function tileGrid(box, z) { const tiles = []; for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) tiles.push({ x, y, z }); - return { tiles, x0, x1, y0, y1 }; + return tiles; } /** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */ @@ -198,17 +151,3 @@ export function coverageZoomForHeight(heightM) { if (heightM > 1_800) return 13; return 14; } - -/** - * Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy; - * null below, where `coverageZoomForHeight` takes over. - */ -export function overviewZoomForHeight(heightM) { - if (!Number.isFinite(heightM) || heightM <= 60_000) return null; - if (heightM > 15_000_000) return 0; - if (heightM > 7_000_000) return 1; - if (heightM > 3_000_000) return 2; - if (heightM > 1_200_000) return 3; - if (heightM > 400_000) return 4; - return 5; -} diff --git a/src/layers/streetLevel/tileMath.test.mjs b/src/layers/streetLevel/tileMath.test.mjs index b9b88d490..5ed5f73d2 100644 --- a/src/layers/streetLevel/tileMath.test.mjs +++ b/src/layers/streetLevel/tileMath.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { coverageZoomForHeight, - overviewZoomForHeight, latToTileY, lonToTileX, normalizeBbox, @@ -38,31 +37,21 @@ test('normalizeBbox orders and clamps coordinates', () => { north: 38.69, }); assert.equal(normalizeBbox([0, 0, 0, 1]), null); - assert.equal(normalizeBbox('nope'), null); + assert.equal(normalizeBbox([1, 2, 3]), null); assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null); }); -test('city-scale boxes span many z14 tiles', () => { - const sacramento = [-121.56, 38.44, -121.36, 38.69]; - assert.equal(tilesForBbox(sacramento, 14).total, 160); - const detroit = [-83.29, 42.25, -82.91, 42.45]; - assert.equal(tilesForBbox(detroit, 14).total, 234); -}); - test('tilesForBbox orders from the centre outwards and honours the cap', () => { - const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, { + const tiles = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, { limit: 5, }); - assert.equal(result.total, 160); - assert.equal(result.truncated, true); - assert.equal(result.tiles.length, 5); + assert.equal(tiles.length, 5); const centreX = lonToTileX(-121.46, 14); const centreY = latToTileY(38.565, 14); - assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1); - assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1); + assert.ok(Math.abs(tiles[0].x - centreX) <= 1); + assert.ok(Math.abs(tiles[0].y - centreY) <= 1); const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14); - assert.equal(full.truncated, false); - assert.equal(full.tiles.length, 160); + assert.equal(full.length, 160); }); test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => { @@ -74,28 +63,18 @@ test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () assert.equal(coverageZoomForHeight(Number.NaN), null); }); -test('overviewZoomForHeight covers the globe above the sequence ceiling', () => { - assert.equal(overviewZoomForHeight(30_000), null); - assert.equal(overviewZoomForHeight(100_000), 5); - assert.equal(overviewZoomForHeight(900_000), 4); - assert.equal(overviewZoomForHeight(2_000_000), 3); - assert.equal(overviewZoomForHeight(5_000_000), 2); - assert.equal(overviewZoomForHeight(10_000_000), 1); - assert.equal(overviewZoomForHeight(25_000_000), 0); -}); - test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => { assert.equal(lonToTileX(180, 1), 1); assert.equal(lonToTileX(179.99, 1), 1); assert.equal(lonToTileX(-180, 1), 0); const columns = new Set( - tilesForBbox([-180, -85, 180, 85], 1).tiles.map((tile) => tile.x), + tilesForBbox([-180, -85, 180, 85], 1).map((tile) => tile.x), ); assert.deepEqual([...columns].sort(), [0, 1]); }); test('a box across the date line takes tiles on both sides of it, not the far side of the globe', () => { - const { tiles } = tilesForBbox([179, -1, -179, 1], 8); + const tiles = tilesForBbox([179, -1, -179, 1], 8); const columns = new Set(tiles.map((tile) => tile.x)); assert.ok(columns.has(255), 'west of the line'); assert.ok(columns.has(0), 'east of the line'); @@ -104,70 +83,49 @@ test('a box across the date line takes tiles on both sides of it, not the far si 'nothing from the middle of the map', ); // The limit keeps the tiles nearest the line. - const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 }).tiles; + const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 }); assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247)); }); -test('a box across the date line ranks the tiles at the line first, without and with a focus', () => { +test('a box across the date line ranks from the line, or from the camera across it', () => { const z = 14; const n = 2 ** z; const bbox = [179.9, 0, -179.9, 0.1]; const key = (t) => `${t.x}/${t.y}`; - const keys = (list) => new Set(list.map(key)); const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)]; const plain = tilesForBbox(bbox, z); - assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side'); + assert.equal(plain.length, 10 * (south - north + 1), '5 columns either side'); // The box centre is the date line itself, not the middle of the map: the - // middle two rows of the columns touching it come first, then the ring - // around them, all within two columns of the line. - const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2]; - assert.deepEqual( - keys(plain.tiles.slice(0, 4)), - new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]), - ); - for (const tile of plain.tiles.slice(4, 12)) + // columns touching it come first, then the ones beside them. + for (const tile of plain.slice(0, 4)) + assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`); + for (const tile of plain.slice(4, 12)) assert.ok( [n - 2, n - 1, 0, 1].includes(tile.x), `${key(tile)} near the line`, ); - // A line of sight across the date line just north of the equator: the two - // tiles it crosses come first, then the two in the row below them. + // From a camera metres from the line: its tile, then the one across it. const row = latToTileY(0.05, z); - const from = { lon: 179.99, lat: 0.05 }; - const to = { lon: -179.99, lat: 0.05 }; - for (const focus of [ - { from, to }, - { from: to, to: from }, - ]) { - const ranked = tilesForBbox(bbox, z, { focus }).tiles; - assert.deepEqual( - keys(ranked.slice(0, 2)), - new Set([`${n - 1}/${row}`, `0/${row}`]), - ); - assert.deepEqual( - keys(ranked.slice(2, 4)), - new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]), - ); - } + const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z); + const lat = (rowSouth + rowNorth) / 2; + const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } }); + assert.deepEqual(west.slice(0, 2).map(key), [`${n - 1}/${row}`, `0/${row}`]); + const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } }); + assert.deepEqual(east.slice(0, 2).map(key), [`0/${row}`, `${n - 1}/${row}`]); }); -test('a focus ranks tiles along the line of sight, not from the box centre', () => { +test('ranking from the camera keeps the ground under it, not the box centre', () => { // A box that runs far north of the camera, as a tilted view's does: the - // box centre is kilometres from both the camera and the screen centre. + // box centre is kilometres from the camera. const from = { lon: -121.4944, lat: 38.5816 }; - const to = { lon: -121.4944, lat: 38.596 }; const bbox = [-121.6, 38.57, -121.38, 38.9]; const z = 14; const key = (t) => `${t.x}/${t.y}`; const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`; - const centre = `${lonToTileX(to.lon, z)}/${latToTileY(to.lat, z)}`; - const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key); - assert.equal(plain.includes(under), false, 'the old ranking drops them'); - const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } }); - const keys = ranked.tiles.map(key); - assert.ok(keys.includes(under), 'the tile under the camera'); - assert.ok(keys.includes(centre), 'the tile at the centre of the screen'); - assert.equal(ranked.total > 9, true); + const plain = tilesForBbox(bbox, z, { limit: 9 }).map(key); + assert.equal(plain.includes(under), false, 'the box centre drops it'); + const ranked = tilesForBbox(bbox, z, { limit: 9, from }); + assert.equal(key(ranked[0]), under, 'the tile under the camera first'); }); test('wrapLon wraps longitudes and their differences into [-180, 180]', () => { diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 9d1afecc2..04c6ed6c4 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -1,6 +1,5 @@ -import { COLORS } from './policy.js'; - import { msg, t } from '../../i18n/index.js'; + /** * @typedef {object} ProviderSnapshot * @property {string} id @@ -19,7 +18,6 @@ import { msg, t } from '../../i18n/index.js'; * @property {boolean} [groundClick] * @property {Array<'pano'|'since'>} [filters] * @property {{noun: string, action: string}|null} [coverageLabel] - * @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected. * @property {'capture'|'upload'} [sinceBasis] What its SINCE compares. */ @@ -66,7 +64,7 @@ export function keyAlternatives(providers) { * The filter controls a switched-on provider honours (both when none is on). * @param {Array} providers */ -export function filterControls(providers) { +function filterControls(providers) { const active = providers.filter((p) => p.on); const honoured = (key) => active.length === 0 || @@ -75,23 +73,20 @@ export function filterControls(providers) { } /** - * The note under SINCE when a lit provider dates its imagery by upload, - * e.g. "KartaView: by upload date", or null. + * The note beside the SINCE value when a lit provider dates its imagery by + * upload, "(by upload date)", or null. One source is lit at a time, so the + * note needs no provider name. * @param {Array} providers */ -export function sinceNote(providers) { - const names = providers - .filter((p) => p.on && p.sinceBasis === 'upload') - .map((p) => p.name); - return names.length - ? t('{names}: by upload date', { names: names.join(', ') }) +function sinceNote(providers) { + return providers.some((p) => p.on && p.sinceBasis === 'upload') + ? t('(by upload date)') : null; } /** - * The panel snapshot from core state and provider snapshots. The legend has - * one swatch per active source that draws coverage lines, then Selected. - * @param {{enabled: boolean, filter: object, providers: Array, street: object, sequence: object}} input + * The panel snapshot from core state and provider snapshots. + * @param {{enabled: boolean, groundClickReady?: boolean, filter: object, providers: Array, street: object, sequence: object}} input */ export function composeUIState({ enabled, @@ -100,30 +95,9 @@ export function composeUIState({ providers, street, sequence, - surface = 'draped', }) { - const active = providers.filter((p) => p.on); const { keyRequired, keyRejected, ...coverage } = summarizeCoverage(providers); - const legend = active - .filter((provider) => provider.groundClick !== true) - .map((provider) => ({ - key: provider.id, - label: provider.name, - color: provider.color, - })); - // "Selected" only beside a source whose lines turn cyan when selected. - if ( - active.some( - (provider) => - provider.groundClick !== true && provider.highlightsSequence !== false, - ) - ) - legend.push({ - key: 'selected', - label: t('Selected'), - color: COLORS.selected, - }); return { enabled, groundClickReady: groundClickReady === true, @@ -135,9 +109,7 @@ export function composeUIState({ sinceNote: sinceNote(providers), providers: providers.map((p) => ({ ...p })), coverage, - legend, sequence: { ...sequence }, street: { ...street }, - surface, }; } diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 21b5eefe0..009600026 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeUIState, summarizeCoverage } from './uiState.js'; -import { COLORS } from './policy.js'; import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs'; const provider = (overrides = {}) => @@ -47,66 +46,6 @@ test('the layer is key-gated only when every switched-on provider lacks its key' assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate'); }); -test('the legend is one swatch per active source, in its colour, then Selected', () => { - const single = composeUIState({ ...base, providers: [provider()] }); - assert.deepEqual(single.legend, [ - { key: 'mapillary', label: 'Mapillary', color: '#05cb63' }, - { key: 'selected', label: 'Selected', color: COLORS.selected }, - ]); - const two = composeUIState({ - ...base, - providers: [ - provider(), - provider({ id: 'panoramax', name: 'Panoramax', color: '#a66bff' }), - ], - }); - assert.deepEqual( - two.legend.map((entry) => [entry.label, entry.color]), - [ - ['Mapillary', '#05cb63'], - ['Panoramax', '#a66bff'], - ['Selected', COLORS.selected], - ], - ); - const oneOff = composeUIState({ - ...base, - providers: [ - provider(), - provider({ id: 'panoramax', name: 'Panoramax', on: false }), - ], - }); - assert.deepEqual( - oneOff.legend.map((entry) => entry.key), - ['mapillary', 'selected'], - ); - assert.deepEqual( - composeUIState({ ...base, providers: [provider({ on: false })] }).legend, - [], - ); -}); - -test('Street View draws no lines: no legend swatch, and no Selected on its own', () => { - const streetView = provider({ - id: 'google', - name: 'Google Street View', - color: '#4285f4', - count: 0, - groundClick: true, - filters: [], - }); - assert.deepEqual( - composeUIState({ ...base, providers: [streetView] }).legend, - [], - ); - assert.deepEqual( - composeUIState({ - ...base, - providers: [provider(), streetView], - }).legend.map((entry) => entry.key), - ['mapillary', 'selected'], - ); -}); - test('the filter controls show only for a lit provider that honours them', () => { const streetView = provider({ id: 'google', groundClick: true, filters: [] }); const controls = (providers) => @@ -128,7 +67,7 @@ test('the filter controls show only for a lit provider that honours them', () => ); }); -test('a lit provider that dates by upload puts a note under SINCE', () => { +test('a lit provider that dates by upload notes it beside the SINCE value', () => { const kartaView = provider({ id: 'kartaview', name: 'KartaView', @@ -136,7 +75,7 @@ test('a lit provider that dates by upload puts a note under SINCE', () => { }); const note = (providers) => composeUIState({ ...base, providers }).sinceNote; assert.equal(note([provider()]), null, 'Mapillary: capture date'); - assert.equal(note([kartaView]), 'KartaView: by upload date'); + assert.equal(note([kartaView]), '(by upload date)'); assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit'); }); @@ -194,18 +133,6 @@ test('a rejected key gates like a missing one and is named as rejected', () => { ); }); -test('the surface mode defaults to draped and passes through', () => { - assert.equal( - composeUIState({ ...base, providers: [provider()] }).surface, - 'draped', - ); - assert.equal( - composeUIState({ ...base, providers: [provider()], surface: 'terrain' }) - .surface, - 'terrain', - ); -}); - test('every switched-on provider’s hint is kept, once each', async () => { const { summarizeCoverage } = await import('./uiState.js'); const { providerSnapshot } = @@ -249,22 +176,17 @@ test('a key-gated layer offers the switched-off sources that would draw with a k ); }); -test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => { +test('a source names its own lines', () => { const kartaView = provider({ id: 'kartaview', name: 'KartaView', color: '#635bff', - highlightsSequence: false, coverageLabel: { noun: 'streets', action: 'click a line for its nearest photo', }, }); const ui = composeUIState({ ...base, providers: [kartaView] }); - assert.deepEqual( - ui.legend.map((entry) => entry.key), - ['kartaview'], - ); assert.deepEqual(ui.coverage.label, { noun: 'streets', action: 'click a line for its nearest photo', diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 09bc516ed..e14cb6493 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -10,40 +10,42 @@ export function metresBetween(a, b) { ); } -/** - * Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks - * (Node) it runs a frame later with a null deadline. - */ -export function whenIdle(task, timeout) { - if (typeof globalThis.requestIdleCallback === 'function') - globalThis.requestIdleCallback(task, { timeout }); - else setTimeout(() => task(null), 16); +/** Whether a height (m) is plausible ellipsoidal land: Dead Sea to Everest, with geoid slack. */ +export function plausibleSurfaceHeight(height) { + return Number.isFinite(height) && height >= -500 && height <= 9000; } /** - * Ellipsoidal height (m) of the surface under the camera: globe terrain, or - * `groundAt` where Google 3D hides the globe; null without a sample. - * @param {object} viewer - * @param {{groundAt?: (lon: number, lat: number) => number|null}} [options] + * Height (m) of the rendered surface, 3D tiles included, at `carto`; null + * when unsampleable or implausible (`sampleHeight` reads kilometres + * underground before tiles load). */ -export function groundUnderCamera(viewer, { groundAt } = {}) { - const carto = viewer?.camera?.positionCartographic; - if (!carto) return null; - const globe = viewer.scene?.globe; - let ground = globe?.show === false ? null : globe?.getHeight?.(carto); - if (!Number.isFinite(ground)) - ground = groundAt?.( - Cesium.Math.toDegrees(carto.longitude), - Cesium.Math.toDegrees(carto.latitude), - ); - return Number.isFinite(ground) ? ground : null; +export function sampledSurfaceHeight(scene, carto) { + let height = null; + try { + if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); + } catch { + /* not sampleable yet */ + } + return plausibleSurfaceHeight(height) ? height : null; } -/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */ -export function cameraHeightAboveGround(viewer, options = {}) { +/** + * Ellipsoidal height (m) of the ground under the camera, or null. Under + * Google 3D (globe hidden) the rendered surface counts, minus any roof above + * the camera. + */ +export function groundUnderCamera(viewer) { const carto = viewer?.camera?.positionCartographic; - if (!carto) return null; - return carto.height - (groundUnderCamera(viewer, options) ?? 0); + const scene = viewer?.scene; + if (!carto || !scene) return null; + const globe = scene.globe; + if (globe && globe.show !== false) { + const ground = globe.getHeight?.(carto); + if (Number.isFinite(ground)) return ground; + } + const sampled = sampledSurfaceHeight(scene, carto); + return sampled !== null && sampled < carto.height ? sampled : null; } /** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */ @@ -74,16 +76,24 @@ function groundHit(camera, point, ellipsoid, maxRange) { return Cesium.Cartographic.fromCartesian(cartesian, ellipsoid) || null; } +/** Camera height (m) above the ground under it. */ +export function cameraHeightAboveGround(viewer) { + const carto = viewer?.camera?.positionCartographic; + if (!carto) return null; + return carto.height - (groundUnderCamera(viewer) ?? 0); +} + +/** Screen rays per side, less one, that `visibleBbox` casts. */ +const BBOX_GRID = 5; + /** - * Visible [west, south, east, north] degrees, or null. Only screen rays that - * hit count, so a horizon cannot inflate the box to the world. - * `groundHeight` raises the ellipsoid to the ground (Google 3D hides the - * globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always - * includes the ground around the camera. + * Visible [west, south, east, north] degrees from the rays that hit the + * ground, or null when fewer than four do. `groundHeight` raises the ellipsoid + * to the ground (Denver is 1,600 m up); `maxRange` (m) drops far hits. */ export function visibleBbox( viewer, - { grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {}, + { groundHeight = null, maxRange = null } = {}, ) { const scene = viewer?.scene; const camera = viewer?.camera; @@ -97,36 +107,15 @@ export function visibleBbox( groundHeight, ); const point = new Cesium.Cartesian2(); - for (let i = 0; i <= grid; i++) { - for (let j = 0; j <= grid; j++) { - point.x = (width * i) / grid; - point.y = (height * j) / grid; + for (let i = 0; i <= BBOX_GRID; i++) { + for (let j = 0; j <= BBOX_GRID; j++) { + point.x = (width * i) / BBOX_GRID; + point.y = (height * j) / BBOX_GRID; const carto = groundHit(camera, point, ellipsoid, maxRange); if (carto) hits.push(carto); } } } - // The ground around the camera always counts: a street-level view's screen - // rows skip from the horizon to the first few metres. - const nadir = camera.positionCartographic; - if (Number.isFinite(nearRange) && nearRange > 0 && nadir) { - const dLat = nearRange / 111_320; - const dLon = dLat / Math.max(0.05, Math.cos(nadir.latitude)); - const lat = Cesium.Math.toDegrees(nadir.latitude); - const lon = Cesium.Math.toDegrees(nadir.longitude); - for (const [dx, dy] of [ - [-1, -1], - [-1, 1], - [1, -1], - [1, 1], - ]) - hits.push( - Cesium.Cartographic.fromDegrees( - lon + dx * dLon, - Math.max(-85, Math.min(85, lat + dy * dLat)), - ), - ); - } if (hits.length >= 4) { let west = Infinity; let south = Infinity; @@ -159,78 +148,57 @@ export function visibleBbox( } if (west !== east && north - south > 0) return [west, south, east, north]; } - const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid); - if (!rectangle) return null; - return [ - Cesium.Math.toDegrees(rectangle.west), - Cesium.Math.toDegrees(rectangle.south), - Cesium.Math.toDegrees(rectangle.east), - Cesium.Math.toDegrees(rectangle.north), - ]; + return null; } -/** Centre of the visible ground, or the camera's own footprint. */ -export function viewCentre(viewer) { - if (!viewer) return null; - const bbox = visibleBbox(viewer); - if (bbox) { - // Across the date line (west > east) the middle is on the far side of 0°. - const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0); - let lon = bbox[0] + span / 2; - if (lon > 180) lon -= 360; - return { lat: (bbox[1] + bbox[3]) / 2, lon }; - } - const carto = viewer.camera?.positionCartographic; +/** The point on the ground under the camera, as {lon, lat} degrees. */ +export function cameraNadir(viewer) { + const carto = viewer?.camera?.positionCartographic; if (!carto) return null; return { - lat: Cesium.Math.toDegrees(carto.latitude), lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), }; } /** - * Ground the camera looks at, for ranking tiles: `nadir` under the camera and - * `ahead` at the screen centre (null on a miss or beyond `maxRange`). + * Where coverage tiles are ranked from: halfway between the nadir and the + * screen centre's ground, or the nadir when that is out of `maxRange`. */ export function viewFocus( viewer, { groundHeight = null, maxRange = null } = {}, ) { - const camera = viewer?.camera; - const carto = camera?.positionCartographic; - if (!carto) return null; - const nadir = { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), + const nadir = cameraNadir(viewer); + const scene = viewer?.scene; + const width = scene?.canvas?.clientWidth || scene?.canvas?.width || 0; + const height = scene?.canvas?.clientHeight || scene?.canvas?.height || 0; + if (!nadir || !width || !height) return nadir; + const hit = groundHit( + viewer.camera, + new Cesium.Cartesian2(width / 2, height / 2), + raisedEllipsoid( + scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, + groundHeight, + ), + maxRange, + ); + if (!hit) return nadir; + const ahead = { + lon: Cesium.Math.toDegrees(hit.longitude), + lat: Cesium.Math.toDegrees(hit.latitude), + }; + return { + lon: wrapLon(nadir.lon + wrapLon(ahead.lon - nadir.lon) / 2), + lat: (nadir.lat + ahead.lat) / 2, }; - const canvas = viewer.scene?.canvas; - const width = canvas?.clientWidth || canvas?.width || 0; - const height = canvas?.clientHeight || canvas?.height || 0; - let ahead = null; - if (width > 0 && height > 0) { - const hit = groundHit( - camera, - new Cesium.Cartesian2(width / 2, height / 2), - raisedEllipsoid( - viewer.scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, - groundHeight, - ), - maxRange, - ); - if (hit) - ahead = { - lon: Cesium.Math.toDegrees(hit.longitude), - lat: Cesium.Math.toDegrees(hit.latitude), - }; - } - return { nadir, ahead }; } /** * Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid * (NYC −22 m), and an occluder at 0 m would hide the cones around it. */ -export const HORIZON_CULL_DEPTH_M = 1000; +const HORIZON_CULL_DEPTH_M = 1000; const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid( Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, @@ -295,3 +263,22 @@ export function createHorizonCull({ getViewer, items, onChange }) { return { update, stop }; } + +/** The middle of the visible ground, else the point under the camera; null without a camera. */ +export function viewCentre(viewer) { + if (!viewer) return null; + const bbox = visibleBbox(viewer); + if (bbox) { + // Across the date line (west > east) the middle is on the far side of 0°. + const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0); + let lon = bbox[0] + span / 2; + if (lon > 180) lon -= 360; + return { lat: (bbox[1] + bbox[3]) / 2, lon }; + } + const carto = viewer.camera?.positionCartographic; + if (!carto) return null; + return { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + }; +} diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index 0f9c7d2a5..8f5a4dd99 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -2,13 +2,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { - cameraHeightAboveGround, + cameraNadir, createHorizonCull, + groundUnderCamera, metresBetween, - viewCentre, viewFocus, visibleBbox, } from './view.js'; +import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js'; import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; const RAD = Math.PI / 180; @@ -28,28 +29,42 @@ function viewer({ globeShown, globeHeight }) { test('a shown globe answers the ground under the camera', () => { assert.equal( - cameraHeightAboveGround(viewer({ globeShown: true, globeHeight: 1600 })), - 300, + groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })), + 1600, ); }); -test('a hidden globe (Google 3D) falls back to the bare-earth height', () => { - const calls = []; - const groundAt = (lon, lat) => { - calls.push([+lon.toFixed(2), +lat.toFixed(2)]); - return 1600; - }; +test('a hidden globe (Google 3D) or a missing sample gives no ground', () => { // A hidden globe's getHeight is ignored even when it returns a number. - const hidden = viewer({ globeShown: false, globeHeight: 0 }); - assert.equal(cameraHeightAboveGround(hidden, { groundAt }), 300); - assert.deepEqual(calls, [[-104.99, 39.74]]); + assert.equal( + groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })), + null, + ); + assert.equal( + groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })), + null, + ); + assert.equal(groundUnderCamera({}), null); }); -test('with no ground sample the ellipsoidal height is used', () => { - const hidden = viewer({ globeShown: false, globeHeight: 0 }); - assert.equal(cameraHeightAboveGround(hidden), 1900); - assert.equal(cameraHeightAboveGround(hidden, { groundAt: () => null }), 1900); - assert.equal(cameraHeightAboveGround({}), null); +/** The Denver viewer with Google 3D: no globe, a sampleable rendered surface. */ +function google3d(sample) { + const view = viewer({ globeShown: false, globeHeight: 0 }); + view.scene.sampleHeightSupported = true; + view.scene.sampleHeight = () => sample; + return view; +} + +test('on Google 3D the rendered surface answers the ground under the camera', () => { + assert.equal(groundUnderCamera(google3d(1612)), 1612); +}); + +test('an implausible or above-camera surface sample gives no ground', () => { + // Before tiles stream in, sampleHeight can read kilometres underground. + assert.equal(groundUnderCamera(google3d(-14_886)), null); + // A roof above the camera (1,900 m) would put the camera underground. + assert.equal(groundUnderCamera(google3d(1950)), null); + assert.equal(groundUnderCamera(google3d(undefined)), null); }); /** A camera whose screen rays land on a grid of lon/lat points. */ @@ -73,16 +88,11 @@ test('a view across the date line is a narrow box with west > east', () => { const bbox = visibleBbox(viewer); assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`); assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`); - const centre = viewCentre(viewer); - assert.ok(Math.abs(Math.abs(centre.lon) - 180) < 0.5, `centre ${centre.lon}`); }); test('an ordinary view keeps west < east', () => { const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51])); assert.ok(bbox[0] < bbox[2]); - assert.ok( - Math.abs(viewCentre(gridViewer([10, 11, 12], [50, 51])).lon - 11) < 1e-6, - ); }); /** A pinhole viewer `agl` m above ground `ground` m up, looking north. */ @@ -119,20 +129,66 @@ test('a tilted street view over high ground boxes the streets it looks at, not t // the box is 20 km wide and starts past the street at the screen centre. assert.ok(unranged[2] - unranged[0] > 0.2); assert.ok(unranged[1] > ahead, 'the old box missed the street in view'); - const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 }; + const options = { groundHeight: 1610, maxRange: 6000 }; const [west, south, east, north] = visibleBbox(view, options); assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box'); - assert.ok(south < 39.74 && north > ahead, 'camera and screen centre inside'); - const focus = viewFocus(view, options); - assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9); + assert.ok(south < ahead && north > ahead, 'the screen centre is inside'); assert.ok( - Math.abs(focus.ahead.lat - ahead) < 0.002, - 'centre ray on the street', + south > 39.74, + 'it starts ahead of the camera, where the view does', + ); + assert.ok(west < -104.99 && east > -104.99); + const nadir = cameraNadir(view); + assert.ok( + Math.abs(nadir.lat - 39.74) < 1e-9 && Math.abs(nadir.lon + 104.99) < 1e-9, ); - assert.ok(Math.abs(focus.ahead.lon - -104.99) < 1e-6); }); -test('looking at the horizon from eye height still boxes the ground around the camera', () => { +test('a tilted view from 3 km loads the tile at the centre of the screen', () => { + // 3 km up, 70° from straight down: the screen centre meets the ground + // 8.2 km ahead, past the 3 × 3 z13 tiles around the camera's ground point. + const lon = -121.49; + const lat = 38.58; + const view = pinhole({ lon, lat, ground: 10, agl: 3000, pitch: -20 }); + const ranged = { groundHeight: 10, maxRange: 30_000 }; + const bbox = visibleBbox(view, ranged); + const ahead = lat + 3000 / Math.tan((20 * Math.PI) / 180) / 110_540; + const centreTile = `${lonToTileX(lon, 13)}/${latToTileY(ahead, 13)}`; + const chosen = (from) => + tilesForBbox(bbox, 13, { limit: 9, from }).map( + (tile) => `${tile.x}/${tile.y}`, + ); + assert.ok( + !chosen(cameraNadir(view)).includes(centreTile), + 'ranked from the nadir alone, the screen centre is left out', + ); + const focus = viewFocus(view, ranged); + assert.ok(focus.lat > lat && focus.lat < ahead, 'between camera and centre'); + assert.ok(chosen(focus).includes(centreTile), 'the screen centre is loaded'); + // The bottom edge of the screen (50° down) meets the ground 2.5 km ahead. + const nearest = lat + 3000 / Math.tan((50 * Math.PI) / 180) / 110_540; + assert.ok( + chosen(focus).includes(`${lonToTileX(lon, 13)}/${latToTileY(nearest, 13)}`), + 'and so is the nearest ground in view', + ); +}); + +test('looking straight down or at the sky, the focus is the ground under the camera', () => { + const lon = -121.49; + const lat = 38.58; + const down = viewFocus( + pinhole({ lon, lat, ground: 10, agl: 500, pitch: -90 }), + { groundHeight: 10, maxRange: 5000 }, + ); + assert.ok(Math.abs(down.lat - lat) < 1e-4 && Math.abs(down.lon - lon) < 1e-4); + const sky = viewFocus(pinhole({ lon, lat, ground: 10, agl: 2, pitch: 60 }), { + groundHeight: 10, + maxRange: 2500, + }); + assert.ok(Math.abs(sky.lat - lat) < 1e-9 && Math.abs(sky.lon - lon) < 1e-9); +}); + +test('looking at the horizon from eye height boxes the near ground, not the horizon', () => { const view = pinhole({ lon: -121.4944, lat: 38.5816, @@ -143,10 +199,19 @@ test('looking at the horizon from eye height still boxes the ground around the c const [west, south, east, north] = visibleBbox(view, { groundHeight: 10, maxRange: 2500, - nearRange: 1000, }); - assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008); - assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008); + assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead'); + assert.ok(north - 38.5816 < 2500 / 111_000, 'no further than the range'); + assert.ok(west < -121.4944 && east > -121.4944); + // Looking at the sky, nothing is in range: no box, never the horizon's. + const sky = pinhole({ + lon: -121.4944, + lat: 38.5816, + ground: 10, + agl: 2, + pitch: 60, + }); + assert.equal(visibleBbox(sky, { groundHeight: 10, maxRange: 2500 }), null); }); test('metresBetween measures the short way round the date line', () => { @@ -285,7 +350,7 @@ function cullFrom(camera, points) { } test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => { - // NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away. + // NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away. assert.deepEqual( cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]), [true], diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index ce7363a13..16c992afd 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -2,35 +2,27 @@ import { msg } from '../../i18n/index.js'; import { freshStreet } from './state.js'; /** - * Owns the panel's viewer element and the provider adapter mounted in it. - * Adapter poses update `state.street`, the marker and the follow camera. + * The panel's photo viewer element and the provider adapter mounted in it. + * Adapter poses update `state.street` and the marker. */ export function createViewerHost({ state, parts }) { /** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */ let active = null; - /** @type {{id: string, promise: Promise}|null} */ + /** The mount in flight, shared by concurrent opens. @type {{id: string, promise: Promise}|null} */ let mounting = null; let openSeq = 0; - /** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */ - const warmed = new Map(); function notify() { state.notify?.(); } - function providerEntry(providerId) { - return state.providers.get(providerId) || null; - } - function applyPose(pose) { - if (!active || !state.street.open || pose.providerId !== active.id) return; + if (!active || !state.street.open) return; const previousSequence = state.street.sequenceId; Object.assign(state.street, { - providerId: pose.providerId, imageId: pose.imageId, position: pose.position ? { ...pose.position } : null, bearing: Number.isFinite(pose.bearing) ? pose.bearing : null, - tilt: Number.isFinite(pose.tilt) ? pose.tilt : 0, altitude: Number.isFinite(pose.altitude) ? pose.altitude : null, isPano: pose.isPano === true, capturedAt: pose.capturedAt ?? null, @@ -49,9 +41,8 @@ export function createViewerHost({ state, parts }) { parts.marker.set( state.street.position, state.street.bearing, - providerEntry(pose.providerId)?.def.marker, + state.providers.get(active.id)?.def.marker, ); - parts.follow.followCamera(); // Select the sequence only once the image is on screen, so its lookup // never competes with the image download. if (!state.street.loading && state.street.sequenceId !== previousSequence) @@ -61,67 +52,58 @@ export function createViewerHost({ state, parts }) { function selectCurrentSequence() { const { sequenceId, providerId } = state.street; - const entry = providerEntry(providerId); + const entry = state.providers.get(providerId); if (!sequenceId || !entry?.instance.selectSequence || !state.enabled) return; - const current = entry.instance.sequenceStats?.()?.selectedId; - if (current !== sequenceId) entry.instance.selectSequence(sequenceId); + if (entry.instance.sequenceStats?.()?.selectedId !== sequenceId) + entry.instance.selectSequence(sequenceId); } /** - * Mount a provider's adapter. It becomes `active` only on success, so a - * failed mount is retried on the next open; concurrent opens share one. + * Mount a provider's adapter, releasing another provider's first. It + * counts as mounted only on success, so a failed mount is retried on the + * next open; concurrent opens share one. */ function mount(entry) { - if (active?.id === entry.def.id) return Promise.resolve(active.adapter); - if (mounting?.id === entry.def.id) return mounting.promise; + const id = entry.def.id; + if (active?.id === id) return Promise.resolve(active.adapter); + if (mounting?.id === id) return mounting.promise; if (active) { active.adapter.close(); active.unsubscribe(); active.adapter.unmount(); active = null; } - // A prewarmed viewer would sit under this one, holding its WebGL context. - for (const [id, warm] of warmed) { - if (id === entry.def.id) continue; - warmed.delete(id); - warm.unmount(); - } const adapter = entry.instance.viewer; const promise = (async () => { await adapter.mount(state.street.host); if (mounting?.promise !== promise) { // Unmounted (layer off, provider switched) while loading. A newer - // mount of this provider shares the adapter and its viewer: tearing - // it down here would leave that mount active with no viewer. - const reused = - active?.id === entry.def.id || mounting?.id === entry.def.id; - if (!reused) adapter.unmount(); + // mount of this provider shares the adapter: tearing it down here + // would leave that mount without a viewer. + if (active?.id !== id && mounting?.id !== id) adapter.unmount(); throw new Error('Street-level viewer was closed'); } // Listen only once current: an outdated mount releasing the same // `applyPose` would otherwise unhook the mount that replaced it. - const unsubscribe = adapter.onPose(applyPose); - active = { id: entry.def.id, adapter, unsubscribe }; - warmed.delete(entry.def.id); + active = { id, adapter, unsubscribe: adapter.onPose(applyPose) }; adapter.setRenderMode?.(state.street.renderMode); return adapter; })(); - mounting = { id: entry.def.id, promise }; - promise.then( - () => { - if (mounting?.promise === promise) mounting = null; - }, - () => { - if (mounting?.promise === promise) mounting = null; - }, - ); + mounting = { id, adapter, promise }; + const settle = () => { + if (mounting?.promise === promise) mounting = null; + }; + promise.then(settle, settle); return promise; } - /** Open an image: true once its first pose is in, false if it failed or was overtaken. */ + /** + * Open an image: true once its first pose is in, false if it failed or was + * overtaken. `frame: false` leaves the camera where it is. + */ async function open(providerId, imageId, { frame = true } = {}) { - const entry = providerEntry(providerId); + const entry = state.providers.get(providerId); if (!entry || !imageId) return false; if (!state.street.host) { // A nearest-image lookup may have set it; nothing is loading now. @@ -134,8 +116,7 @@ export function createViewerHost({ state, parts }) { const seq = ++openSeq; const current = () => seq === openSeq; // Claimed now, honoured after loading only if nothing newer took the camera. - const ticket = - frame && !state.street.follow ? parts.follow.beginFraming() : null; + const ticket = frame ? parts.framing.begin() : null; Object.assign(state.street, { loading: true, error: null, @@ -152,7 +133,7 @@ export function createViewerHost({ state, parts }) { state.street.renderModes = typeof adapter.setRenderMode === 'function'; await adapter.open(String(imageId)); if (!current()) return false; - if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket); + if (ticket) parts.framing.frame(ticket); } catch (error) { if (current()) state.street.error = error?.message || msg('Image could not be opened'); @@ -165,10 +146,10 @@ export function createViewerHost({ state, parts }) { return true; } - /** Close the image and stop any framing flight; the adapter stays warm. */ + /** Close the image and stop any framing flight; the adapter stays mounted. */ function close() { openSeq++; - parts.follow.cancelFraming(); + parts.framing.cancel(); active?.adapter.close(); Object.assign(state.street, freshStreet()); parts.marker.clear(); @@ -181,74 +162,30 @@ export function createViewerHost({ state, parts }) { } function resize() { - try { - active?.adapter.resize(); - } catch { - /* no-op */ - } + active?.adapter.resize(); } function setRenderMode(mode) { state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; - try { - active?.adapter.setRenderMode?.(state.street.renderMode); - } catch { - /* adapter not ready */ - } + active?.adapter.setRenderMode?.(state.street.renderMode); notify(); } - /** Stand active providers' viewers up ahead of the first image. */ - async function prewarm(entries) { - const host = state.street.host; - if (!host || !state.enabled) return; - for (const entry of entries) { - const adapter = entry.instance.viewer; - if (!adapter.prewarm || !entry.on) continue; - // Another provider's viewer is showing in the host: do not build under it. - if (active && active.id !== entry.def.id) continue; - try { - await adapter.prewarm(host); - } catch { - /* the real open reports errors */ - } - if (active?.id === entry.def.id || mounting?.id === entry.def.id) - continue; - // Switched off, or another viewer took the host meanwhile. - if (!state.enabled || !entry.on || active || mounting) adapter.unmount(); - else warmed.set(entry.def.id, adapter); - } - } - - /** Tear down every viewer, or only `providerId`'s (active or prewarmed). */ + /** + * Close the image and destroy the viewer (and its WebGL context): every + * provider's, or only `providerId`'s. A mount still in flight is told too, + * so it builds nothing when its library lands. + */ function unmount(providerId) { - if ( - !providerId || - active?.id === providerId || - mounting?.id === providerId - ) { - close(); - mounting = null; - if (active) { - active.unsubscribe(); - active.adapter.unmount(); - active = null; - } - } - for (const [id, adapter] of warmed) { - if (providerId && id !== providerId) continue; - warmed.delete(id); - adapter.unmount(); - } + if (providerId && active?.id !== providerId && mounting?.id !== providerId) + return; + close(); + const pending = mounting; + mounting = null; + active?.unsubscribe(); + (active?.adapter ?? pending?.adapter)?.unmount(); + active = null; } - return { - attach, - open, - close, - resize, - setRenderMode, - prewarm, - unmount, - }; + return { attach, open, close, resize, setRenderMode, unmount }; } diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 259934b73..fdcb74e21 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -22,63 +22,68 @@ registerHooks({ const source = url.includes('/library?') ? 'const lib = await globalThis.__gevMapillaryLibrary();\n' + 'export const Viewer = lib.Viewer;\n' + - 'export const RenderMode = lib.RenderMode;\n' + 'export const RenderMode = lib.RenderMode;\n' + + 'export const GraphDataProvider = lib.GraphDataProvider;\n' : 'export default {};\n'; return { format: 'module', shortCircuit: true, source }; }, }); -const { createMapillaryViewer } = +const { createMapillaryViewer, graphEndpointUrl } = await import('./providers/mapillary/viewer.js'); +const { createMapillarySource } = + await import('./providers/mapillary/source.js'); /** * A viewer host over `adapter`. `marker` records where the position marker - * was put (null once cleared), `followed` counts camera-follow updates and - * `selected` every sequence the provider was asked to select. + * was put (null once cleared), `framing` counts framing calls and + * `selected` every sequence the layer was asked to select. */ function harness(adapter) { + const selected = []; const state = { enabled: true, notify() {}, - providers: new Map(), - street: { host: {}, renderMode: 'letterbox', follow: false }, + street: { host: {}, renderMode: 'letterbox' }, + sequence: { selectedId: null }, + providers: new Map([ + [ + 'mapillary', + { + def: { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' }, + instance: { + viewer: adapter, + selectSequence(id) { + selected.push(id); + state.sequence.selectedId = id; + }, + sequenceStats: () => ({ selectedId: state.sequence.selectedId }), + }, + }, + ], + ]), }; - const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' }; - const selected = []; - let selectedId = null; - const instance = { - viewer: adapter, - selectSequence(id) { - selected.push(id); - selectedId = id; - }, - sequenceStats: () => ({ selectedId }), - }; - state.providers.set('mapillary', { def, instance }); const framing = { begun: 0, started: 0, cancelled: 0 }; const marker = []; - const followed = { count: 0 }; const parts = { marker: { set: (position, bearing) => marker.push(position && { position, bearing }), clear: () => marker.push(null), }, - follow: { - followCamera: () => followed.count++, - beginFraming: () => ({ generation: ++framing.begun }), - lookAtPosition: (ticket) => ticket && framing.started++, - cancelFraming: () => framing.cancelled++, + framing: { + begin: () => ({ generation: ++framing.begun }), + frame: (ticket) => ticket && framing.started++, + cancel: () => framing.cancelled++, }, }; return { state, framing, marker, - followed, selected, - /** Esc on the map: the provider drops its selected sequence. */ + /** Esc on the map: the selected sequence is dropped. */ clearSequence() { - selectedId = null; + state.sequence.selectedId = null; }, host: createViewerHost({ state, parts }), }; @@ -105,7 +110,6 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) { /** A pose event from the library, as MapillaryJS fires them. */ emitPose(id, overrides = {}) { emit?.({ - providerId: 'mapillary', imageId: id, sequenceId: `seq-${id}`, position: { lon: 1, lat: 2 }, @@ -168,7 +172,7 @@ test('unmounting while a mount is in flight does not leave it active', async () host.unmount(); await opening; assert.equal(adapter.calls.listeners, 0); - assert.equal(adapter.calls.unmount, 1); + assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount'); assert.equal(state.street.open, false); // Not left active: the next open mounts again. await host.open('mapillary', 'b'); @@ -176,46 +180,6 @@ test('unmounting while a mount is in flight does not leave it active', async () assert.equal(state.street.imageId, 'b'); }); -test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => { - const adapter = fakeAdapter(); - let release = null; - adapter.prewarm = () => - new Promise((resolve) => { - release = resolve; - }); - const { state, host } = harness(adapter); - const entry = state.providers.get('mapillary'); - entry.on = true; - - // Warmed, then the layer is switched off: unmount releases the warm viewer. - const warming = host.prewarm([entry]); - release(); - await warming; - assert.equal(adapter.calls.unmount, 0); - host.unmount(); - assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed'); - - // Switched off while the library was still loading: released on arrival. - const late = host.prewarm([entry]); - state.enabled = false; - release(); - await late; - assert.equal(adapter.calls.unmount, 2); -}); - -test('switching one provider off releases only its viewer', async () => { - const adapter = fakeAdapter(); - adapter.prewarm = async () => {}; - const { state, host } = harness(adapter); - const entry = state.providers.get('mapillary'); - entry.on = true; - await host.prewarm([entry]); - host.unmount('panoramax'); - assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone'); - host.unmount('mapillary'); - assert.equal(adapter.calls.unmount, 1); -}); - test('a pose that arrives after the photo was closed does not bring it back', async () => { const adapter = fakeAdapter(); const { state, host } = harness(adapter); @@ -238,44 +202,23 @@ function deferred() { return { promise, resolve }; } -test('switching the layer off and on during a cold first open keeps the shared viewer', async () => { - const gate = deferred(); - const adapter = fakeAdapter({ gate }); - const { state, host } = harness(adapter); - const first = host.open('mapillary', 'a'); // the library is still loading - host.unmount(); // layer off … - const second = host.open('mapillary', 'b'); // … on again, and a new click - gate.resolve(); - await Promise.all([first, second]); - assert.equal(adapter.calls.unmount, 0, 'the outdated mount left it alone'); - assert.equal(adapter.calls.listeners, 1); - assert.equal(state.street.error, null); - assert.equal(state.street.imageId, 'b'); - await host.open('mapillary', 'c'); - assert.equal(state.street.error, null, 'later opens are not stuck'); - assert.equal(state.street.imageId, 'c'); -}); - const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); -test('every pose the viewer reports moves the marker and the followed camera', async () => { +test('every pose the viewer reports moves the marker; only the open frames the camera', async () => { const adapter = fakeAdapter(); - const { state, host, marker, followed } = harness(adapter); + const { host, marker, framing } = harness(adapter); await host.open('mapillary', 'a'); assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]); - assert.equal(followed.count, 1); - // The user walks on, with FOLLOW on: the globe camera goes along. - state.street.follow = true; + // The user walks on: the marker goes along, the globe camera stays. adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 }); assert.deepEqual(marker.at(-1), { position: { lon: 3, lat: 4 }, bearing: 180, }); - assert.equal(followed.count, 2); // Looking around in place is a pose too. adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 }); assert.equal(marker.at(-1).bearing, 270); - assert.equal(followed.count, 3); + assert.deepEqual([framing.begun, framing.started], [1, 1]); }); test('a pose on another sequence selects it, but not while the image still loads', async () => { @@ -405,7 +348,7 @@ function fakeLibrary() { } globalThis.__gevMapillaryLibrary = async () => { await gate.promise; - return { Viewer, RenderMode: {} }; + return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider }; }; return { gate, viewers }; } @@ -445,27 +388,17 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b assert.equal(viewers.live, 0, 'nothing left holding a WebGL context'); }); -test('a prewarm that gave up when the layer went off and on mid-download is followed by one that builds', async () => { +test('two mounts on one host during the first download build one Mapillary viewer', async () => { const { gate, viewers } = fakeLibrary(); const adapter = createMapillaryViewer({ source: { token: 't' } }); const element = {}; - const first = adapter.prewarm(element); - adapter.unmount(); // layer off while MapillaryJS downloads - const second = adapter.prewarm(element); // layer on again + const first = adapter.mount(element); + const second = adapter.mount(element); gate.resolve(); await Promise.all([first, second]); - assert.equal(viewers.created, 1, 'the second prewarm built the viewer'); - assert.equal(viewers.live, 1); - adapter.unmount(); -}); - -test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => { - const { gate, viewers } = fakeLibrary(); - gate.resolve(); - const adapter = createMapillaryViewer({ source: { token: 't' } }); - await adapter.mount({}); - assert.equal(viewers.instances[0].options.trackResize, false); + assert.equal(viewers.created, 1); adapter.unmount(); + assert.equal(viewers.live, 0, 'nothing left holding a WebGL context'); }); test('closing the photo stops sequence playback in the hidden viewer', async () => { @@ -481,20 +414,6 @@ test('closing the photo stops sequence playback in the hidden viewer', async () assert.equal(viewers.live, 1, 'the viewer itself stays warm'); }); -test('closing the photo or switching the layer off stops its framing flight', async () => { - const adapter = fakeAdapter(); - const { framing, host } = harness(adapter); - await host.open('mapillary', 'a'); - assert.equal(framing.started, 1, 'the open framed the photo'); - host.close(); - assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it'); - await host.open('mapillary', 'b'); - host.unmount('panoramax'); - assert.equal(framing.cancelled, 1, 'another provider leaves it alone'); - host.unmount(); - assert.equal(framing.cancelled, 2, 'layer off: likewise'); -}); - /** The real Mapillary adapter, mounted on a ready library, and its poses. */ async function mountedMapillary() { const library = fakeLibrary(); @@ -562,7 +481,7 @@ test('back-to-back Mapillary opens download only the newer image', async () => { test('a photo asks for the camera when it starts opening and frames with that ticket once loaded', async () => { const openGate = deferred(); const adapter = fakeAdapter({ openGate }); - const { state, host, framing } = harness(adapter); + const { host, framing } = harness(adapter); const opening = host.open('mapillary', 'a'); assert.equal(framing.begun, 1, 'claimed before the download'); await settle(); @@ -570,11 +489,6 @@ test('a photo asks for the camera when it starts opening and frames with that ti openGate.resolve(); assert.equal(await opening, true); assert.equal(framing.started, 1); - - // FOLLOW owns the camera already: an open neither claims nor frames. - state.street.follow = true; - assert.equal(await host.open('mapillary', 'b'), true); - assert.deepEqual([framing.begun, framing.started], [1, 1]); }); /** The harness with a second provider, `google`, whose adapter has no render modes. */ @@ -594,20 +508,6 @@ function twoProviders() { return { ...h, mapillary, google }; } -test("opening one provider's image releases another's prewarmed viewer in the shared host", async () => { - const { state, host, mapillary, google } = twoProviders(); - await host.prewarm([state.providers.get('mapillary')]); - assert.equal(mapillary.calls.unmount, 0, 'warm'); - await host.open('google', 'g-1'); - assert.equal(mapillary.calls.unmount, 1, 'not left under the panorama'); - assert.equal(google.calls.unmount, 0); - // While Google shows, Mapillary is not built again under it. - let built = 0; - mapillary.prewarm = async () => built++; - await host.prewarm([...state.providers.values()]); - assert.equal(built, 0); -}); - test('the render modes follow the viewer that is open', async () => { const { state, host } = twoProviders(); await host.open('mapillary', 'm-1'); @@ -634,23 +534,6 @@ test('a pose can date its image by month and name its place', async () => { ); }); -test('a prewarm that lands after another provider took the host is released, not built under it', async () => { - const { state, host, mapillary } = twoProviders(); - let release; - mapillary.prewarm = () => - new Promise((resolve) => { - release = resolve; - }); - const warming = host.prewarm([state.providers.get('mapillary')]); - await host.open('google', 'g-1'); // the user opens Street View meanwhile - release(); - await warming; - assert.equal(mapillary.calls.unmount, 1, 'released at once'); - // Not kept warm: the next Mapillary open mounts afresh. - await host.open('mapillary', 'm-1'); - assert.equal(state.street.providerId, 'mapillary'); -}); - test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => { const { state, host } = twoProviders(); await host.open('google', 'g-1'); @@ -685,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets host.close(); assert.equal(state.street.license, null); }); + +/** MapillaryJS 4's Graph provider, recording what it was built with. */ +class FakeGraphDataProvider { + constructor(options = {}) { + this.options = options; + } +} + +test('without a Graph endpoint the viewer gets the client token, as before', async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const adapter = createMapillaryViewer({ source: { token: 't' } }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, 't'); + assert.equal(viewer.options.dataProvider, undefined); +}); + +test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const source = createMapillarySource({ + graphEndpoint: 'https://app.example/api/mapillary/graph', + fetchImpl: async () => { + throw new Error('the viewer does not use the source transport'); + }, + }); + const adapter = createMapillaryViewer({ source }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, undefined); + const provider = viewer.options.dataProvider; + assert.ok(provider instanceof FakeGraphDataProvider); + assert.deepEqual(provider.options, { + endpoint: 'https://app.example/api/mapillary/graph/', + }); +}); + +test('a relative Graph endpoint resolves against the page, with one trailing slash', () => { + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal(graphEndpointUrl({ token: 't' }), ''); +}); diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js index 08366dba4..b4b434710 100644 --- a/src/layers/submarineCables/geometry.js +++ b/src/layers/submarineCables/geometry.js @@ -33,7 +33,7 @@ export function featureReference(feature) { }; } -export function coordsFromProperty(value) { +function coordsFromProperty(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -41,7 +41,7 @@ export function coordsFromProperty(value) { return [lon, lat]; } -export function coordsFromPoint(value) { +function coordsFromPoint(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -49,7 +49,7 @@ export function coordsFromPoint(value) { return [lon, lat]; } -export function collectLonLat(value, out) { +function collectLonLat(value, out) { if (!Array.isArray(value)) return; if (typeof value[0] === 'number' && typeof value[1] === 'number') { const lon = Number(value[0]); diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 05afde1c1..6c58a57a4 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -20,6 +20,23 @@ export function createAnimation({ parts, source, }) { + function syncRenderDemand() { + const needed = Boolean( + layerState._enabled && + (layerState._dots.length || layerState._heatJamPrim), + ); + // Height easing only runs in a held frame. Settle it before releasing, so + // a population emptied mid-correction cannot report loading forever. + if (!needed && layerState._heightPending) parts.retention.finishHeights(); + if (needed === Boolean(layerState._renderHeld)) return; + layerState._renderHeld = needed; + if (needed) { + layerState._lastAnimTime = 0; + services.render.holdContinuousRender('traffic'); + layerState._viewer?.scene?.requestRender?.(); + } else services.render.releaseContinuousRender('traffic'); + } + /** * Spawn animated dot primitives along a single road. * @@ -188,6 +205,7 @@ export function createAnimation({ : null, }); } + syncRenderDemand(); } /** @@ -206,6 +224,8 @@ export function createAnimation({ */ function animate() { + syncRenderDemand(); + if (!layerState._renderHeld) return; const now = Date.now(); // Delta time in seconds, capped to avoid jumps when returning from background tab const dt = layerState._lastAnimTime @@ -339,6 +359,13 @@ export function createAnimation({ layerState._count = 0; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; layerState._closedRoads = 0; + syncRenderDemand(); } - return { spawnDotsForRoad, animate, maybeStopLight, clearDots }; + return { + spawnDotsForRoad, + animate, + maybeStopLight, + clearDots, + syncRenderDemand, + }; } diff --git a/src/layers/traffic/flowDecode.js b/src/layers/traffic/flowDecode.js index 691496554..9a0c248dd 100644 --- a/src/layers/traffic/flowDecode.js +++ b/src/layers/traffic/flowDecode.js @@ -19,7 +19,7 @@ import { VectorTile } from '@mapbox/vector-tile'; * tree-shakeable; decoding happens client-side so the proxy stays a dumb * binary cache. * - * @module data/flowTiles + * @module layers/traffic/flowDecode */ const FLOW_LAYER_NAME = 'Traffic flow'; diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 49567a9ee..f517c99bc 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js'; import { tileToBBox } from '../../data/tomtomTiles.js'; import { decodeFlowTile } from './flowDecode.js'; +const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`; + +/** + * Whether the proxy answered a tile from its cache past the refresh window + * (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom + * fails), and when the proxy fetched it. + */ +function tileFreshness(response) { + const status = response.headers?.get?.('x-tomtom-cache') || ''; + const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at')); + return { + stale: status.startsWith('STALE'), + fetchedAt: fetchedAt > 0 ? fetchedAt : null, + }; +} + /** Own one bounded decoded flow cache; every request still passes the server tile budget. */ export function createFlowTileSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + // Freshness travels in response headers, but the tile cache keeps only + // decoded segments: hold each response's freshness until its tile decodes, + // then key it by the decoded tile, which the cache hands back on a hit. + const pending = new Map(); + const freshness = new WeakMap(); const tiles = createVectorTileSource({ - template: '/api/tomtom/flow/{z}/{x}/{y}.pbf', + template: FLOW_TILE_PATH('{z}', '{x}', '{y}'), allowedOrigin: 'http://localhost', - decode: (bytes, z, x, y) => - decodeFlowTile(bytes, z, x, y, { strict: true }) + decode: (bytes, z, x, y) => { + // Take the mark before decoding, so a tile that fails to decode + // leaves nothing behind. + const path = FLOW_TILE_PATH(z, x, y); + const entry = pending.get(path); + const mark = entry?.mark; + entry?.release(); + const segments = decodeFlowTile(bytes, z, x, y, { strict: true }) .filter(isDrivableFlowSegment) .flatMap((segment) => clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({ ...segment, coords, })), - ), - fetchImpl, + ); + if (mark) freshness.set(segments, mark); + return segments; + }, + fetchImpl: async (url, init) => { + const response = await fetchImpl(url, init); + if (response.ok && !init.signal.aborted) { + const path = String(url); + const entry = { + mark: tileFreshness(response), + release() { + if (pending.get(path) === entry) pending.delete(path); + init.signal.removeEventListener('abort', entry.release); + }, + }; + pending.get(path)?.release(); + pending.set(path, entry); + // The vector source aborts this signal for failed/cancelled body reads, + // including size-limit failures that never reach decode. + init.signal.addEventListener('abort', entry.release, { once: true }); + } + return response; + }, ttlMs: 120_000, maxTiles: 16, }); let partial = false; /** * Flow segments for bounds, with whether any covering tile failed to load - * for this request. + * for this request, whether any came from the proxy's stale cache, and when + * the oldest tile was fetched. */ async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) { const result = await tiles.fetchBounds(bounds, { @@ -44,7 +93,16 @@ export function createFlowTileSource({ coords, })), ); - return { segments, partial: result.partial === true }; + const marks = result.tiles.map((tile) => freshness.get(tile) || {}); + const times = marks + .map((mark) => mark.fetchedAt) + .filter((at) => Number.isFinite(at)); + return { + segments, + partial: result.partial === true, + stale: marks.some((mark) => mark.stale === true), + oldestTileAt: times.length ? Math.min(...times) : null, + }; } return { fetchFlowDetail, @@ -52,6 +110,9 @@ export function createFlowTileSource({ return (await fetchFlowDetail(bounds, options)).segments; }, getFlowSessionStats: () => ({ ...tiles.getStats(), partial }), - resetFlowTileCache: () => tiles.clear(), + resetFlowTileCache: () => { + for (const entry of pending.values()) entry.release(); + tiles.clear(); + }, }; } diff --git a/src/layers/traffic/footprint.js b/src/layers/traffic/footprint.js index d75947941..9a241d997 100644 --- a/src/layers/traffic/footprint.js +++ b/src/layers/traffic/footprint.js @@ -1,9 +1,9 @@ import * as Cesium from 'cesium'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; -export const RETICLE_RANGE_KM = 10; -export const RETICLE_TILE_CAP = 16; -export const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; +const RETICLE_RANGE_KM = 10; +const RETICLE_TILE_CAP = 16; +const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 94c40d935..fc13e205d 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -12,7 +12,6 @@ export function createLifecycle({ parts, source, }) { - const { holdContinuousRender, releaseContinuousRender } = services.render; const { resetFlowTileCache } = source; const methods = { @@ -81,7 +80,7 @@ export function createLifecycle({ layerState._enabled = true; layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) + parts.animation.syncRenderDemand(); layerState._lastAnimTime = 0; layerState._pointCollection.show = true; @@ -142,7 +141,7 @@ export function createLifecycle({ disable(viewer) { layerState._enabled = false; services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); - releaseContinuousRender('traffic'); + parts.animation.syncRenderDemand(); clearTimeout(layerState._fetchTimeout); clearInterval(layerState._enableKickTimer); layerState._enableKickTimer = null; diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 3c230d870..ef60e478f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -37,7 +37,14 @@ function setup(t) { visibleRoadsForAltitude: (roads) => roads, }, }; - const ctx = { state, services: {}, parts, source: {} }; + const ctx = { + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts, + source: {}, + }; parts.style = createStyle(ctx); parts.model = createModel(ctx); parts.animation = createAnimation(ctx); @@ -558,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling assert.notEqual(forward.waypoints, reverse.waypoints); }); +test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => { + const h = setup(t); + h.parts.retention.reconcile(h.roads(), 450, true); + h.settle(); + assert.equal(h.state._renderHeld, true); + const road = h.state._roads[0]; + // Republishing the sole road starts a height correction. + const corrected = h.roads(0, 120); + h.parts.retention.reconcile(corrected, 450, true); + assert.equal(h.state._heightPending, true); + assert.equal(road.heightMoving, true); + // The closure arrives before the next population pass, which then removes + // the final points after their fade, long before the correction finishes. + corrected[0].flow = { level: 0, closure: true }; + for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame(); + assert.equal(h.state._dots.length, 0); + assert.equal(h.state._heatJamPrim, null); + assert.equal(h.state._renderHeld, false); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._populationPending, false); + assert.equal(road.heightMoving, false); + assert.ok( + Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) < + 0.01, + ); + // Frames driven by other layers stay idle and cannot revive the work. + h.frame(); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._renderHeld, false); +}); + test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => { const h = setup(t); h.state._viewer.scene = { diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index 81b01f299..c20b15e41 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -175,13 +175,3 @@ export const STYLED_MIN_BASE_PX = 5; /** @const {number} Maximum tile cache entries before LRU eviction */ export const TILE_CACHE_MAX_ENTRIES = 64; - -/** - * Milliseconds the first dot paint will wait for flow data. Cached flow - * settles within this window (decode cache, 120 s TTL) and renders fully - * colored; a cold tile fetch loses the race, dots paint immediately in - * white, and recolorDotsInPlace applies the colors when flow arrives — - * field-test round 1's "takes forever to load" was the sequential wait. - */ - -export const FLOW_RENDER_RACE_MS = 250; diff --git a/src/layers/traffic/renderDemand.test.mjs b/src/layers/traffic/renderDemand.test.mjs new file mode 100644 index 000000000..df5965145 --- /dev/null +++ b/src/layers/traffic/renderDemand.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAnimation } from './animation.js'; + +test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => { + const calls = []; + const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } }; + const animation = createAnimation({ + state, + services: { + render: { + holdContinuousRender: (id) => calls.push(['hold', id]), + releaseContinuousRender: (id) => calls.push(['release', id]), + }, + }, + parts: { + retention: { clear() {} }, + rendering: { + removeHeatLines() { + state._heatJamPrim = null; + }, + }, + }, + }); + animation.syncRenderDemand(); + assert.deepEqual(calls, []); + state._dots.push({}); + animation.syncRenderDemand(); + animation.syncRenderDemand(); + assert.deepEqual(calls, [['hold', 'traffic']]); + animation.clearDots(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._heatJamPrim = {}; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['hold', 'traffic']); + state._enabled = false; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._enabled = true; + animation.clearDots(); + assert.equal(state._renderHeld, false); + state._dots.push({}); + animation.syncRenderDemand(); + assert.equal(state._renderHeld, true); +}); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index 7e148148d..bf0132b98 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -50,6 +50,7 @@ export function createRendering({ layerState._heatSlowPrim = null; } layerState._heatLineCount = 0; + parts.animation.syncRenderDemand?.(); } /** @@ -152,6 +153,7 @@ export function createRendering({ } layerState._heatLineCount = kept.length; + parts.animation.syncRenderDemand?.(); if (candidates.length > kept.length) { console.log( `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index 4bfe7bfb6..a987f64b0 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -125,6 +125,7 @@ export function createRetention({ state, parts }) { count++; } state._count = count; + parts.animation.syncRenderDemand?.(); } function reconcile(roads, height, replace) { altitude = height; @@ -241,6 +242,29 @@ export function createRetention({ state, parts }) { road.heightMoving = moving; } } + /** + * Settle every retained road on its corrected heights at once. Easing only + * runs inside the held animation frame, so once nothing is left to animate + * the remaining correction would otherwise stay pending indefinitely. + * Published roads whose record was just dropped are settled too, so a later + * held frame cannot revive their correction. + */ + function finishHeights() { + const roads = new Set(state._roads); + for (const record of records.values()) roads.add(record.road); + for (const road of roads) { + if (!road.heightMoving) continue; + for (let i = 0; i < road.waypoints.length; i++) + Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); + for (let i = 0; i < road.segmentDist.length; i++) + road.segmentDist[i] = Cesium.Cartesian3.distance( + road.waypoints[i], + road.waypoints[i + 1], + ); + road.heightMoving = false; + } + state._heightPending = false; + } function clear() { if (timer !== null) { if (typeof cancelAnimationFrame === 'function') @@ -252,5 +276,5 @@ export function createRetention({ state, parts }) { state._populationPending = false; state._heightPending = false; } - return { reconcile, easeHeights, clear, refreshCounts }; + return { reconcile, easeHeights, finishHeights, clear, refreshCounts }; } diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index affaff56d..927dc416e 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; +import { getEventListeners } from 'node:events'; +import { createFlowTileSource } from './flowSource.js'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; import { decodeFlowTile } from './flowDecode.js'; import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js'; @@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti assert.ok(publicUrls.length >= 4); assert.ok(apiUrls.length >= 4); }); + +test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => { + const fetchedAt = Date.UTC(2026, 9, 16, 8); + const reply = (status, at) => async () => + new Response(fixture, { + headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) }, + }); + const stale = createTrafficSource({ + fetchImpl: reply('STALE-BUDGET', fetchedAt), + }); + const first = await stale.fetchFlowDetail(bounds); + assert.ok(first.segments.length > 0); + assert.equal(first.stale, true); + assert.equal(first.oldestTileAt, fetchedAt); + // The decoded tile is served from cache now, and keeps its mark. + const again = await stale.fetchFlowDetail(bounds); + assert.equal(stale.getFlowSessionStats().tilesFetched, 1); + assert.equal(again.stale, true); + assert.equal(again.oldestTileAt, fetchedAt); + + const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) }); + const live = await fresh.fetchFlowDetail(bounds); + assert.equal(live.stale, false); + assert.equal(live.oldestTileAt, fetchedAt); + + const unmarked = createTrafficSource({ + fetchImpl: async () => new Response(fixture), + }); + const plain = await unmarked.fetchFlowDetail(bounds); + assert.equal(plain.stale, false); + assert.equal(plain.oldestTileAt, null); +}); + +// Capture constructor-owned Maps only while constructing the source, so tests +// can assert retained metadata without adding a diagnostic API to production. +function flowSourceWithMaps(fetchImpl) { + const maps = []; + const NativeMap = globalThis.Map; + let source; + try { + globalThis.Map = class extends NativeMap { + constructor(...args) { + super(...args); + maps.push(this); + } + }; + source = createFlowTileSource({ fetchImpl }); + } finally { + globalThis.Map = NativeMap; + } + return { source, pending: maps[0] }; +} + +test('flow freshness is released after body failures, size rejection, decode and reset', async () => { + for (const failure of ['body', 'size', 'decode']) { + let requestSignal; + let fail = true; + const { source, pending } = flowSourceWithMaps(async (url, { signal }) => { + requestSignal = signal; + return { + ok: true, + headers: new Headers({ + 'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS', + 'x-tomtom-fetched-at': fail ? '1000' : '2000', + ...(fail && failure === 'size' + ? { 'content-length': '99999999' } + : {}), + }), + async arrayBuffer() { + if (fail && failure === 'body') throw new Error('body disconnected'); + return fail && failure === 'decode' ? new Uint8Array([255]) : fixture; + }, + }; + }); + await assert.rejects(source.fetchFlowDetail(bounds)); + assert.equal(pending.size, 0, `${failure} leaves no pending metadata`); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + assert.equal(source.getFlowSessionStats().cacheEntries, 0); + fail = false; + const fresh = await source.fetchFlowDetail(bounds); + assert.equal(fresh.stale, false); + assert.equal(fresh.oldestTileAt, 2000); + assert.equal(pending.size, 0, 'successful decode releases metadata'); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + source.resetFlowTileCache(); + assert.equal(pending.size, 0); + } +}); + +test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => { + let resolveOld; + let oldSignal; + let calls = 0; + const { source, pending } = flowSourceWithMaps((url, { signal }) => { + if (++calls === 1) { + oldSignal = signal; + return new Promise((resolve) => { + resolveOld = resolve; + }); + } + return Promise.resolve( + new Response(fixture, { + headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' }, + }), + ); + }); + const old = source.fetchFlowDetail(bounds); + const rejected = assert.rejects(old, { name: 'AbortError' }); + while (!resolveOld) await new Promise((resolve) => setImmediate(resolve)); + source.resetFlowTileCache(); + const fresh = await source.fetchFlowDetail(bounds); + resolveOld( + new Response(fixture, { + headers: { + 'x-tomtom-cache': 'STALE-ERROR', + 'x-tomtom-fetched-at': '1000', + }, + }), + ); + await rejected; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(pending.size, 0); + assert.equal(getEventListeners(oldSignal, 'abort').length, 0); + assert.equal(fresh.stale, false); + assert.equal(fresh.oldestTileAt, 2000); + assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000); +}); + +test('cancelling a flow body releases its freshness before a new request', async () => { + const controller = new AbortController(); + let requestSignal; + const { source, pending } = flowSourceWithMaps(async (url, { signal }) => { + requestSignal = signal; + return { + ok: true, + headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }), + async arrayBuffer() { + assert.equal(pending.size, 1); + controller.abort(); + return fixture; + }, + }; + }); + await assert.rejects( + source.fetchFlowDetail(bounds, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(pending.size, 0); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + assert.equal(source.getFlowSessionStats().cacheEntries, 0); +}); diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 4edf4c234..595bc2a0d 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -1,6 +1,6 @@ import { normalizeRoadMode } from './roadModes.js'; import * as Cesium from 'cesium'; -import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { FLOW_BUCKET_COLORS } from './policy.js'; export function createState({ services }) { const layerState = {}; diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 6fd46211f..22b179120 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -9,7 +9,6 @@ import { FEED_STALE_AFTER_MS, MAX_VEHICLES_TOTAL, MISSED_POLLS_TO_DROP, - TRANSIT_POLL_MS, isStaleVehicleFix, transitVehicleKey, vehicleReportTimeMs, diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 11b670519..90210063a 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) { else if (hidden) { hidden = false; parts.rendering.resumePlayback(); + parts.viewport.runProximityCheck(); } } function bindVisibility() { diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 8a2d39edf..ea547f85d 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -6,7 +6,6 @@ import { FEED_EVICT_AFTER_MS, FEED_STALE_AFTER_MS, MISSED_POLLS_TO_DROP, - SELECTED_CARD_REFRESH_MS, TRANSIT_POLL_MS, VEHICLE_MAX_FIX_AGE_MS, } from './policy.js'; @@ -1407,7 +1406,6 @@ test('the selected card lands where the vehicle stopped, not short of it', async // segment ends left it a few metres behind, and then the render hold was // released with no frame scheduled to close the gap. const app = harness(t); - const start = Date.now(); app.serve('mbta', () => ({ status: 200, body: snapshot('mbta', 'MBTA', [vehicle('b', 42.36, -71.06, reported())], { @@ -3576,7 +3574,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve source: {}, parts: { flow: { ensureFlowStatus: noop }, - animation: { animate: noop, clearDots: noop }, + animation: { animate: noop, clearDots: noop, syncRenderDemand: noop }, viewport: { onCameraChanged: noop }, ingestion: { cancelActiveFetch: noop }, }, diff --git a/src/layers/transit/movement.js b/src/layers/transit/movement.js index 84128cc39..2c9a61dcd 100644 --- a/src/layers/transit/movement.js +++ b/src/layers/transit/movement.js @@ -9,10 +9,7 @@ import { correctHeight, setTimeOffsetMs, } from '../../data/contactPlayback.js'; -export const FIX_HISTORY_MAX = 128; -export const DISPLAY_LAG_MIN_MS = 25_000; -export const DISPLAY_LAG_RESET_MS = 90_000; -export const MODE_MAX_SPEED_MPS = Object.freeze({ +const MODE_MAX_SPEED_MPS = Object.freeze({ bus: 36, // ~130 km/h — a coach on a motorway tram: 28, // ~100 km/h — interurban light rail subway: 42, // ~150 km/h @@ -32,22 +29,19 @@ export const MODE_MAX_SPEED_MPS = Object.freeze({ * @param {boolean} [inferred] Whether the mode came from a feed default. * @returns {number} m/s */ -export function speedCeilingMps(mode, inferred = false) { +function speedCeilingMps(mode, inferred = false) { if (inferred) return MODE_MAX_SPEED_MPS.unknown; return MODE_MAX_SPEED_MPS[mode] ?? MODE_MAX_SPEED_MPS.unknown; } -/** Position deadband is disabled: even a short reported crawl is played. */ -export const FIX_NOISE_M = 0; - /** * Displacement (m) a glide must cover before its direction is worth believing. * Below this the course derived from it is noise; the last good one is held. */ -export const COURSE_MIN_TRAVEL_M = 12; +const COURSE_MIN_TRAVEL_M = 12; /** Maximum display-course change per second (deg). A bus is not a gyroscope. */ -export const COURSE_MAX_RATE_DEG_PER_S = 120; +const COURSE_MAX_RATE_DEG_PER_S = 120; /** * Longest stretch of elapsed time (s) one evaluation may spend. The limiter * used to bank the time since the course last CHANGED, so a vehicle that held @@ -56,15 +50,7 @@ export const COURSE_MAX_RATE_DEG_PER_S = 120; * EVALUATED, and capped here so that even a first look after a long idle * sweeps visibly instead of snapping. */ -export const COURSE_STEP_MAX_S = 0.25; - -/** - * Consecutive fixes at the same place before a vehicle is called STOPPED. - * One is not enough: a fifteen-second gap between identical fixes is also what - * a vehicle waiting at a light looks like, and so is a feed that repeated - * itself. - */ -export const STOPPED_FIXES = 2; +const COURSE_STEP_MAX_S = 0.25; /** * How long after its newest fix a vehicle stops being described as travelling. @@ -75,7 +61,7 @@ export const STOPPED_FIXES = 2; * otherwise is the same overreach as reading STOPPED off a stale status. Ninety * seconds matches the threshold at which a whole feed reads as gone quiet. */ -export const MOTION_UNKNOWN_AFTER_MS = 90_000; +const MOTION_UNKNOWN_AFTER_MS = 90_000; const EARTH_M_PER_DEG = 111_320; @@ -94,29 +80,13 @@ export function fixDistanceM(a, b) { return Math.hypot(dLat, dLon); } -/** - * Course in degrees clockwise from north, from one point to another. - * @param {{lat:number, lon:number}} from - * @param {{lat:number, lon:number}} to - * @returns {number} 0-360 - */ -export function courseBetween(from, to) { - const dLat = (to.lat - from.lat) * EARTH_M_PER_DEG; - const dLon = - (to.lon - from.lon) * - EARTH_M_PER_DEG * - Math.cos((from.lat * Math.PI) / 180); - const deg = (Math.atan2(dLon, dLat) * 180) / Math.PI; - return (deg + 360) % 360; -} - /** * Shortest signed difference between two courses, in degrees (-180, 180]. * @param {number} from * @param {number} to * @returns {number} */ -export function courseDelta(from, to) { +function courseDelta(from, to) { let delta = ((to - from + 540) % 360) - 180; if (delta === -180) delta = 180; return delta; @@ -259,7 +229,7 @@ export function attachFloorToPlace(entry, fix, heightM) { correctHeight(entry.track, scratch.seq, heightM); } } -export function displayCourse(entry, nowMs) { +function displayCourse(entry, nowMs) { if (!entry) return null; const target = Number.isFinite(entry.sample?.segmentCourseDeg) ? entry.sample.segmentCourseDeg diff --git a/src/layers/transit/movement.test.mjs b/src/layers/transit/movement.test.mjs index e758a52c8..692d2d3ac 100644 --- a/src/layers/transit/movement.test.mjs +++ b/src/layers/transit/movement.test.mjs @@ -9,7 +9,7 @@ import { displacementPlausible, } from './movement.js'; import { buildTransitSelectionCopy } from './policy.js'; -import { seek, sampleAt } from '../../data/contactPlayback.js'; +import { sampleAt } from '../../data/contactPlayback.js'; const entry = () => { const e = { mode: 'bus', modeInferred: false, courseDeg: null }; initializePlayback(e); diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 2fb5101ce..5dc17daeb 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -99,7 +99,7 @@ export const VISIBILITY_REFRESH_MS = 250; * on screen. Generous on purpose: a vehicle that crosses the edge mid-glide * should already have been moving, not pop into motion at the border. */ -export const VIEW_MARGIN_RATIO = 0.6; +const VIEW_MARGIN_RATIO = 0.6; /** * Whether a vehicle is close enough to the camera's look-at area to be worth @@ -442,10 +442,12 @@ export function buildTransitSelectionCopy( ? entry.sample.latestReportAgeMs : vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000, ); + // Seconds through 89s, then whole minutes. Rounding 90s up would skip + // "1 min" and call a minute-and-a-half-old report two minutes old. const reported = ageS < 90 ? t('Reported {seconds} s ago', { seconds: ageS }) - : t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) }); + : t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) }); // A time the feed did not give for THIS vehicle is labelled, so the card // never presents a borrowed or inferred time as the vehicle's own report. const ownTime = @@ -516,10 +518,10 @@ export function createTransitSelectedOverlayEntry(key, position, copy, mode) { } /** Modes that ordinarily run below the street, drawn on the surface above themselves. */ -export const UNDERGROUND_MODES = new Set(['subway']); +const UNDERGROUND_MODES = new Set(['subway']); /** The mode in plain words, for a card a person reads rather than scans. */ -export const TRANSIT_MODE_WORD = Object.freeze({ +const TRANSIT_MODE_WORD = Object.freeze({ bus: msg('Bus'), tram: msg('Tram'), subway: msg('Subway'), @@ -529,7 +531,7 @@ export const TRANSIT_MODE_WORD = Object.freeze({ }); /** Short, fixed-width mode word for the detection card's class field. */ -export const TRANSIT_MODE_ABBR = Object.freeze({ +const TRANSIT_MODE_ABBR = Object.freeze({ bus: 'BUS', tram: 'TRAM', subway: 'METRO', @@ -588,7 +590,7 @@ export function transitDetectionId(record, feed = null) { } /** Longest route text the single-line detection label can carry. */ -export const DETECTION_ID_MAX_CHARS = 14; +const DETECTION_ID_MAX_CHARS = 14; /** * Cut long route text with an ellipsis, so a reader can see it was cut. @@ -600,7 +602,7 @@ export const DETECTION_ID_MAX_CHARS = 14; * @param {string} text * @returns {string} Uppercase, at most DETECTION_ID_MAX_CHARS characters. */ -export function elideRouteText(text) { +function elideRouteText(text) { const value = String(text || '').toUpperCase(); if (value.length <= DETECTION_ID_MAX_CHARS) return value; return `${value.slice(0, DETECTION_ID_MAX_CHARS - 1)}\u2026`; @@ -659,6 +661,6 @@ export function transitDetectionMetric( * @param {number} [nowMs] * @returns {string} */ -export function transitVehicleState(entry, nowMs = Date.now()) { +function transitVehicleState(entry, nowMs = Date.now()) { return displayMotion(entry, nowMs).word; } diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 96d77f2a2..2309978a5 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -22,7 +22,6 @@ import { import { MARKER_PIXEL_SIZE, ROTATION_REFRESH_MS, - SELECTED_CARD_REFRESH_MS, VISIBILITY_REFRESH_MS, SELECTED_PIXEL_SIZE, TRANSIT_MODE_COLORS, diff --git a/src/layers/transit/selectionCopy.i18n.test.mjs b/src/layers/transit/selectionCopy.i18n.test.mjs index 81fdfa0bb..26de5d28a 100644 --- a/src/layers/transit/selectionCopy.i18n.test.mjs +++ b/src/layers/transit/selectionCopy.i18n.test.mjs @@ -39,6 +39,26 @@ test('en: the copy is unchanged with and without the i18n t()', () => { ]); }); +test('a report 90 seconds old reads as 1 minute, not 2', () => { + setLocale('en'); + const line = (ageMs) => + buildTransitSelectionCopy( + feed, + { + id: 'age', + lat: 1, + lon: 1, + timestamp: Math.floor((now - ageMs) / 1000), + timestampSource: 'vehicle', + }, + 'bus', + now, + ).details[1]; + assert.equal(line(89_000), 'Reported 89 s ago'); + assert.equal(line(90_000), 'Reported 1 min ago'); + assert.equal(line(120_000), 'Reported 2 min ago'); +}); + for (const locale of ['es', 'ru']) { test(`${locale}: the Transit card is in the UI language`, () => { setLocale(locale); diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 641fab5a2..730126f10 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) { } function runProximityCheck() { + if ( + globalThis.document?.hidden || + globalThis.document?.visibilityState === 'hidden' + ) + return; if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index da0a2ce82..64e115637 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -12,8 +12,6 @@ export function createCards({ layer, options, }) { - const { state } = vesselState; - function updateSelectedVesselHud(record) { const el = document.getElementById('hud-ais-vessel'); if (!el) return; diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index 75e5cdd67..9ab06fbb8 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -1,8 +1,3 @@ -export { - PARTIAL_RETENTION_MS, - SELECTED_PIN_REFRESHES, - AIS_FIRST_CONNECT_LABEL, -} from './recordPolicy.js'; import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; import { msg } from '../../i18n/index.js'; diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 88e5aa2ec..96c5df6eb 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -10,7 +10,7 @@ export function createSelection({ options, }) { const { state } = vesselState; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const { requestWorldFocus } = services.worldFocus; const { selectEntityContext, @@ -37,7 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); const pickedId = resolvePickId(picked); let record = pickedId ? state.records.byMmsi.get(pickedId) : null; const rawId = picked?.id ?? picked?.primitive?.id; diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index db62ea98a..757764fe6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -296,10 +296,6 @@ export function createWeatherLayer({ motion?.addEventListener?.('change', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility); registerClock(); - removeCamera = viewer.camera.moveEnd?.addEventListener(() => { - checkHost(); - if (enabled) notify(); - }); }, attachShellServices(services) { imageryHost = @@ -315,10 +311,18 @@ export function createWeatherLayer({ }, enable() { enabled = true; + if (!removeCamera) { + removeCamera = viewer.camera.moveEnd?.addEventListener(() => { + checkHost(); + notify(); + }); + } registerClock(); }, disable() { enabled = false; + removeCamera?.(); + removeCamera = null; unregisterClock?.(); unregisterClock = null; ++generation; @@ -357,7 +361,7 @@ export function createWeatherLayer({ signal: controller.signal, }); if (!enabled || controller.signal.aborted || request !== controller) - return false; + return enabled && !signal?.aborted && request !== controller; if (snapshot.unavailable) { error = msg( 'Weather source unavailable; previous observation retained', @@ -376,7 +380,9 @@ export function createWeatherLayer({ ) await show(snapshot.latest, controller.signal); return ( - !controller.signal.aborted && request === controller && enabled + enabled && + !signal?.aborted && + (!controller.signal.aborted || request !== controller) ); } const time = @@ -388,9 +394,14 @@ export function createWeatherLayer({ (satellite && rendering.getDiagnostics().infrared !== infrared) ) await show(time, controller.signal); - return !controller.signal.aborted && request === controller && enabled; + return ( + enabled && + !signal?.aborted && + (!controller.signal.aborted || request !== controller) + ); } catch (cause) { - if (controller.signal.aborted || request !== controller) return false; + if (controller.signal.aborted || request !== controller) + return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Weather unavailable'); stop(); return true; diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index b25bcceb2..0f2fad68b 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -14,6 +14,8 @@ const GLOBAL_TILE_MAXIMUM_LEVEL = 3; // Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB. const MAX_MOSAICS = 6; const MAX_PREFETCH_TILES = 8; +// How often a staged frame waiting out its quiet interval rechecks readiness. +export const READY_WAKE_MS = 150; /** Globe imagery: own at most a displayed and a staging frame. Use native Cesium * tile scheduling, projection and texture disposal; the application clock is never touched. */ @@ -126,6 +128,7 @@ function createGlobeRendering({ frame.offRender?.(); frame.offCamera?.(); clearTimeout(frame.timeout); + clearTimeout(frame.wake); for (const request of frame.requests) request.cancel?.(); frame.requests.clear(); frame.retries.clear(); @@ -277,6 +280,8 @@ function createGlobeRendering({ frame.offCamera?.(); frame.offCamera = null; clearTimeout(frame.timeout); + clearTimeout(frame.wake); + frame.wake = null; frame.offAbort?.(); if (ok) { lastError = null; @@ -450,8 +455,20 @@ function createGlobeRendering({ if (++settled >= 2) finish(true); else viewer.scene.requestRender(); } else settled = 0; - if (incoming === frame && frame.pending === 0 && frame.loaded > 0) - viewer.scene.requestRender(); + // Waiting out the quiet interval needs another readiness check + // later, not a render every frame: wake on a timer so the render + // governor's frame cap still holds while a frame is staged. + if ( + incoming === frame && + frame.pending === 0 && + frame.loaded > 0 && + !frame.wake + ) + frame.wake = setTimeout(() => { + frame.wake = null; + if (!frame.closed && incoming === frame) + viewer.scene.requestRender(); + }, READY_WAKE_MS); }); viewer.scene.requestRender(); onChange(); diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index cf004476f..5386bfad7 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -4,7 +4,7 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; // the weather layer translates them where it shows them (t / translateKnown). const msg = (text) => text; -export const WEATHER_PRODUCTS = Object.freeze([ +const WEATHER_PRODUCTS = Object.freeze([ 'radar', 'clouds', 'clouds-regional', diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 1a4ba5177..a42485ed0 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; import * as Cesium from 'cesium'; @@ -242,6 +242,50 @@ test('a ready weather frame does not wait for unrelated terrain, but requires su h.rendering.clear(); }); +test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; + const h = renderingHarness({ now: () => now }); + h.viewer.scene.globe.tilesLoaded = false; + const loaded = h.rendering.setFrame(snapshot, times[0]); + const tile = deferred(); + h.providers[0].response = tile; + const request = h.providers[0].requestImage(0, 0, 0, {}); + tile.resolve({}); + await request; + const before = h.renders(); + for (let i = 0; i < 10; i++) h.postRender.emit(); + assert.equal( + h.renders(), + before, + 'renders while waiting do not request further renders', + ); + t.mock.timers.tick(READY_WAKE_MS - 1); + assert.equal(h.renders(), before); + t.mock.timers.tick(1); + assert.equal(h.renders(), before + 1, 'one wake per interval'); + now = 200; + h.postRender.emit(); + h.postRender.emit(); + assert.equal(await loaded, true); + const settledRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), settledRenders, 'commit clears the wake timer'); + + const abandoned = h.rendering.setFrame(snapshot, times[1]); + const second = deferred(); + h.providers.at(-1).response = second; + const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {}); + second.resolve({}); + await secondRequest; + h.postRender.emit(); + h.rendering.clear(); + assert.equal(await abandoned, false); + const clearedRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer'); +}); + test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => { let now = 0; const h = renderingHarness({ now: () => now }); @@ -694,7 +738,11 @@ test('superseding a pending stage preserves the newer manifest loading state', a await flush(); const newer = h.layer.update(); await flush(); - assert.equal(await old, false); + assert.equal( + await old, + true, + 'replacement is accepted by the enable lifecycle', + ); assert.equal( h.layer.getStats().loading, true, @@ -2311,3 +2359,44 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) { assert.equal(h.viewer.camera.moveEnd.size, 0); }); } + +test('a product replacement accepts supersession but never paints the obsolete first response', async () => { + const pending = []; + const h = layerHarness({ + id: 'weather-satellite', + feed: { + getSnapshot: (args) => { + const task = deferred(); + pending.push({ ...args, ...task }); + return task.promise; + }, + }, + }); + const first = h.layer.update(); + h.layer.setParams({ product: 'clouds' }); + assert.equal(pending.length, 2); + pending[0].resolve({ ...snapshot, product: 'clouds-regional' }); + assert.equal(await first, true); + assert.equal(h.stages.length, 0); + pending[1].resolve({ ...snapshot, product: 'clouds' }); + await flush(); + h.stages.at(-1).finish(); + await flush(); + assert.equal(h.layer.getParams().product, 'clouds'); + h.layer.destroy(); +}); + +test('disabled weather releases its camera listener and re-enabling binds only once', () => { + const h = layerHarness(); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work'); + h.layer.enable(); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.destroy(); + assert.equal(h.moveEnd.size, 0); +}); diff --git a/src/layers/wind/gpuRendering.js b/src/layers/wind/gpuRendering.js index 27d71dcd5..0873fa63b 100644 --- a/src/layers/wind/gpuRendering.js +++ b/src/layers/wind/gpuRendering.js @@ -6,8 +6,8 @@ import { WIND_NARROW_PATH_LIMIT, } from './streamlines.js'; -export const WIND_FADE_LOW_METERS = 15000; -export const WIND_FADE_HIGH_METERS = 60000; +const WIND_FADE_LOW_METERS = 15000; +const WIND_FADE_HIGH_METERS = 60000; // Original material: a persistent fine curve plus a moving, tapered highlight. // The integer part of s identifies a path; its fractional part follows the wind. diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index c76e2501c..aa6161f06 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -180,7 +180,7 @@ export function createWindLayer({ overlay: requestedScalar(), }); if (!enabled || controller.signal.aborted || request !== controller) - return false; + return enabled && !signal?.aborted && request !== controller; manifest = snapshot; error = null; if (sampledPosition) sample(sampledPosition); @@ -194,7 +194,8 @@ export function createWindLayer({ } return true; } catch (cause) { - if (controller.signal.aborted || request !== controller) return false; + if (controller.signal.aborted || request !== controller) + return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Wind source unavailable'); return true; } finally { diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index ac843f62a..175381031 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -97,7 +97,7 @@ const complete = (model, extra = {}) => ({ }); test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => { const requests = []; - const { layer, calls } = harness({ + const { layer } = harness({ getSnapshot: async (args) => { requests.push(args); return complete( @@ -412,3 +412,36 @@ test('wind unit chips appear only alongside a speed legend, including canvas tra for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); } layer.destroy(); }); + +test('a parameter replacement during manager enable does not roll the layer back', async () => { + const { LayerLifecycle } = await import('../../data/lifecycle.js'); + const pending = []; + const rendering = Object.fromEntries( + ['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [ + name, + () => {}, + ]), + ); + const layer = createWindLayer({ + feed: { + getSnapshot: (args) => + new Promise((resolve) => pending.push({ ...args, resolve })), + }, + createRendering: () => rendering, + }); + const manager = new LayerLifecycle({ container: {} }); + manager.register(layer); + const enabled = manager.setEnabled('wind', true); + for (let i = 0; i < 20 && !pending.length; i++) await Promise.resolve(); + layer.setParams({ model: 'ifs' }); + await Promise.resolve(); + pending[0].resolve(snapshot('gfs')); + assert.equal(await enabled, true); + assert.equal(manager.isEnabled('wind'), true); + assert.equal(layer.getStats().loading, true); + pending[1].resolve(snapshot('ifs')); + for (let i = 0; i < 10; i++) await Promise.resolve(); + assert.equal(layer.getStats().model, 'IFS'); + assert.equal(layer.getStats().count, 4); + await manager.destroyAll(); +}); diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 01d00b1d6..ff9f63c9c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -93,22 +93,28 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { - const ui = readShellSource(); const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); - assert.match(handler, /this\.showStatus\(message\)/); - assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); - assert.doesNotMatch(handler, /this\._showToast\(message\)/); - assert.doesNotMatch(handler, /pushCockpitSignal/); + const present = shellMethod('_presentAfterStartupCover').toString(); + assert.match( + handler, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + ); + assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match(present, /canPresentDeferredStatusNotice\(/); + for (const source of [handler, present]) { + assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /pushCockpitSignal/); + } assert.match(handler, /result\.classification === 'pending'/); assert.match(handler, /state: 'acquiring'/); assert.match(handler, /persistent: true/); assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); - assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'/); + assert.match(handler, /result\.classification === 'cancelled'\) return/); }); test('universal notice masks active loading only for its own fixed dwell', () => { @@ -629,7 +635,7 @@ test('the loading ticker never runs hidden and stops after loading and notices s // safety-net poll, so its `if (document.hidden) return;` is correct there // and must not be confused with this self-stopping ticker's leak. const armBody = ui.slice(ui.indexOf('_armLoadingFeedbackTicker() {')); - assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in ui.js'); + assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in the UI shell source'); const arm = armBody.slice(0, armBody.indexOf('\n }\n') + 5); // 1. The ARM guard itself refuses while hidden. Previously the only hidden diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 79cc92489..6c63406ee 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -1,5 +1,4 @@ import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -95,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => { /stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/, ); - const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {'); + const reassert = ui.indexOf( + " _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {", + ); assert.ok(reassert > 0, '_reassertNavigationHandoff is missing'); assert.match( ui.slice(reassert, reassert + 900), - /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/, + /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/, ); // …and the policy only reaches `release` after its authority checks pass. diff --git a/src/locationStatus.js b/src/locationStatus.js index 1d90582c9..2c9e406f3 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -64,5 +64,3 @@ export function locationMiniStatus({ return { ...EMPTY }; } - -export default locationMiniStatus; diff --git a/src/locations.js b/src/locations.js index 1c5e94f2f..d17186905 100644 --- a/src/locations.js +++ b/src/locations.js @@ -3,6 +3,7 @@ import { isUnavailableCapability } from './sources/capability.js'; import { createOverpassFeatureSource } from './sources/overpassFeatures.js'; import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; +import { greatCircleKm } from './geoDistance.js'; import { viewportBias, placesNearViewRecovery, @@ -648,18 +649,6 @@ export const PLACE_ANCHOR_OFFSET_RATIO = 0.15; */ const PLACE_FALLBACK_HALF_SPAN_KM = 20; -/** Great-circle distance in km (small enough here that the spherical model is fine). */ -function greatCircleKm(lat1, lng1, lat2, lng2) { - const dLat = Cesium.Math.toRadians(lat2 - lat1); - const dLng = Cesium.Math.toRadians(lng2 - lng1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(Cesium.Math.toRadians(lat1)) * - Math.cos(Cesium.Math.toRadians(lat2)) * - Math.sin(dLng / 2) ** 2; - return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a))); -} - /** * Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests. * @@ -694,10 +683,8 @@ export function placeFramingViewport( return viewport; const offsetKm = greatCircleKm( - anchorLat, - anchorLng, - metrics.centerLat, - metrics.centerLng, + { lat: anchorLat, lon: anchorLng }, + { lat: metrics.centerLat, lon: metrics.centerLng }, ); if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport; diff --git a/src/locations.test.mjs b/src/locations.test.mjs index c225488a3..8d2a9503c 100644 --- a/src/locations.test.mjs +++ b/src/locations.test.mjs @@ -152,6 +152,124 @@ test('regionFramingPlan: E-W elongated region looks along the east axis', () => assert.equal(plan.headingDeg, 90); }); +// Compatibility guard for inherited geocode authority, not a newly fixed bug. +test('search retains main geocode authority for Gold Coast, Australia', async () => { + const viewer = stubViewer(); + const result = await runSearch(viewer, {}, { + query: 'Gold Coast', + result: { + formatted_address: 'Gold Coast, Queensland, Australia', + types: ['locality', 'political'], + geometry: { + location: { lat: -28, lng: 153.4 }, + viewport: boxOf(-28.2, 153.2, -27.8, 153.6), + }, + }, + }); + assert.equal(result.label, 'Gold Coast, Queensland, Australia'); + assert.equal(result.navigationMode, 'city-overview'); + assert.equal(viewer.flights.length, 1); + const flown = flownRectangleDegrees(viewer); + assert.ok(Math.abs((flown.south + flown.north) / 2 + 28) < 0.000001); + assert.ok(Math.abs((flown.west + flown.east) / 2 - 153.4) < 0.000001); +}); + +function alpsViewer() { + const viewer = stubViewer(); + Object.assign(viewer.camera.positionCartographic, { + latitude: Cesium.Math.toRadians(46.5), + longitude: Cesium.Math.toRadians(10), + height: 248_000, + }); + return viewer; +} + +test('a successful nearby recovery resolves after a geocoder miss', async () => { + const viewer = alpsViewer(); + const result = await searchAndFlyTo(viewer, 'the Alps', { + placeSearch: { async geocode() { return { place: null, answered: true }; } }, + recoverNearView: async () => ({ + lat: 46.6, + lon: 10.1, + label: 'The Alps local landmark', + types: ['locality', 'political'], + viewport: { low: { latitude: 46.5, longitude: 10 }, high: { latitude: 46.7, longitude: 10.2 } }, + }), + }); + assert.equal(result.label, 'The Alps local landmark'); + assert.equal(result.navigationMode, 'city-overview'); + assert.equal(viewer.flights.length, 1); +}); + +test('a geocoder miss returns null even over a namesake region', async () => { + for (const answered of [true, false]) { + const viewer = alpsViewer(); + const result = await searchAndFlyTo(viewer, 'the Alps', { + placeSearch: { async geocode() { return { place: null, answered }; } }, + recoverNearView: async () => null, + }); + assert.equal(result, null); + assert.equal(viewer.flights.length, 0); + } +}); + +test('a geocoder rejection propagates without nearby recovery or a flight', async () => { + const viewer = alpsViewer(); + const failure = new Error('geocoder failed'); + let recoveries = 0; + await assert.rejects(searchAndFlyTo(viewer, 'the Alps', { + placeSearch: { async geocode() { throw failure; } }, + recoverNearView: async () => { recoveries += 1; return null; }, + }), (error) => error === failure); + assert.equal(recoveries, 0); + assert.equal(viewer.flights.length, 0); +}); + +test('search cancellation before or during geocoding cannot issue a flight', async () => { + for (const preAborted of [true, false]) { + const viewer = alpsViewer(); + const controller = new AbortController(); + if (preAborted) controller.abort(); + let geocodes = 0; + let recoveries = 0; + await assert.rejects(searchAndFlyTo(viewer, 'the Alps', { + signal: controller.signal, + placeSearch: { + async geocode(_query, { signal }) { + geocodes += 1; + assert.equal(signal, controller.signal); + controller.abort(); + return { place: null, answered: true }; + }, + }, + recoverNearView: async () => { recoveries += 1; return null; }, + }), { name: 'AbortError' }); + assert.equal(geocodes, preAborted ? 0 : 1); + assert.equal(recoveries, 0); + assert.equal(viewer.flights.length, 0); + } +}); + +test('cancellation during nearby recovery cannot issue a flight', async () => { + const viewer = alpsViewer(); + const controller = new AbortController(); + let authorityChecks = 0; + await assert.rejects(searchAndFlyTo(viewer, 'the Alps', { + signal: controller.signal, + placeSearch: { async geocode() { return { place: null, answered: true }; } }, + recoverNearView: async () => { + controller.abort(); + return { + lat: 46.6, lon: 10.1, label: 'Alpine landmark', + types: ['locality'], + }; + }, + beforeFly: () => { authorityChecks += 1; return true; }, + }), { name: 'AbortError' }); + assert.equal(authorityChecks, 0); + assert.equal(viewer.flights.length, 0); +}); + test('regionFramingPlan: park- and lake-scale viewports keep full framing', () => { // Zilker Park (~3 km). assert.equal(regionFramingPlan({ diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index b7beec325..0fa66e0da 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,6 +1,5 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { shellMethod } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; -import { StyleManager } from './ui/applicationShell.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -8,7 +7,6 @@ import { readFileSync } from 'node:fs'; import { msg, t } from './i18n/index.js'; // Exercise the installed event routes and central close method, without WebGL. -const source = readShellSource(); const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); diff --git a/src/maps/availability.js b/src/maps/availability.js index 30ef3e6c6..ceace9968 100644 --- a/src/maps/availability.js +++ b/src/maps/availability.js @@ -1,7 +1,4 @@ -import { - keySetupRequirement, - keySetupRequirementEnvVars, -} from '../keySetupCore.mjs'; +import { keySetupRequirementEnvVars } from '../keySetupCore.mjs'; import { t } from '../i18n/index.js'; /** * Why Google 3D is unavailable, phrased so the tooltip and toast recommend the diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 7896c8f5c..089730139 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,7 +38,11 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); - if (!response.ok) return null; + if (response.status === 204) return null; + if (!response.ok) { + await response.body?.cancel(); + return null; + } return parseToken(await response.json(), now()); } catch { return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index 87b7cf8dc..10213db0c 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => { assert.equal(calls.length, 2); }); +test('a refused token response releases its unread body before returning', async () => { + for (const status of [404, 429, 500]) { + let cancelled = false; + const response = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { status }, + ); + const tokens = createGoogleTokenSource({ fetchImpl: async () => response }); + assert.equal(await tokens.token(), null); + assert.equal(cancelled, true, `HTTP ${status} body released`); + } +}); + test('a server that does not offer tokens yields none', async () => { for (const answer of [ new Response('Not found', { status: 404 }), @@ -62,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => { assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); } }); + +test('an empty 204 response explicitly means no Google token is available', async () => { + const { calls, fetchImpl } = server([ + new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }), + ]); + const tokens = createGoogleTokenSource({ fetchImpl }); + assert.equal(await tokens.token(), null); + assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]); +}); diff --git a/src/maps/imageryComparison.js b/src/maps/imageryComparison.js index 5586bd86e..cf7d266ae 100644 --- a/src/maps/imageryComparison.js +++ b/src/maps/imageryComparison.js @@ -1,5 +1,5 @@ /** Basemap that before/after imagery comparisons are read against. */ -export const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; +const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; const SWITCH_POLICIES = new Set(['preserve', 'esri', 'esri-if-photoreal']); const leases = new WeakMap(); diff --git a/src/maps/terrainRetry.js b/src/maps/terrainRetry.js index 26fc95576..6cf0fe2af 100644 --- a/src/maps/terrainRetry.js +++ b/src/maps/terrainRetry.js @@ -24,7 +24,7 @@ */ export const TERRAIN_RETRY_ATTEMPTS = 3; -export const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); +const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); export const TERRAIN_RETRY_BASE_MS = 750; export const TERRAIN_RETRY_MAX_MS = 15_000; export const TERRAIN_RETRY_SPREAD_MS = 1_500; diff --git a/src/navigationPolicyFrame.test.mjs b/src/navigationPolicyFrame.test.mjs index a861870cf..5110232f1 100644 --- a/src/navigationPolicyFrame.test.mjs +++ b/src/navigationPolicyFrame.test.mjs @@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { TRACKABLE_FAMILIES } from './agent/worldPacket.js'; import { setLocale } from './i18n/index.js'; import { COCKPIT_REFUSAL_NOUNS, @@ -68,10 +69,8 @@ const NOUN_LITERAL = /\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g; function trackableKinds() { - const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8'); - const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/); - assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js'); - return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]); + // This is shared with voice; test the exported owner, not its file position. + return TRACKABLE_FAMILIES.map(({ kind }) => kind); } // Non-literal arguments: what each one can be. A wrapper's own `noun` (or all diff --git a/src/nominatimGeocode.js b/src/nominatimGeocode.js index b7dd6145a..4a5a1948e 100644 --- a/src/nominatimGeocode.js +++ b/src/nominatimGeocode.js @@ -166,6 +166,8 @@ export function nominatimViewboxFromBounds(bounds) { const neLat = nominatimLatitude(match[3]); const neLng = nominatimLongitude(match[4]); if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null; - if (swLat >= neLat) return null; + // One Nominatim viewbox cannot express a wrapped interval. Omit this soft + // preference rather than biasing the search toward the opposite hemisphere. + if (swLat >= neLat || swLng > neLng) return null; return `${swLng},${neLat},${neLng},${swLat}`; } diff --git a/src/nominatimGeocode.test.mjs b/src/nominatimGeocode.test.mjs index b139ad820..8517649d5 100644 --- a/src/nominatimGeocode.test.mjs +++ b/src/nominatimGeocode.test.mjs @@ -173,3 +173,12 @@ test('a viewport hint is sent only when every edge is a real coordinate', () => `${JSON.stringify(bounds)} must not become a viewbox`, ); }); + +test('wrapped viewports omit the Nominatim bias without rejecting the search', () => { + assert.equal(nominatimViewboxFromBounds('-20,170|-10,-171'), null); + assert.equal(nominatimViewboxFromBounds('50,175|60,-165'), null); + assert.equal( + nominatimViewboxFromBounds('-20,170|-10,180'), + '170,-10,180,-20', + ); +}); diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 717c826a2..ccf3cbd59 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1155,11 +1155,7 @@ export function isOverlayPointVisible( * Return true when a rectangle intersects any cached UI exclusion box. * `count` lets pooled buffers expose a live prefix without truncating. */ -export function overlayRectIntersectsAny( - rect, - exclusions, - count = exclusions.length, -) { +function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) { for (let i = 0; i < count; i++) { const other = exclusions[i]; if ( @@ -1177,7 +1173,7 @@ export function overlayRectIntersectsAny( * Like `overlayRectIntersectsAny`, but only against exclusions that composite * BELOW the host — the ones a placement may never overlap at any cost. */ -export function overlayRectIntersectsAnyHard( +function overlayRectIntersectsAnyHard( rect, exclusions, count = exclusions.length, diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index e29785d48..2ff6d6c8c 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojson.js'; +import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojsonCore.js'; import { FIRMS_AMBIENT_COHORT_LIMIT } from '../data/firmsLabels.js'; import { vesselOverlayCohortLimit } from '../data/vesselLabels.js'; import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js'; diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index a9549c85d..d3d5cfc6d 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -2,11 +2,8 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; const source = readShellSource(); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 2a472e246..a13f5c04b 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { const ui = readShellSource(); - const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); + // The long form spells the share format's panel table (one source of truth). + const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8'); assert.match( ui, @@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre 'pin and unpin must update the share hash even when collapse state is unchanged', ); assert.match(ui, /\{ id: 'param-slider-panel' \}/); - assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/); + assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/); }); test('parameterized Display presets keep one stable scroll owner', () => { diff --git a/src/pinokioLauncherContract.test.mjs b/src/pinokioLauncherContract.test.mjs index 24c9a9908..78748ff32 100644 --- a/src/pinokioLauncherContract.test.mjs +++ b/src/pinokioLauncherContract.test.mjs @@ -113,6 +113,9 @@ test('Pinokio menu resolves the nested install marker and exposes each lifecycle runtime.url = url; const items = await menu(kernel, info); assert.equal(items.filter((item) => item.default).length, 1); + const ready = installed && running === 'start.js' && Boolean(url); + assert.equal(items[0].popout, ready ? true : undefined); + assert.ok(items.slice(1).every((item) => item.popout === undefined)); return items.map(({ text, href, default: isDefault = false }) => ({ text, href, default: isDefault })); }; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index c6a7269ab..4f9ef7602 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,8 +2,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { providerCooldownMs } from '../server/providers/space/cooldown.js'; import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; +import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] .map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8')) @@ -22,7 +24,13 @@ function extract(name) { function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { - readResponseTextCapped, coalesceProxyRequest, + readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, + // The rate-limited proxies reach for these; give them the real ones so a + // fixture request is throttled exactly as production would be. + makeRateLimiter, clientKey, + // terrain.js gates cross-site requests first; fixture requests carry no + // browser headers, so admit them like a loopback tool. + admitSameSiteKeyless: () => false, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, @@ -110,7 +118,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met test('CelesTrak unexpected failures hide details', async () => { const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } }); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 500); assert.equal(res.body, 'celestrak proxy error'); assert.equal(res.headers['x-tle-cache'], 'ERROR'); @@ -119,7 +127,7 @@ test('CelesTrak unexpected failures hide details', async () => { test('CelesTrak retains invalid-group and unavailable responses', async () => { const app = fixture('celestrakProxy'); assert.equal((await app.request('/../')).status, 400); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 502); assert.equal(res.headers['x-tle-cache'], 'NONE'); }); @@ -128,13 +136,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => { let now = Date.now(); let calls = 0; const app = fixture('celestrakProxy', { - Date: { now: () => now }, + Date: class extends Date { static now() { return now; } }, fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); }, }); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS'); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT'); now += 7 * 3600_000; - const stale = await app.request('/active'); + const stale = await app.request('/stations'); assert.equal(stale.status, 200); assert.equal(stale.body, '1 valid-fixture-TLE'); assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR'); diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 23ad778d5..dfe5b4257 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,5 +1,6 @@ import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; +import { realtimeInstructions } from '../server/providers/openai/instructions.js'; import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; @@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 30-tool inventory', () => { +test('Realtime schema exposes the authoritative 31-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 30); + assert.equal(tools.length, 31); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 30, 'tool names are unique'); + assert.equal(new Set(names).size, 31, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); + assert.ok(names.includes('set_voice_inactivity_timeout')); assert.ok(names.includes('select_nearest_aircraft')); assert.ok(names.includes('control_radio')); assert.ok(names.includes('next_satellite_pass')); @@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => { }); test('Context panel opening stays distinct from Contacts activation', () => { - const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); - assert.ok(start >= 0, 'panel-routing instruction is missing'); - const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /"Open Context" means only set_panel_open/); - assert.match(text, /does not activate a Context sub-mode/); - assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/); - assert.match(text, /expands the parent Context panel before activating Contacts/); + const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.ok(text, 'panel-routing instruction is missing'); + assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/); + assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/); + assert.match(text, /"Open Contacts" is set_context_mode contacts/); + assert.match(text, /expands Context itself/); }); test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { @@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(text, /nearest airborne result/); assert.match(text, /healthy fallback feed is valid data/i); assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/); - assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/); + const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/); assert.match(text, /set_context_mode, or control_cockpit/); const byName = new Map(realtimeTools().map((tool) => [tool.name, tool])); - assert.match(byName.get('set_context_mode').description, /explicitly requests/); - assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i); - assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/); - assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/); + assert.match(byName.get('set_context_mode').description, /only when explicitly requested/); + assert.match(routing, /selecting\/viewing an aircraft never enters them/); + assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/); assert.equal( byName.get('fly_to_location').parameters.properties.waitForArrival.type, 'boolean', @@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(nearest.description, /Atomically/); assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); - assert.match(nearest.description, /does not open Contacts or Cockpit/); }); test('the two Context/Cockpit tools pin their enums and required arguments', () => { @@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => { const panel = byName.get('set_panel_open'); assert.deepEqual( panel.parameters.properties.panelId.enum, - ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'], + ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'], ); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); @@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'set_context_mode', 'control_cockpit', 'set_panel_open', + 'set_voice_inactivity_timeout', 'get_current_view_state', 'fly_to_location', 'select_nearest_aircraft', @@ -209,6 +210,35 @@ test('no unchanged Realtime tool definition drifts silently', () => { const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + // Globe rotation adds one motion and clarifies the two affected descriptions. + // Check those edits explicitly, then keep the legacy pin for every other field. + const camera = unchanged.find((tool) => tool.name === 'move_camera'); + assert.deepEqual(camera.parameters.properties.motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + assert.equal( + camera.description, + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', + ); + assert.equal( + camera.parameters.properties.direction.description, + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + ); + assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); + camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; + camera.parameters.properties.motion.enum = + camera.parameters.properties.motion.enum.filter( + (motion) => motion !== 'rotate_globe', + ); + camera.description = + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").'; + camera.parameters.properties.direction.description = + 'Required except for orbit (defaults right/clockwise) and stop.'; assert.equal(unchanged.length, 14); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index dfd951377..766079aa4 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js'; // actually decides fresh-boot layer state — including the early return that // makes each layer's own initializer the operative default. // 2. Scope feather. Owner: "I like to hide feather" set it to 0% on -// 2026-08-22; the owner revised that to 8% on 2026-08-23 and locked 11% -// on 2026-08-24, a soft -// edge. The hard crop is still one drag away, and that is pinned too. +// 2026-08-22; later revisions were 8%, then 11%, then 100% on +// 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available. // 3. Detection ON (Dense @ 75%) for EVERY style, Normal included. // Owner: "detect should also be on by default." // 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with @@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js'; import { SCOPE_FEATHER_RATIO_DEFAULT, getScopeMaskFeather, + isScopeMaskEnabled, scopeMaskGeometry, setScopeMaskFeather, } from './scopeMask.js'; @@ -75,32 +75,40 @@ function managerForHash(hash) { } // --------------------------------------------------------------------------- -// 2. Scope feather — a subtle soft edge on a first run +// 2. Scope ON with 100% feather on a first run // --------------------------------------------------------------------------- -test('first run opens with a subtle scope feather, at every surface that decides it', () => { - assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11, - 'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings'); - assert.equal(getScopeMaskFeather(), 0.11, +test('first run opens with Scope ON and 100% feather at every default surface', () => { + assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled'); + assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25'); + assert.equal(getScopeMaskFeather(), 1, 'and the live module starts there, not merely documents it'); // The slider and its readout are the same default rendered as markup — a // fresh boot applies no restore, so a stale value here would show one number // over a mask drawn at another. - assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/, - 'index.html: the feather slider ships at 11'); - assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%]*\svalue="100"/, + 'index.html: the feather slider ships at 100'); + assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%]*\schecked\s*\/>/, + 'the native Scope checkbox starts checked'); + // The link this session generates must describe the mask this session draws, // for the window before the first _syncShareState. - assert.match(shareSource, /this\._scopeFeatherPct = 11;/, + assert.match(shareSource, /this\._scopeFeatherPct = 100;/, 'sharelink.js: the generator starts from the same value the mask starts at'); + const params = managerForHash('')._buildHashParams(); + assert.equal(params.get('sc'), '1'); + assert.equal(params.get('scf'), '100'); }); -test('an explicit feather still wins over the subtle default', () => { +test('an explicit feather still wins over the 100% default', () => { // A link is authored state. The new default governs a session that said // nothing; it must never overwrite one that said something. + assert.equal(managerForHash('#lat=10&lon=20&scf=11').parseInitialHash().scopeFeatherPct, 11, + 'links carrying the previous default preserve their authored feather'); assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35); assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64); assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0, @@ -113,23 +121,28 @@ test('an explicit feather still wins over the subtle default', () => { 'a pre-scf link restores the author\'s view, not the new default'); }); -test('the subtle default did not weaken the feather control, and 0 is still reachable', () => { +test('share links preserve explicit Scope OFF and feather choices over the defaults', () => { + const shared = managerForHash('#lat=10&lon=20&sc=0&scf=11').parseInitialHash(); + assert.equal(shared.scopeEnabled, false); + assert.equal(shared.scopeFeatherPct, 11); +}); + +test('the 100% default retains the expanded feather range and hard edge', () => { // The cheap way to move a default would be to nerf the control. Prove the // slider still spans its full range and the geometry is still DERIVED from // the ratio — a check that would pass vacuously if it only ever saw one value. - assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/, + assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/, 'the slider still offers the whole range'); const previous = getScopeMaskFeather(); try { - for (const ratio of [0.35, 0.7, 1]) { + for (const ratio of [0.35, 0.7, 1, 1.75, 2]) { setScopeMaskFeather(ratio); const geo = scopeMaskGeometry(1200, 900); const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9, `feather ${ratio} must still widen the band to that fraction of the keyhole`); } - // The new default is a real, narrow band — not the hard crop, and nowhere - // near the retired 35 % halo. + // The first-run value draws a band derived from the same keyhole radius. setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT); const soft = scopeMaskGeometry(1200, 900); const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index b727e6d12..2254ea24e 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -45,11 +45,7 @@ test('desktop Display participates in Tactical exclusivity without changing mobi /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, ); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); - // A panel floating out of the rail (portable mode) is exempt from exclusivity. - assert.match( - css, - /#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/, - ); + assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/sceneCommandRelease.js b/src/sceneCommandRelease.js new file mode 100644 index 000000000..4d8594401 --- /dev/null +++ b/src/sceneCommandRelease.js @@ -0,0 +1,74 @@ +/** + * Let a turned-off layer's memory go. + * + * Cesium (engine 22.3, `Scene/View.js`) reuses each view's per-pass command + * arrays: every frame resets `frustumCommands.indices[pass]` to 0 and + * overwrites `commands[pass][i]` from the start. `Scene.js` truncates the + * voxel, Gaussian-splat and translucent passes to their counts; the opaque + * and other passes keep the slots past the new count. When a layer is turned + * off the next frame has fewer commands, so that tail keeps the destroyed + * primitives' DrawCommands, and through `_owner` → primitive → entity → + * EntityCollection the whole data source stays reachable. The offscreen view + * `scene.sampleHeight` / `pickFromRay` render into keeps its last list the + * same way, until the next height sample. + * + * Measured (production build, heap after GC, Data Centers on → off): 116 MiB + * kept on main, 13 MiB with this module; heap snapshots traced the entities + * through `View.frustumCommandsList` and `Picking._pickOffscreenView`. + * + * After a layer finishes turning off, this renders one frame, trims the main + * view's arrays to that frame's counts, and empties the offscreen pick view's + * lists (it rebuilds them in full on every pick). It reads private Cesium + * fields: `src/sceneCommandRelease.test.mjs` fails if any of them is renamed + * or if Cesium changes which passes it truncates, and once Cesium clears the + * tail itself this module can go. + */ + +/** Drop command references a view no longer draws. */ +function trimView(view, { keepCurrent }) { + for (const frustum of view?.frustumCommandsList || []) { + const { commands, indices } = frustum; + for (let pass = 0; pass < commands.length; pass += 1) { + const keep = keepCurrent ? indices[pass] : 0; + if (commands[pass].length > keep) commands[pass].length = keep; + if (!keepCurrent) indices[pass] = 0; + } + } + if (!keepCurrent) + for (const extent of view?._commandExtents || []) + extent.command = undefined; +} + +/** + * Release stale command references in the scene's main view (keeping what + * the last frame drew) and in its offscreen pick view (all of it). + * @param {object} scene Cesium Scene, right after a render. + */ +export function trimStaleSceneCommands(scene) { + trimView(scene._defaultView, { keepCurrent: true }); + trimView(scene._picking?._pickOffscreenView, { keepCurrent: false }); +} + +/** + * After every layer disable, render one frame and trim once it is drawn. + * @param {{scene: object, dataManager: object, requestRender: (reason: string) => void}} deps + * @returns {() => void} uninstall + */ +export function installLayerReleaseTrim({ scene, dataManager, requestRender }) { + let removeOneShot = null; + const unsubscribe = dataManager.subscribe((change) => { + if (change?.type !== 'visibility' || change.enabled !== false) return; + if (removeOneShot) return; // a trim is already waiting for its frame + removeOneShot = scene.postRender.addEventListener(() => { + removeOneShot(); + removeOneShot = null; + trimStaleSceneCommands(scene); + }); + requestRender('layer-release-trim'); + }); + return () => { + unsubscribe(); + removeOneShot?.(); + removeOneShot = null; + }; +} diff --git a/src/sceneCommandRelease.test.mjs b/src/sceneCommandRelease.test.mjs new file mode 100644 index 000000000..d88037872 --- /dev/null +++ b/src/sceneCommandRelease.test.mjs @@ -0,0 +1,137 @@ +// A turned-off layer's draw commands must not stay reachable through Cesium's +// reused per-pass command arrays (src/sceneCommandRelease.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + installLayerReleaseTrim, + trimStaleSceneCommands, +} from './sceneCommandRelease.js'; + +const frustum = (lists, counts) => ({ + commands: lists.map((list) => [...list]), + indices: [...counts], +}); + +test('the main view keeps what the last frame drew and drops the stale tail', () => { + const view = { + frustumCommandsList: [frustum([['a', 'b', 'stale'], ['c'], []], [2, 1, 0])], + }; + trimStaleSceneCommands({ _defaultView: view }); + assert.deepEqual(view.frustumCommandsList[0].commands, [ + ['a', 'b'], + ['c'], + [], + ]); + assert.deepEqual(view.frustumCommandsList[0].indices, [2, 1, 0]); +}); + +test('the offscreen pick view is emptied, since every pick rebuilds it', () => { + const extents = [{ command: 'x' }, { command: 'y' }]; + const pick = { + frustumCommandsList: [frustum([['p', 'q'], ['r']], [2, 1])], + _commandExtents: extents, + }; + trimStaleSceneCommands({ + _defaultView: { frustumCommandsList: [] }, + _picking: { _pickOffscreenView: pick }, + }); + assert.deepEqual(pick.frustumCommandsList[0].commands, [[], []]); + assert.deepEqual(pick.frustumCommandsList[0].indices, [0, 0]); + assert.deepEqual( + extents.map((e) => e.command), + [undefined, undefined], + ); +}); + +test('a layer disable renders one frame and trims after it', () => { + const listeners = new Set(); + const postRender = new Set(); + const requests = []; + const view = { frustumCommandsList: [frustum([['a', 'stale']], [1])] }; + const scene = { + _defaultView: view, + postRender: { + addEventListener(fn) { + postRender.add(fn); + return () => postRender.delete(fn); + }, + }, + }; + const uninstall = installLayerReleaseTrim({ + scene, + dataManager: { + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }, + requestRender: (reason) => requests.push(reason), + }); + const emit = (change) => listeners.forEach((fn) => fn(change)); + + emit({ type: 'visibility', layerId: 'x', enabled: true }); + assert.equal(requests.length, 0, 'enabling trims nothing'); + emit({ type: 'visibility', layerId: 'x', enabled: false }); + emit({ type: 'visibility', layerId: 'y', enabled: false }); + assert.deepEqual( + requests, + ['layer-release-trim'], + 'one frame for back-to-back disables', + ); + assert.equal( + view.frustumCommandsList[0].commands[0].length, + 2, + 'not before the frame', + ); + postRender.forEach((fn) => fn()); + assert.deepEqual(view.frustumCommandsList[0].commands, [['a']]); + assert.equal(postRender.size, 0, 'the frame listener is one-shot'); + uninstall(); + assert.equal(listeners.size, 0); +}); + +// The workaround reads private Cesium fields. Pin the shape it relies on, and +// fail when Cesium starts clearing the tail itself so the module can go. +test('Cesium still reuses per-pass command arrays without clearing their tail', () => { + const require = createRequire(import.meta.url); + const enginePkg = require.resolve('@cesium/engine/package.json'); + const read = (rel) => + readFileSync(new URL(rel, pathToFileURL(enginePkg)), 'utf8'); + const scene = read('./Source/Scene/Scene.js'); + const view = read('./Source/Scene/View.js'); + const frustumCommands = read('./Source/Scene/FrustumCommands.js'); + const picking = read('./Source/Scene/Picking.js'); + // Every private field the trim reads. A rename would otherwise turn the + // trim into a silent no-op (it reaches them through `?.` and `|| []`). + assert.match(scene, /this\._picking = new Picking\(this\);/); + assert.match(scene, /this\._defaultView = new View\(/); + assert.match(picking, /this\._pickOffscreenView = new View\(/); + assert.match(view, /this\.frustumCommandsList = \[\];/); + assert.match(view, /frustumCommandsList\[n\]\.indices\[p\] = 0;/); + assert.match(view, /this\._commandExtents = \[\];/); + assert.match( + frustumCommands, + /this\.commands = commands;\s*this\.indices = indices;/, + ); + // Cesium truncates only the voxel, Gaussian-splat and translucent passes, + // each in its own draw step; the opaque and other passes keep their tail. + // A change here means Cesium's handling moved: re-check whether this + // module is still needed. + assert.doesNotMatch( + view, + /\.length\s*=\s*[^;]*indices\[/, + 'View.js now truncates the per-pass arrays: remove src/sceneCommandRelease.js', + ); + assert.deepEqual( + [ + ...scene.matchAll( + /commands\.length = frustumCommands\.indices\[Pass\.(\w+)\];/g, + ), + ].map((match) => match[1]), + ['VOXELS', 'GAUSSIAN_SPLATS', 'TRANSLUCENT'], + 'Scene.js truncates a different set of passes: re-check src/sceneCommandRelease.js', + ); +}); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index f72b43dc7..1077b0658 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -12,7 +12,6 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; // cancellable — an AbortSignal for the data manager, a liveness predicate for // the visual commit. Several of these assert exactly that plumbing. import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; import { SceneDirector } from './director.js'; @@ -1265,7 +1264,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', ( // switch would otherwise stand on the globe. const source = readShellSource(); const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/); - assert.ok(method, 'applyVisualState is missing from ui.js'); + assert.ok(method, 'applyVisualState is missing from the UI shell source'); assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/); const mapStackBlock = method[0].match(/if \(state\.mapStack\) \{[\s\S]*?\n {4}\}/); diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index 191bb1ac1..ba3940a57 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -994,7 +994,7 @@ const PUBLIC_SCENE_RECIPES = [ ]; /** Build the recipe list without mutating stored user-authored projects. */ -export function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { +function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES]; } @@ -1004,7 +1004,7 @@ export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE ? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE] : []; -export function getSceneRecipeById(id) { +function getSceneRecipeById(id) { return SCENE_RECIPES.find((recipe) => recipe.id === id) || null; } diff --git a/src/scopeMask.js b/src/scopeMask.js index 775a257f7..c43108377 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -1,4 +1,5 @@ import { getKeyholeGeometry } from './celestialRing.js'; +import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js'; /** * Scope mask — the app's signature circular viewport treatment, made real. @@ -41,25 +42,10 @@ import { getKeyholeGeometry } from './celestialRing.js'; /** Matches the page background the emergent scope faded into. */ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 }; /** - * Default edge feather as a fraction of the keyhole radius. - * - * 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on - * 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that - * set it to zero: a subtle soft edge rather than either the hard crop or the - * retired 35 % halo. The slider is untouched and still spans 0..100; this is - * only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a - * share link carries, or the operator dials in, overrides this immediately, and - * the hard-crop path at 0 is still reachable from the handle. - * - * Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in - * index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no - * restore, so those literals ARE the first-run state. NOT the `scf` PARSE - * fallback, which stays at 35 on purpose: a link predating that field was - * authored when 35 was what its author saw, and a link from the feather-0 era - * carries `scf=0` explicitly because the generator always writes the field. - * Pinned in reasonableDefaults.test.mjs. + * First-run feather ratio; the Display slider and share defaults use 100%. + * The historical missing-scf parse fallback remains 35%. */ -export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11; +export const SCOPE_FEATHER_RATIO_DEFAULT = 1; /** * Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so * faint stars survive in the corners at globe scale. @@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() { export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) { const keyhole = getKeyholeGeometry(width, height); if (!(keyhole.radius > 0)) return null; - const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0)); + const feather = Math.max( + 0, + Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0), + ); // Feather straddles the keyhole edge so the visible radius stays anchored // to the shared geometry every keyhole consumer (labels, cards) fades on. const half = keyhole.radius * feather * 0.5; @@ -423,6 +412,14 @@ function draw() { _painted = true; } +/** Expose only the OFF state; an absent attribute preserves the original HUD. */ +function syncScopeMaskAppearance(enabled = _enabled) { + const dataset = globalThis.document?.documentElement?.dataset; + if (!dataset) return; + if (enabled) delete dataset.scopeMask; + else dataset.scopeMask = 'off'; +} + /** * Install the scope mask into the viewer container. Idempotent. * @param {Cesium.Viewer} viewer @@ -430,6 +427,7 @@ function draw() { */ export function installScopeMask(viewer) { if (_canvas || !viewer?.container) return; + syncScopeMaskAppearance(); _container = viewer.container; _viewer = viewer; _canvas = document.createElement('canvas'); @@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) { const next = Boolean(enabled); const reEnabled = next && !_enabled; _enabled = next; + syncScopeMaskAppearance(); if (reEnabled) { // The camera moved freely while the scope was off and nothing sampled it, // so the painted terminus can be a whole altitude band stale. Re-sync once @@ -522,11 +521,14 @@ export function isScopeMaskEnabled() { } /** - * @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1]. + * @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2]. * @returns {void} */ export function setScopeMaskFeather(ratio) { - _featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0)); + _featherRatio = Math.max( + 0, + Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0), + ); draw(); } @@ -540,6 +542,7 @@ export function getScopeMaskFeather() { * @returns {void} */ export function destroyScopeMask() { + syncScopeMaskAppearance(true); _resizeObserver?.disconnect(); _resizeObserver = null; teardownDevicePixelRatioWatch(); @@ -553,6 +556,7 @@ export function destroyScopeMask() { /** Test seam. */ export function _resetScopeMaskForTest() { + syncScopeMaskAppearance(true); _resizeObserver?.disconnect(); _resizeObserver = null; teardownDevicePixelRatioWatch(); diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index 7f5dcba62..d37b6b23b 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -18,6 +18,7 @@ import { SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT, SCOPE_FEATHER_RATIO_DEFAULT, + getScopeMaskFeather, SCOPE_OUTSIDE_ALPHA, SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_FAR_M, @@ -26,6 +27,7 @@ import { _resetScopeMaskForTest, } from './scopeMask.js'; import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js'; beforeEach(() => _resetScopeMaskForTest()); @@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => { assert.equal(geo.outerR, keyholeR); }); -test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => { +test('feather ratio reaches the center at 200% and clamps larger input', () => { const wide = scopeMaskGeometry(1000, 800, 7); - const one = scopeMaskGeometry(1000, 800, 1); - assert.equal(wide.innerR, one.innerR); - assert.equal(wide.outerR, one.outerR); + const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX); + const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS; + assert.equal(maximum.innerR, 0); + assert.equal(maximum.outerR, keyholeR * 2); + assert.equal(wide.innerR, maximum.innerR); + assert.equal(wide.outerR, maximum.outerR); + for (const ratio of [1, 1.5, 2]) { + setScopeMaskFeather(ratio); + assert.equal(getScopeMaskFeather(), ratio); + const geometry = scopeMaskGeometry(1000, 800); + assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR); + assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio); + } + setScopeMaskFeather(7); + assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX); const nan = scopeMaskGeometry(1000, 800, Number.NaN); const zero = scopeMaskGeometry(1000, 800, 0); assert.equal(nan.innerR, zero.innerR); }); +test('UI and share feather percentages share one bounded integer range', () => { + for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) { + assert.equal(clampScopeFeatherPct(value), expected); + } +}); + test('degenerate viewport yields null instead of a broken gradient', () => { assert.equal(scopeMaskGeometry(0, 0), null); assert.equal(scopeMaskGeometry(-5, 100), null); @@ -136,12 +156,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) { removeEventListener: (type, fn) => listeners.delete(fn), }), }; - globalThis.document = { createElement: () => canvas }; + const documentElement = { dataset: {} }; + globalThis.document = { documentElement, createElement: () => canvas }; globalThis.ResizeObserver = class { observe() {} disconnect() {} }; const container = { clientWidth: width, clientHeight: height, appendChild() {} }; return { canvas, container, + documentElement, /** Canvas work counters: backing-store resizes, clears, fills. */ ops, /** Every fillStyle string assigned since install (hard-crop path). */ @@ -160,6 +182,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) { }; } +test('scope appearance follows direct state changes and install/destroy lifecycles', () => { + const dom = stubScopeMaskDom(); + try { + setScopeMaskEnabled(false); + assert.equal(dom.documentElement.dataset.scopeMask, 'off'); + delete dom.documentElement.dataset.scopeMask; + installScopeMask({ container: dom.container }); + assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state'); + destroyScopeMask(); + assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker'); + installScopeMask({ container: dom.container }); + assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference'); + setScopeMaskEnabled(true); + assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation'); + setScopeMaskEnabled(false); + _resetScopeMaskForTest(); + assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker'); + } finally { + destroyScopeMask(); + dom.restore(); + } +}); + test('a DPR change with no resize still repaints the backing store', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { diff --git a/src/scopeMaskPolicy.js b/src/scopeMaskPolicy.js new file mode 100644 index 000000000..13515fb43 --- /dev/null +++ b/src/scopeMaskPolicy.js @@ -0,0 +1,11 @@ +/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */ +export const SCOPE_FEATHER_RATIO_MAX = 2; +const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100; + +/** Normalize a UI/share-link feather percentage to the supported integer range. */ +export function clampScopeFeatherPct(value) { + return Math.max( + 0, + Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)), + ); +} diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 50fec4a38..019b768a1 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -38,12 +38,12 @@ const READ_PIPELINE_DEPTH = 4; const RATE_WINDOW_MS = 1_000; // A bulk read that has not completed in this long is treated as a stalled // USB transfer: the session is torn down instead of waiting forever. -export const SDR_READ_STALL_MS = 5_000; +const SDR_READ_STALL_MS = 5_000; // Decoder admission. Every block sent to the worker is numbered and must be // acknowledged; past these bounds the receiver stops with an overload error // rather than queueing raw IQ the decoder cannot keep up with. Eight 16 ms // blocks is 128 ms of input, about forty times the measured decode cost. -export const SDR_WORKER_ADMISSION = Object.freeze({ +const SDR_WORKER_ADMISSION = Object.freeze({ maxBlocks: 8, maxBytes: 8 * 65_536, maxAgeMs: 1_000, @@ -66,7 +66,7 @@ const DECODER_WINDOW_SAMPLES = 256; // raw USB device is closed directly, which aborts any pending transfer. The // raw close is bounded by the same deadline, so a teardown settles within // twice this even when `USBDevice.close()` itself never settles. -export const SDR_CLOSE_TIMEOUT_MS = 2_000; +const SDR_CLOSE_TIMEOUT_MS = 2_000; /** Tuning presets per receiver mode; gain is a separate per-mode setting. */ export const SDR_RECEIVER_PRESETS = Object.freeze({ @@ -1912,9 +1912,3 @@ function formatMhz(hz) { function positiveModulo(value, divisor) { return ((value % divisor) + divisor) % divisor; } - -export const SDR_FM_BAND = Object.freeze({ - minHz: FM_MIN_HZ, - maxHz: FM_MAX_HZ, -}); -export const SDR_ADSB_FREQUENCY_HZ = ADSB_FREQUENCY_HZ; diff --git a/src/sdr/gain.js b/src/sdr/gain.js index 761edf013..94d525fe2 100644 --- a/src/sdr/gain.js +++ b/src/sdr/gain.js @@ -10,7 +10,7 @@ * about 1 msg/s and 28.0 dB about 9 msg/s on the same antenna. */ -export const SDR_GAIN_AUTO = 'auto'; +const SDR_GAIN_AUTO = 'auto'; /** R820T tuner gain steps in dB, as listed by librtlsdr. */ export const R820T_GAIN_STEPS_DB = Object.freeze([ diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 105f13934..948cc25e7 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -47,15 +47,37 @@ function readComponent(text) { }; } -/** Split on a comma, a semicolon, or whitespace — never on anything else. */ +/** + * Split on a comma, a semicolon, or whitespace — never on anything else. + * A hemisphere letter stays with the number it names, so `40.7 N 74.0 W` + * is two components. Splitting on the space before that letter used to + * leave three pieces and the search declined the coordinate. + */ function splitComponents(query) { const text = String(query).trim(); if (!text) return null; - const parts = /[,;]/.test(text) - ? text.split(/[,;]/) - : text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i); - const trimmed = parts.map((part) => part.trim()); - return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null; + if (/[,;]/.test(text)) { + const trimmed = text.split(/[,;]/).map((part) => part.trim()); + return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null; + } + const words = text.split(/\s+/); + // Each component has at most a number, a degree sign and one hemisphere. + if (words.length > 6) return null; + // Keep every character: extracting tokens would turn `1e3` or `1.2.3` + // into invented coordinates. A lone letter between numbers can name either + // component; require an unambiguous split rather than guessing its owner. + let result = null; + for (let boundary = words.length - 1; boundary > 0; boundary -= 1) { + const parts = [ + words.slice(0, boundary).join(' '), + words.slice(boundary).join(' '), + ]; + if (parts.every((part) => readComponent(part))) { + if (result) return null; + result = parts; + } + } + return result; } /** Present a parsed pair the way a coordinate is normally written. */ diff --git a/src/search/coordinateParser.test.mjs b/src/search/coordinateParser.test.mjs index 7007a9b61..6128d5f4c 100644 --- a/src/search/coordinateParser.test.mjs +++ b/src/search/coordinateParser.test.mjs @@ -52,6 +52,19 @@ test('hemisphere letters fix the axis, in either order', () => { label: '40.0000° N, 74.0000° W', }); assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40); + // Written the way a coordinate is usually pasted, with no comma. + assert.deepEqual(parseCoordinateQuery('40.7128 N 74.0060 W'), { + lat: 40.7128, + lon: -74.006, + label: '40.7128° N, 74.0060° W', + }); + assert.deepEqual(parseCoordinateQuery('40.7128° N 74.0060° W')?.lon, -74.006); + assert.deepEqual(parseCoordinateQuery('N 40.7128 W 74.0060'), { + lat: 40.7128, + lon: -74.006, + label: '40.7128° N, 74.0060° W', + }); + assert.deepEqual(parseCoordinateQuery('74.0060 W 40.7128 N')?.lat, 40.7128); // One letter is enough: it fixes its own axis, the other takes what is left. assert.deepEqual(parseCoordinateQuery('40 N, -74')?.lon, -74); assert.deepEqual(parseCoordinateQuery('-74, 40 N'), { @@ -66,6 +79,21 @@ test('malformed and ambiguous coordinates are refused, never guessed', () => { // The reviewer's case: trailing text must not be discarded. '12junk, 34oops', '12junk,34', + '12junk 34', + '1e3 5', + '1e3', + '1E3', + '1e-3', + '1.2.3', + '12-34', + '40 NW 74', + '40 N 74', + '40 W 74', + '40 E 74', + '°40 74', + '40°° 74', + '40 74°°', + '40 26 46 N 79 58 56 W', '43.1731, -79.0384x', 'abc, def', 'Paris', @@ -196,3 +224,25 @@ test('malformed or unsupported geo URIs are refused without losing their meaning assert.equal(parseCoordinateQuery(query), null, query); } }); + +test('whitespace boundaries preserve degree signs and hemisphere ownership', () => { + for (const query of ['40 ° 74', '40° 74', '40 N, 74', 'N 40 74']) { + assert.equal(parseCoordinateQuery(query)?.lat, 40, query); + assert.equal(parseCoordinateQuery(query)?.lon, 74, query); + } + for (const query of ['40 ° N 74 ° W', 'N 40 ° W 74 °', '40\tN\n74\tW']) { + assert.equal(parseCoordinateQuery(query)?.lat, 40, query); + assert.equal(parseCoordinateQuery(query)?.lon, -74, query); + } +}); + +test('a signed plain component leaves a spaced letter with the other component', () => { + assert.deepEqual( + parseCoordinateQuery('-40 N 74'), + parseCoordinateQuery('-40, N 74'), + ); + assert.deepEqual( + parseCoordinateQuery('40 N -74'), + parseCoordinateQuery('40 N, -74'), + ); +}); diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index 159cd66fd..eecb1fa34 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -307,10 +307,20 @@ export function createGroundSnap({ groundFloor }) { } const now = Date.now(); const entry = entries.get(icao); - if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force + // Backoff in force, unless it ends further off than any backoff can: + // a retry stamped before the clock stepped back would otherwise wait for + // the clock to catch up. + if ( + entry && + now < entry.nextRetryMs && + entry.nextRetryMs - now <= RETRY_MAX_MS + ) + return heldSnapM(entry, surfacePos); // Per-window budget: deny WITHOUT a retry stamp so the overflow simply // tries again next tick instead of waiting out a backoff it didn't earn. - if (now - windowStartMs > SAMPLE_WINDOW_MS) { + // A clock that stepped back also opens a new window, or none would open + // until it caught up. + if (now < windowStartMs || now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; } diff --git a/src/services/groundSnap.test.mjs b/src/services/groundSnap.test.mjs new file mode 100644 index 000000000..8dfe42c46 --- /dev/null +++ b/src/services/groundSnap.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGroundSnap } from './groundSnap.js'; + +const groundFloor = { + cachedMeshFloor: () => null, + meshFloorPreferred: () => false, + reportValidatedMeshFloorCell: () => {}, +}; + +/** A viewer whose ground is a loaded 3D tileset, counting height samples. */ +function makeViewer() { + const calls = { samples: 0 }; + const tileset = { show: true, tilesLoaded: true }; + return { + calls, + scene: { + primitives: { length: 1, get: () => tileset }, + sampleHeight() { + calls.samples += 1; + return 1650; + }, + globe: { getHeight: () => 1600 }, + }, + }; +} + +const atDenverAirport = Cesium.Cartesian3.fromDegrees(-104.6737, 39.8561, 0); + +test('a clock that steps back opens a new sample window instead of denying samples until it catches up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const viewer = makeViewer(); + const snap = createGroundSnap({ groundFloor }); + const icaos = ['a00001', 'a00002', 'a00003', 'a00004', 'a00005', 'a00006']; + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal(viewer.calls.samples, 4, 'four samples per 250 ms window'); + t.mock.timers.setTime(1_000_000 - 60_000); + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal( + viewer.calls.samples, + 6, + 'the two denied aircraft sample in the new window', + ); +}); + +test('a retry backoff stamped before the clock stepped back does not wait for it to catch up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 10_000_000 }); + const viewer = makeViewer(); + const tileset = viewer.scene.primitives.get(0); + const snap = createGroundSnap({ groundFloor }); + // Tiles still streaming: a miss, backed off for 2 s. + tileset.tilesLoaded = false; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + tileset.tilesLoaded = true; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + assert.equal(viewer.calls.samples, 0, 'the backoff holds'); + // The clock steps back an hour: the stamp is an hour off, past any backoff. + t.mock.timers.setTime(10_000_000 - 3_600_000); + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), 1650); + assert.equal(viewer.calls.samples, 1); +}); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index e1416d288..c967f80c8 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -187,9 +187,5 @@ export function createMeshFloorSampler({ () => eventTarget?.removeEventListener('gev:map-stack-changed', onStack), { once: true }, ); - return { - sampleMeshFloorCells, - cachedGroundFloor, - visibleTilesetLoaded: _visibleTilesetLoaded, - }; + return { sampleMeshFloorCells, cachedGroundFloor }; } diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 26d695ba1..d4a2a7ca7 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -13,6 +13,7 @@ import { npmProcessSpec, readDoctorDotenvValue, resolveOpenSkyAuthMode, + resolveOpenSkyCredentialsFile, resolveCredential, } from '../scripts/setup-doctor.mjs'; @@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () => ); }); -test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => { +test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => { const oauthCredentials = { OPENSKY_CLIENT_ID: { configured: true }, OPENSKY_CLIENT_SECRET: { configured: true }, @@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden for (const credentials of [{}, oauthCredentials]) { assert.equal( buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights, - 'OpenSky Basic mode selected (credential presence and validity not verified)', + 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)', ); } }); @@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => { assert.match(output, /Setup needs attention/); assert.doesNotMatch(output, /Ready\. Run/); }); + +test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => { + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights, + 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)', + ); + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights, + 'OpenSky keyless anonymous access (rate-limited)', + ); + const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-')); + try { + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + false, + ); + writeFileSync( + path.join(root, '.env'), + 'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n', + ); + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + true, + ); + assert.equal( + resolveOpenSkyCredentialsFile({ + authoritativeEnvironment: true, + environment: { OPENSKY_CREDENTIALS_FILE: '' }, + rootDir: root, + }), + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/share/follow.js b/src/share/follow.js new file mode 100644 index 000000000..e24fc003b --- /dev/null +++ b/src/share/follow.js @@ -0,0 +1,155 @@ +/** + * What a share link's thing view follows, and where its camera is: the + * entity kinds and the follow-offset geometry of the share format + * (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code, + * default and formula here is permanent. + * + * Pure and dependency-free, so the app's follow camera and the codec + * (viewCodec.js) share one definition. + */ + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Things a view can follow. `code` is permanent and is also the number of the + * thing-message field that carries this kind's id, so the kind costs nothing + * on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO + * address or a decimal number); any other identifier travels as text. + * `tracking` names the layer-state slot that holds the same selection in the + * long form. + */ +export const SHARE_VIEW_ENTITY_KINDS = freezeAll([ + { + code: 1, + kind: 'aircraft', + layerId: 'flights', + idForm: 'hex24', + tracking: 'flights', + }, + { + code: 2, + kind: 'military_aircraft', + layerId: 'military', + idForm: 'hex24', + tracking: 'military', + }, + { + code: 3, + kind: 'vessel', + layerId: 'ais-live-vessels', + idForm: 'decimal', + tracking: null, + }, + { + code: 4, + kind: 'satellite', + layerId: 'satellites', + idForm: 'decimal', + tracking: 'satellites', + }, +]); +/** + * The frozen meaning of an omitted follow-offset field. An offset whose + * fields all equal these is written as nothing, so it decodes as these. + */ +export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({ + range: 2000, + heading: 0, + pitch: -35, +}); + +const EARTH_RADIUS_M = 6_371_000; +const RAD = Math.PI / 180; + +/** + * The camera's offset from a followed entity in the entity's local east, + * north and up metres: the camera looks along (heading, pitch) at the entity + * from `range` metres away. Heading is clockwise from north; a negative pitch + * looks down, so the camera sits above the entity. + */ +export function followOffsetToEnu({ range, heading, pitch }) { + const horizontal = range * Math.cos(pitch * RAD); + return { + east: -horizontal * Math.sin(heading * RAD), + north: -horizontal * Math.cos(heading * RAD), + up: -range * Math.sin(pitch * RAD), + }; +} + +/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */ +export function followOffsetFromEnu({ east, north, up }) { + const range = Math.hypot(east, north, up); + if (!(range > 0)) return null; + return { + range: Math.round(range), + heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360, + pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD), + }; +} + +/** Earth-centred position of (lat, lon, alt) on the format's sphere. */ +function toEcef(lat, lon, alt) { + const r = EARTH_RADIUS_M + alt; + const cosLat = Math.cos(lat * RAD); + return [ + r * cosLat * Math.cos(lon * RAD), + r * cosLat * Math.sin(lon * RAD), + r * Math.sin(lat * RAD), + ]; +} + +/** The local east, north and up unit vectors at (lat, lon). */ +function enuBasis(lat, lon) { + const sinLat = Math.sin(lat * RAD); + const cosLat = Math.cos(lat * RAD); + const sinLon = Math.sin(lon * RAD); + const cosLon = Math.cos(lon * RAD); + return { + east: [-sinLon, cosLon, 0], + north: [-sinLat * cosLon, -sinLat * sinLon, cosLat], + up: [cosLat * cosLon, cosLat * sinLon, sinLat], + }; +} + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** + * Where the camera sits while following an entity at `fix` from `offset`, + * and how it looks back at the entity, on a sphere of radius 6 371 000 m. A + * thing view with a fix stores no camera of its own: this is its camera, and + * the place a decoder falls back to. Pure, and computed only from values at + * the format's precision, so encoder and decoder agree. + */ +export function followCameraFor(fix, offset) { + const local = followOffsetToEnu(offset); + const at = toEcef(fix.lat, fix.lon, fix.alt); + const basis = enuBasis(fix.lat, fix.lon); + const camera = [0, 1, 2].map( + (i) => + at[i] + + local.east * basis.east[i] + + local.north * basis.north[i] + + local.up * basis.up[i], + ); + const horizontal = Math.hypot(camera[0], camera[1]); + const lat = Math.atan2(camera[2], horizontal) / RAD; + const lon = Math.atan2(camera[1], camera[0]) / RAD; + const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M; + // The view direction, camera to entity, in the camera's own local frame. + const view = [0, 1, 2].map((i) => at[i] - camera[i]); + const own = enuBasis(lat, lon); + const length = Math.hypot(...view); + if (!(length > 0)) + return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 }; + return { + lat, + lon, + alt, + heading: + (Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360, + pitch: + Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD, + roll: 0, + }; +} diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js new file mode 100644 index 000000000..1f54445e4 --- /dev/null +++ b/src/share/legacyShareState.js @@ -0,0 +1,119 @@ +/** + * Conversion between the long share-link form's parsed state (what + * `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the + * compact share-view state of viewCodec.js. + * + * The two use the same field names, so the conversion is small: the compact + * form adds `view`, and the long form keeps a followed entity as a tracking + * selection inside the layer options instead. + */ + +import { + SHARE_TRACKING_RESTORE_POLICIES, + createDefaultLayerState, + normalizeLayerState, +} from '../data/layerState.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + normalizeShareViewState, +} from './viewCodec.js'; + +/** Fields of the long form's parsed state, in its own order. */ +export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ + 'lat', + 'lon', + 'alt', + 'heading', + 'pitch', + 'roll', + 'style', + 'styleParams', + 'bloom', + 'sharpen', + 'bloomIntensity', + 'bloomVersion', + 'sharpenIntensity', + 'hudVariant', + 'hudVisible', + 'detectionMode', + 'detectionDensity', + 'detectionAllocation', + 'detectionFadePct', + 'detectionOutsideOpacityPct', + 'celestialRing', + 'scopeEnabled', + 'scopeFeatherPct', + 'scopeTerminusPct', + 'mapStack', + 'layerState', + 'layerStateInvalid', + 'panelState', + 'sharedAtMs', +]); + +/** + * A compact share-view state from a parsed long-form state. A tracking + * selection in the layer options becomes a thing view with no offset or + * last-known fix, since the long form records neither. Restore bookkeeping + * (`restoreAuthority`, `layerStateInvalid`) is not part of a view. + */ +export function fromLegacyShareState(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const picked = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) { + if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key)) + picked[key] = parsed[key]; + } + return normalizeShareViewState(picked); +} + +/** + * The parsed long-form state for a compact share-view state, ready for + * `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer + * set when the view has none), and a followed aircraft, military aircraft or + * satellite goes back into its layer's tracking selection. The long form + * cannot express the rest of a thing view (a vessel, the follow offset, the + * last-known fix); callers that need them read `state.view` directly. + */ +export function toLegacyShareState(input) { + const state = normalizeShareViewState(input); + const entry = + state.view.kind === 'thing' + ? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind) + : null; + let layerState = + entry && !state.layerState + ? normalizeLayerState({ + ...createDefaultLayerState(), + enabledLayerIds: [entry.layerId], + }) + : state.layerState; + const policy = entry?.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if ( + layerState && + policy && + layerState.enabledLayerIds.includes(entry.layerId) + ) { + const id = + entry.kind === 'satellite' + ? Number(state.view.entity.id) + : state.view.entity.id; + layerState = normalizeLayerState({ + ...layerState, + options: { + ...layerState.options, + [policy.optionOwner]: { + ...layerState.options[policy.optionOwner], + [policy.optionKey]: id, + }, + }, + }); + } + const legacy = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key]; + legacy.layerState = layerState; + legacy.layerStateInvalid = false; + return legacy; +} diff --git a/src/share/legacyShareState.test.mjs b/src/share/legacyShareState.test.mjs new file mode 100644 index 000000000..cde3a9ed6 --- /dev/null +++ b/src/share/legacyShareState.test.mjs @@ -0,0 +1,308 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from '../sharelink.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { + HAL_EXAMPLE_HASH, + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './viewCodec.js'; +import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js'; +import { + LEGACY_SHARE_STATE_KEYS, + fromLegacyShareState, + toLegacyShareState, +} from './legacyShareState.js'; + +const DEG = Math.PI / 180; + +function stubViewer(camera = {}) { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: camera.position ?? { + latitude: 0, + longitude: 0, + height: 0, + }, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? 0, + roll: camera.roll ?? 0, + }, + }; +} + +/** The long form's own parser, run on `hash`. */ +function parseLegacy(hash) { + const body = hash.startsWith('#') ? hash : `#${hash}`; + globalThis.window = { + location: { hash: body, href: `http://localhost/${body}` }, + }; + const manager = new ShareLinkManager(stubViewer()); + const parsed = manager.parseInitialHash(); + manager.destroy(); + return parsed; +} + +/** + * The parsed state as a view: restore bookkeeping removed, and headings and + * rolls wrapped into [0, 360) — the codec's one normalization of values the + * long-form generator writes (Cesium can round 359.6° up to `roll=360`). + */ +function comparable(parsed) { + const out = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key]; + // A long link without `sp` leaves the style's parameters as they are; the + // compact form says what they are, the style's own unless changed. + const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams; + out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null; + for (const key of ['heading', 'roll']) + out[key] = ((out[key] % 360) + 360) % 360; + return out; +} + +/** Legacy hash → parsed state → codec → parsed state, asserting equality. */ +function assertLegacyRoundTrip(hash) { + const parsed = parseLegacy(hash); + assert.ok(parsed, `long form parses: ${hash}`); + const state = fromLegacyShareState(parsed); + const encoded = encodeShareView(state); + const decoded = decodeShareView(encoded, { nowMs: Date.now() }); + assert.equal(decoded.ok, true, decoded.reason); + assert.deepEqual(decoded.state, state, 'codec state survives'); + assert.deepEqual( + toLegacyShareState(decoded.state), + comparable(parsed), + 'parsed long-form state survives', + ); + return { parsed, state, encoded }; +} + +test('the HAL example survives the round trip and shrinks to 39 characters', () => { + const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH); + assert.equal(HAL_EXAMPLE_HASH.length, 302); + assert.equal(encoded.length, 39); + assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o'); + assert.equal(parsed.roll, 360); + assert.equal(state.roll, 0); + assert.deepEqual(state.layerState.enabledLayerIds, [ + 'ais-live-vessels', + 'flights', + 'satellites', + ]); + assert.equal(state.layerState.options.flights.models3d, true); + assert.equal(state.layerState.options.wind.overlay, 'none'); + assert.deepEqual( + state.panelState.specs.find((spec) => spec.id === 'cctv-panel'), + { id: 'cctv-panel', collapsed: false, pinned: null }, + ); +}); + +test('links from earlier long-form versions keep their historical meaning', () => { + for (const hash of [ + 'lat=10&lon=20', + 'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal', + 'v=2&lat=1&lon=2&l=f&lo=f.m.a', + 'v=2&lat=1&lon=2&l=', + 'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35', + 'v=2&lat=1&lon=2&ui=v.c.0', + 'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100', + 'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600', + ]) { + assertLegacyRoundTrip(hash); + } + // A pre-v2 link's omitted settings are written explicitly, so the compact + // form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35). + const old = fromLegacyShareState(parseLegacy('lat=10&lon=20')); + assert.deepEqual( + [old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct], + [5, 16, 35], + ); + assert.deepEqual( + [old.bloomIntensity, old.bloomVersion], + [50, 1], + 'legacy bloom scale', + ); + // `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that. + const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f')); + assert.equal(off.layerState.options.flights.models3d, false); +}); + +test('a tracking selection becomes a thing view and back', () => { + const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3'); + assert.deepEqual(flights.state.view.entity, { + kind: 'aircraft', + id: 'a1b2c3', + }); + assert.equal( + flights.state.layerState.options.flights.selectedFlightsTrackingId, + null, + ); + const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234'); + assert.deepEqual(military.state.view.entity, { + kind: 'military_aircraft', + id: '~ae1234', + }); + const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544'); + assert.deepEqual(satellite.state.view.entity, { + kind: 'satellite', + id: '25544', + }); + assert.equal( + toLegacyShareState(satellite.state).layerState.options.satellites + .selectedSatTrackingId, + 25544, + ); +}); + +test('thing views the long form cannot express convert without one', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['ais-live-vessels']; + const vessel = toLegacyShareState({ + lat: 1, + lon: 2, + layerState, + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState, layerState); +}); + +test('a thing view turns its layer on, with or without a layer set', () => { + for (const layerState of [createDefaultLayerState(), null]) { + const legacy = toLegacyShareState({ + layerState, + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } }, + }); + assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']); + assert.equal( + legacy.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + assert.equal(legacy.layerStateInvalid, false); + } + const vessel = toLegacyShareState({ + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']); + // A place view without a layer set leaves the recipient's layers alone. + assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null); +}); + +/** A long-form link written by the real generator from random live state. */ +function generatedLegacyHash(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager( + stubViewer({ + position: { + latitude: (int(-899999, 899999) / 1e4) * DEG, + longitude: (int(-1799999, 1799999) / 1e4) * DEG, + height: pick(random, [int(-50, 3000), int(0, 30_000_000)]), + }, + heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0), + pitch: int(-90, 0) * DEG, + roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), + }), + ); + const style = pick(random, VIEW_STYLES); + manager.onStyleChange(style); + const density = pick(random, [0, 25, 50, 75, 100]); + manager.onToggleChange(random() < 0.5, random() < 0.5, { + bloomIntensity: int(0, 100), + bloomVersion: pick(random, [1, 2]), + sharpenIntensity: int(0, 100), + hudVariant: pick(random, HUD_LAYOUTS), + hudVisible: random() < 0.5, + detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionDensity: density, + detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']), + detectionFadePct: int(0, 40), + detectionOutsideOpacityPct: int(0, 100), + celestialRingEnabled: random() < 0.5, + scopeEnabled: random() < 0.5, + scopeFeatherPct: int(0, 200), + scopeTerminusPct: random() < 0.3 ? int(94, 100) : null, + mapStack: pick(random, VIEW_MAPS), + }); + if (random() < 0.8) { + const layerState = randomLayerState(random); + manager.setLayerStateProvider(() => layerState); + } + if (random() < 0.8) { + const panelState = { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({ + id: panel.id, + collapsed: random() < 0.7, + ...(panel.pinnable ? { pinned: random() < 0.3 } : {}), + })), + }; + manager.setPanelStateProvider(() => panelState); + } + const specs = SHARE_VIEW_STYLE_PARAMS[style]; + if (specs && random() < 0.7) { + const values = Object.fromEntries( + specs.map((spec) => [ + spec.key, + spec.min + (spec.max - spec.min) * random(), + ]), + ); + manager.setStyleParamStateProvider(() => values); + } + const params = manager._buildHashParams(); + if (random() < 0.7) + params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000)))); + manager.destroy(); + return params.toString(); +} + +test('links written by the long-form generator survive the compact form', () => { + const random = rng(0xc0dec); + let totalLegacy = 0; + let totalCompact = 0; + for (let i = 0; i < 400; i += 1) { + const hash = generatedLegacyHash(random); + const { encoded } = assertLegacyRoundTrip(hash); + totalLegacy += hash.length; + totalCompact += encoded.length; + } + // Even random states, far from defaults, are under half the length. + assert.ok( + totalCompact * 2 < totalLegacy, + `${totalCompact} vs ${totalLegacy}`, + ); +}); + +test('the converter ignores non-objects and restore bookkeeping', () => { + // A malformed v2 layer list is reported by the long-form parser; the view + // itself simply has no layer set. + const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown'); + assert.equal(invalid.layerStateInvalid, true); + assert.equal(fromLegacyShareState(invalid).layerState, null); + assert.equal(fromLegacyShareState(null), null); + const parsed = parseLegacy(HAL_EXAMPLE_HASH); + assert.ok(parsed.restoreAuthority); + const state = fromLegacyShareState(parsed); + assert.equal(Object.hasOwn(state, 'restoreAuthority'), false); + assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false); +}); + +test('the converter knows every key of the long form’s parsed state', () => { + // A key the converter does not list would silently drop out of share codes. + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager(stubViewer({})); + const parsed = manager._parseShareHash('v=2&lat=1&lon=2'); + assert.deepEqual( + Object.keys(parsed).sort(), + [...LEGACY_SHARE_STATE_KEYS].sort(), + ); + manager.destroy(); +}); diff --git a/src/share/shareLinkPath.js b/src/share/shareLinkPath.js new file mode 100644 index 000000000..ee52a235f --- /dev/null +++ b/src/share/shareLinkPath.js @@ -0,0 +1,56 @@ +/** + * Share-link addresses: a share code travels in the path, `/share/`, + * where `` is wherever the app is served (the origin's root, or a base + * path). The same code opens on any copy of the app. + * + * Pure: no DOM. Callers pass the addresses they have. + */ + +/** The path segment that introduces a share code. */ +export const SHARE_PATH_SEGMENT = 'share'; + +// Any last segment counts as a code here, so a mangled one is refused by the +// decoder (and said so) rather than opening the app at a wrong address. +const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`); + +/** + * The share code and the app's base path for a share-link pathname, or null + * when the pathname is not a share link. `/share/AQc` gives + * `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`. + */ +export function parseSharePath(pathname) { + if (typeof pathname !== 'string') return null; + const match = SHARE_PATH.exec(pathname); + if (!match) return null; + let code = match[2]; + try { + code = decodeURIComponent(code); + } catch { + // Left as written; the decoder refuses it. + } + return { code, basePath: match[1] }; +} + +/** + * The app's root address for `href`: the base path of a share link, or the + * directory of any other page, with no hash. The query is kept. + */ +export function appRootUrl(href) { + const url = new URL(href); + url.hash = ''; + const share = parseSharePath(url.pathname); + url.pathname = share ? share.basePath : new URL('./', url).pathname; + return url; +} + +/** + * The share link for `code` on the app at `href`. The sender's `?lang` stays + * behind: the recipient keeps their own language. + */ +export function shareLinkUrl(code, href) { + const root = appRootUrl(href); + const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root); + url.search = root.search; + url.searchParams.delete('lang'); + return url.href; +} diff --git a/src/share/shareLinkPath.test.mjs b/src/share/shareLinkPath.test.mjs new file mode 100644 index 000000000..aab06741d --- /dev/null +++ b/src/share/shareLinkPath.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js'; + +test('a share path names its code and the app base path', () => { + assert.deepEqual(parseSharePath('/share/AQc'), { + code: 'AQc', + basePath: '/', + }); + assert.deepEqual(parseSharePath('/gev/share/AQc/'), { + code: 'AQc', + basePath: '/gev/', + }); + // A mangled code is still a share link, so the app refuses it out loud. + assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c'); + for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc']) + assert.equal(parseSharePath(path), null, path); + assert.equal(parseSharePath(undefined), null); +}); + +test('the app root drops a share path and the hash, and keeps the query', () => { + assert.equal( + appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href, + 'https://gev.example/?qa=1', + ); + assert.equal( + appRootUrl('http://localhost:4173/gev/share/AQc').href, + 'http://localhost:4173/gev/', + ); + assert.equal( + appRootUrl('https://gev.example/index.html#v=2&lat=1').href, + 'https://gev.example/', + ); +}); + +test('a share link sits under the app root without the sender ?lang', () => { + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'), + 'https://gev.example/share/AQc', + ); + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'), + 'https://gev.example/gev/share/AQc?qa=1', + ); +}); diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js new file mode 100644 index 000000000..84ba8fbc1 --- /dev/null +++ b/src/share/viewCodec.js @@ -0,0 +1,1399 @@ +/** + * Compact share-view codec, format version 1. + * + * A shared view travels as a short code in a link's path, `/share/`. The + * code is base64url text for one version byte, protobuf-style tag-length-value + * fields and a one-byte CRC-8 check. Settings equal to their default are + * omitted, and the decoder fills them back in from the same defaults table the + * encoder reads. Older decoders skip fields they do not know, so new fields + * never break old links or old apps. + * + * The rules that keep links valid forever (field numbers, codes and defaults + * are permanent) are in docs/SHARE-LINK-FORMAT.md. Read them before changing + * anything in this file. + * + * Pure: no DOM, no Cesium. The long `#v=2&lat=…` form is still parsed by + * sharelink.js; legacyShareState.js converts between the two. + */ + +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_ALPHABET, + REGISTRY_BY_ID, + SHARE_TRACKING_RESTORE_POLICIES, + TRACKING_ID_GRAMMAR, + normalizeLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { + migrateDetectionState, + normalizeAllocationStrategy, +} from '../data/detectionPolicy.js'; +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { VIEW_STYLES } from '../view/index.js'; +import { + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + STYLES, +} from '../ui/visualPresets.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, +} from './follow.js'; +import { + MAX_ZIGZAG_MAGNITUDE, + ShareWireError, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + asciiText, + decodeBase64Url, + asciiBytes, + crc8, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +export { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, +} from './follow.js'; + +/** First payload byte. Bump only for a change older decoders must refuse. */ +export const SHARE_VIEW_FORMAT_VERSION = 1; +/** Input bound, checked before any decoding work (1536 payload bytes). */ +export const MAX_SHARE_VIEW_CHARS = 2048; + +/** Created-at and observed-at seconds are stored relative to this instant. */ +export const SHARE_VIEW_EPOCH_S = Date.UTC(2026, 0, 1) / 1000; + +/** Coordinates are stored as integer multiples of this many degrees (≈11 m). */ +const COORDINATE_SCALE = 1e4; +const MAX_ALTITUDE_M = 1e9; +const MAX_FOLLOW_RANGE_M = 1e8; +const MAX_UNSIGNED_SETTING = 1e6; +/** Terminus pin range; mirrors SCOPE_TERMINUS_MIN/MAX_PCT in scopeMask.js. */ +const SCOPE_TERMINUS_MIN_PCT = 94; +const SCOPE_TERMINUS_MAX_PCT = 100; +const NAME_GRAMMAR = /^[A-Za-z0-9_-]{1,32}$/; +const MAX_OPTION_VALUE_CHARS = 256; + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Enum code tables. A code is permanent: append new names, never renumber or + * reuse. A name missing from its table still travels, as text in the same + * field (wire type LEN), so the app can grow faster than this table. + */ +export const SHARE_VIEW_ENUMS = Object.freeze({ + viewKind: Object.freeze(['place', 'thing']), + style: Object.freeze([ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + ]), + mapStack: Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]), + hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']), + detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionAllocation: Object.freeze(['ELASTIC', 'WEIGHTED']), +}); + +const ENTITY_BY_KIND = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.kind, e])); +const ENTITY_BY_CODE = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e])); + +/** + * Shareable panels, numbered permanently. `collapsed` and `pinned` are the + * frozen meaning of an omitted entry (the layout a fresh session shows today), + * not a live default: if the app's default moves, this table does not. + * `token` is the panel's letter in the long form's `ui` parameter. + */ +export const SHARE_VIEW_PANELS = freezeAll([ + { + number: 0, + id: 'control-panel', + token: 'c', + pinnable: true, + collapsed: true, + }, + { + number: 1, + id: 'location-bar', + token: 'l', + pinnable: true, + collapsed: true, + }, + { number: 2, id: 'data-panel', token: 'd', pinnable: false, collapsed: true }, + { number: 3, id: 'cctv-panel', token: 'v', pinnable: false, collapsed: true }, + { + number: 4, + id: 'recent-imagery-panel', + token: 'i', + pinnable: false, + collapsed: false, + }, + { + number: 5, + id: 'street-level-panel', + token: 't', + pinnable: false, + collapsed: true, + }, + { + number: 6, + id: 'radio-panel', + token: 'r', + pinnable: false, + collapsed: true, + }, + { + number: 7, + id: 'scene-panel', + token: 's', + pinnable: false, + collapsed: true, + }, + { + number: 8, + id: 'global-context-panel', + token: 'g', + pinnable: false, + collapsed: true, + }, + { number: 9, id: 'pp-toggles', token: 'p', pinnable: false, collapsed: true }, + { + number: 10, + id: 'param-slider-panel', + token: 'm', + pinnable: false, + collapsed: false, + }, +]); +const PANEL_BY_NUMBER = new Map(SHARE_VIEW_PANELS.map((p) => [p.number, p])); +const PANEL_BY_ID = new Map(SHARE_VIEW_PANELS.map((p) => [p.id, p])); + +/** + * Shareable visual-preset parameters per style, as in the long form's `sp` + * parameter: values are stored ×100 after clamping to [min, max]. A + * parameter's token is permanent within its style. + */ +export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({ + retro: freezeAll([ + { key: 'pixelation', token: 'p', min: 1, max: 10 }, + { key: 'distortion', token: 'd', min: 0, max: 1 }, + { key: 'instability', token: 'i', min: 0, max: 1 }, + ]), + surveillance: freezeAll([ + { key: 'gain', token: 'g', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'scanlineStr', token: 's', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + ]), + thermal: freezeAll([ + { key: 'sensitivity', token: 's', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'mode', token: 'm', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + { key: 'palette', token: 'a', min: 0, max: 1 }, + ]), + anime: freezeAll([ + { key: 'saturation', token: 's', min: 0, max: 2 }, + { key: 'edgeThick', token: 'e', min: 0, max: 1 }, + ]), + noir: freezeAll([ + { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, + { key: 'grainAmt', token: 'g', min: 0, max: 1 }, + { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, + ]), + snow: freezeAll([ + { key: 'density', token: 'd', min: 0, max: 1 }, + { key: 'wind', token: 'w', min: 0, max: 1 }, + ]), +}); + +/** + * The default of a setting a style applies when it is selected: the value + * comes from SHARE_VIEW_STYLE_DEFAULTS for the view's style, so a view that + * keeps its style's look costs nothing for it. + */ +export const BY_STYLE = 'by-style'; + +/** + * Booleans share one varint (field 9). Bit `bit` is set when the value + * DIFFERS from `default`, so an absent field means "all defaults" and a bit + * an older decoder does not know is harmless. Bits are permanent. + */ +export const SHARE_VIEW_FLAGS = freezeAll([ + { bit: 0, key: 'bloom', default: BY_STYLE }, + { bit: 1, key: 'sharpen', default: BY_STYLE }, + { bit: 2, key: 'hudVisible', default: BY_STYLE }, + { bit: 3, key: 'celestialRing', default: BY_STYLE }, + { bit: 4, key: 'scopeEnabled', default: true }, +]); + +/** + * What an omitted layer option means, per option owner and key. Frozen like + * every other default here: it is today's app default, and it stays even if + * that default moves (the long form learned this with `models3d`). A new + * option needs an entry before it ships; until it has one it is always + * written explicitly. + */ +export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + traffic: Object.freeze({ roadMode: null }), + 'weather-lightning': Object.freeze({ opacity: 'strong' }), + 'weather-radar': Object.freeze({ opacity: 'strong' }), + 'weather-satellite': Object.freeze({ + infrared: 100, + opacity: 'strong', + product: 'clouds-regional', + }), + wind: Object.freeze({ + model: 'gfs', + overlay: 'none', + units: 'km/h', + paused: false, + }), + flights: Object.freeze({ + models3d: true, + models3dMode: 'proximity', + selectedFlightsTrackingId: null, + selectedMilitaryTrackingId: null, + }), + satellites: Object.freeze({ catalog: 'core', selectedSatTrackingId: null }), + cctv: Object.freeze({ + coverageMode: 'on', + showProjection: true, + autoHop: false, + }), + 'recent-imagery': Object.freeze({ + west: null, + south: null, + east: null, + north: null, + a: null, + b: null, + mode: 0, + split: 50, + viirs: false, + }), + 'street-level': Object.freeze({ + mapillary: true, + providerSwitches: '', + pano: 'all', + sinceDays: 0, + }), + radio: Object.freeze({ filter: 'all', volume: 0.8 }), +}); + +/** The frozen meaning of an omitted option; unlisted options are always written. */ +function layerOptionAbsentValue(ownerId, spec) { + const owner = SHARE_VIEW_LAYER_OPTION_ABSENT[ownerId]; + return owner && Object.hasOwn(owner, spec.key) ? owner[spec.key] : undefined; +} + +/** + * The top-level field registry. Numbers are permanent and never reused; a + * retired field stays listed with `retired: true`. Defaults are the frozen + * meaning of an omitted field. Types: + * uint unsigned varint sint zigzag varint + * angle whole degrees in [0, 360), stored as zigzag of (-180, 180] + * coord zigzag varint of degrees × 1e4 + * enum varint code from SHARE_VIEW_ENUMS, or LEN text for other names + * epoch zigzag varint of seconds since SHARE_VIEW_EPOCH_S + * flags varint bitset, see SHARE_VIEW_FLAGS + * bytes LEN, structure documented per field + */ +export const SHARE_VIEW_FIELDS = freezeAll([ + { number: 1, key: 'view', type: 'enum', enum: 'viewKind', default: 'place' }, + { number: 2, key: 'lat', type: 'coord', default: 0 }, + { number: 3, key: 'lon', type: 'coord', default: 0 }, + { number: 4, key: 'alt', type: 'sint', default: 800 }, + { number: 5, key: 'heading', type: 'angle', default: 0 }, + { number: 6, key: 'pitch', type: 'sint', default: -35 }, + { number: 7, key: 'roll', type: 'angle', default: 0 }, + { number: 8, key: 'sharedAtMs', type: 'epoch', default: null }, + { number: 9, key: 'flags', type: 'flags', default: 0 }, + { number: 10, key: 'layerState', type: 'bytes', default: null }, + { number: 11, key: 'layerOptions', type: 'bytes', default: null }, + { number: 12, key: 'panelState', type: 'bytes', default: null }, + { number: 13, key: 'thing', type: 'bytes', default: null }, + { number: 14, key: 'style', type: 'enum', enum: 'style', default: 'normal' }, + { + number: 15, + key: 'mapStack', + type: 'enum', + enum: 'mapStack', + default: 'photoreal', + }, + { + number: 16, + key: 'hudVariant', + type: 'enum', + enum: 'hudVariant', + default: BY_STYLE, + }, + { number: 17, key: 'bloomIntensity', type: 'uint', default: BY_STYLE }, + { number: 18, key: 'bloomVersion', type: 'uint', default: 2 }, + { number: 19, key: 'sharpenIntensity', type: 'uint', default: BY_STYLE }, + { + number: 20, + key: 'detectionMode', + type: 'enum', + enum: 'detectionMode', + default: BY_STYLE, + }, + { number: 21, key: 'detectionDensity', type: 'uint', default: BY_STYLE }, + { + number: 22, + key: 'detectionAllocation', + type: 'enum', + enum: 'detectionAllocation', + default: BY_STYLE, + }, + { number: 23, key: 'detectionFadePct', type: 'uint', default: BY_STYLE }, + { + number: 24, + key: 'detectionOutsideOpacityPct', + type: 'uint', + default: BY_STYLE, + }, + { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, + { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, + { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, +]); +const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); +const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; + +/** + * Fields inside the thing message (top-level field 13). Permanent. Fields 1–4 + * are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is + * present. The follow offset and the last-known fix are flat fields here, not + * sub-messages, to save their tag and length bytes. + */ +export const SHARE_VIEW_THING_FIELDS = Object.freeze({ + range: 5, // uint metres from the entity to the camera + heading: 6, // angle, the camera's heading while following + pitch: 7, // sint whole degrees, the camera's pitch while following + lat: 8, // coord, the entity's last-known latitude + lon: 9, // coord, its last-known longitude + alt: 10, // sint metres, its last-known altitude + ageS: 11, // sint, seconds the fix predates the created-at time (field 8) + observedAt: 12, // epoch, when the fix was taken; only without a created-at +}); + +/** The default of a field or flag for a view in `style`. */ +function defaultOf(entry, style) { + if (entry.default !== BY_STYLE) return entry.default; + const defaults = + SHARE_VIEW_STYLE_DEFAULTS[style] ?? SHARE_VIEW_STYLE_DEFAULTS.normal; + return defaults[entry.key]; +} + +/** + * The complete default state for a view in `style` (Normal when omitted): + * what a payload with only that style decodes to. + */ +export function createDefaultShareViewState(requested = 'normal') { + const style = VIEW_STYLES.includes(requested) ? requested : 'normal'; + const state = { view: { kind: 'place' } }; + for (const field of SHARE_VIEW_FIELDS) { + if (field.key === 'view' || field.key === 'flags') continue; + if (field.key === 'thing' || field.key === 'layerOptions') continue; + state[field.key] = + field.key === 'style' ? style : structuredClone(defaultOf(field, style)); + } + for (const flag of SHARE_VIEW_FLAGS) state[flag.key] = defaultOf(flag, style); + return state; +} + +/* ------------------------------------------------------------------ */ +/* Normalization: the one canonical state both directions agree on. */ +/* ------------------------------------------------------------------ */ + +const finite = (value) => + typeof value === 'number' + ? Number.isFinite(value) + : typeof value === 'string' && + value.trim() !== '' && + Number.isFinite(Number(value)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +// `+ 0` turns -0 into 0, which is what the decoder produces. +function intOr(value, fallback, min, max) { + return finite(value) + ? clamp(Math.round(Number(value)), min, max) + 0 + : fallback; +} + +function wrapAngle(value, fallback = 0) { + if (!finite(value)) return fallback; + return ((Math.round(Number(value)) % 360) + 360) % 360; +} + +function latitudeOr(value, fallback) { + if (!finite(value)) return fallback; + return ( + Math.round(clamp(Number(value), -90, 90) * COORDINATE_SCALE) / + COORDINATE_SCALE + + 0 + ); +} + +function longitudeOr(value, fallback) { + if (!finite(value)) return fallback; + let lon = Number(value); + // Wrap before scaling, so a huge finite input cannot overflow to NaN. + if (lon < -180 || lon > 180) lon = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.round(lon * COORDINATE_SCALE) / COORDINATE_SCALE + 0; +} + +function epochMsOr(value) { + if (!finite(value)) return null; + const seconds = Math.floor(Number(value) / 1000); + return seconds >= 1 && Number.isSafeInteger(seconds * 1000) + ? seconds * 1000 + : null; +} + +function nameOr(value, fallback) { + return typeof value === 'string' && NAME_GRAMMAR.test(value) + ? value + : fallback; +} + +function normalizeEntityId(entry, value) { + if (value === null || value === undefined) return null; + const raw = + typeof value === 'number' && Number.isFinite(value) + ? String(value) + : typeof value === 'string' + ? value.trim().toLowerCase() + : null; + if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null; + if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null; + return raw; +} + +function normalizeStyleParams(style, values) { + const registry = SHARE_VIEW_STYLE_PARAMS[style]; + if (!registry || !values || typeof values !== 'object') return null; + const out = {}; + for (const spec of registry) { + if (!Object.hasOwn(values, spec.key) || !finite(values[spec.key])) continue; + out[spec.key] = + Math.round(clamp(Number(values[spec.key]), spec.min, spec.max) * 100) / + 100; + } + return Object.keys(out).length ? out : null; +} + +/** + * What each style leaves the shareable settings at when it is selected in a + * fresh session: the app's first-load baseline (GLOBAL_POST_DEFAULTS), with + * the style's own preset (STYLE_PRESET_DEFAULTS) on top, and its shader + * parameters at their defaults (the preset's, else the shader's). Read from + * the tables the app applies, so a fresh view in any style encodes as its + * camera and style alone. + * + * These are format v1's frozen meanings. If the app's tables move, the test + * that pins this table fails: freeze the old values here then, rather than + * letting old links change meaning. + */ +export const SHARE_VIEW_STYLE_DEFAULTS = Object.freeze( + Object.fromEntries( + VIEW_STYLES.map((style) => { + const g = GLOBAL_POST_DEFAULTS; + const preset = STYLE_PRESET_DEFAULTS[style] ?? {}; + const detection = migrateDetectionState( + preset.detection?.mode?.toUpperCase?.() ?? g.detectionMode, + preset.detection?.densityPct ?? g.detectionDensity, + g.detectionDensity, + ); + const params = {}; + for (const spec of SHARE_VIEW_STYLE_PARAMS[style] ?? []) { + const value = + preset.styleParams?.[style]?.[spec.key] ?? + STYLES[style]?.uniforms?.[spec.key]?.default; + if (finite(value)) params[spec.key] = value; + } + return [ + style, + Object.freeze({ + bloom: preset.bloom?.enabled ?? g.bloom.enabled, + bloomIntensity: preset.bloom?.intensity ?? g.bloom.intensity, + sharpen: preset.sharpen?.enabled ?? g.sharpen.enabled, + sharpenIntensity: preset.sharpen?.intensity ?? g.sharpen.intensity, + hudVariant: preset.hudVariant ?? g.hudVariant, + hudVisible: preset.hudVisible ?? g.hudVisible, + detectionMode: detection.enabled ? detection.profile : 'OFF', + detectionDensity: detection.densityPct, + detectionAllocation: normalizeAllocationStrategy( + g.detectionAllocation, + ), + detectionFadePct: g.detectionFadePct, + detectionOutsideOpacityPct: g.detectionOutsideOpacityPct, + // Leaving Normal switches the ring off (VisualSettings.setStyle). + celestialRing: style === 'normal' ? g.celestialRing : false, + styleParams: Object.freeze(normalizeStyleParams(style, params)), + }), + ]; + }), + ), +); + +function normalizePanelState(panelState) { + if (!panelState || !Array.isArray(panelState.specs)) return null; + const byId = new Map(); + for (const spec of panelState.specs) { + if (spec && PANEL_BY_ID.has(spec.id) && typeof spec.collapsed === 'boolean') + byId.set(spec.id, spec); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + if (!spec) continue; + specs.push({ + id: panel.id, + collapsed: spec.collapsed, + pinned: + panel.pinnable && typeof spec.pinned === 'boolean' ? spec.pinned : null, + }); + } + return specs.length ? { specs } : null; +} + +function normalizeFollowOffset(offset) { + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const source = offset && typeof offset === 'object' ? offset : {}; + return { + range: intOr(source.range, d.range, 1, MAX_FOLLOW_RANGE_M), + heading: wrapAngle(source.heading, d.heading), + pitch: intOr(source.pitch, d.pitch, -90, 90), + }; +} + +function normalizeLastKnown(fix) { + if (!fix || typeof fix !== 'object') return null; + if (!finite(fix.lat) || !finite(fix.lon)) return null; + return { + lat: latitudeOr(fix.lat, 0), + lon: longitudeOr(fix.lon, 0), + alt: intOr(fix.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M), + observedAtMs: epochMsOr(fix.observedAtMs), + }; +} + +function thingView(kind, id, offset = null, lastKnown = null) { + return { + kind: 'thing', + entity: { kind, id }, + offset: normalizeFollowOffset(offset), + lastKnown: normalizeLastKnown(lastKnown), + }; +} + +function normalizeView(view) { + if (view?.kind !== 'thing') return { kind: 'place' }; + const entry = ENTITY_BY_KIND.get(view.entity?.kind); + const id = entry ? normalizeEntityId(entry, view.entity?.id) : null; + if (!id) return { kind: 'place' }; + return thingView(entry.kind, id, view.offset, view.lastKnown); +} + +/** The camera fields of `state`, at the format's precision. */ +function setCamera(state, camera) { + state.lat = latitudeOr(camera.lat, 0); + state.lon = longitudeOr(camera.lon, 0); + state.alt = intOr(camera.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(camera.heading, 0); + state.pitch = intOr(camera.pitch, 0, -90, 90); + state.roll = wrapAngle(camera.roll, 0); +} + +/** + * Lift a long-form tracking selection out of the layer options into a thing + * view. In this format the view, not a layer option, says what is followed. + */ +function liftTrackedEntity(layerState, view) { + let lifted = view; + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const policy = entry.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if (!policy) continue; + const options = layerState.options[policy.optionOwner]; + const id = normalizeEntityId(entry, options[policy.optionKey]); + options[policy.optionKey] = null; + if (id && lifted.kind === 'place') lifted = thingView(entry.kind, id); + } + return lifted; +} + +/** + * The canonical form of a share-view state. Missing or invalid fields take + * their defaults, numbers take the format's precision, and a long-form + * tracking selection becomes a thing view. A thing view with a last-known fix + * takes its camera from the fix and the follow offset, and a thing view's + * layer is always on. `decodeShareView` returns this + * form, and `encodeShareView(state)` decodes back to `normalizeShareViewState(state)`. + */ +export function normalizeShareViewState(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const style = VIEW_STYLES.includes(source.style) ? source.style : 'normal'; + // Style-applied settings default to what this style applies. + const d = createDefaultShareViewState(style); + const state = {}; + state.lat = latitudeOr(source.lat, d.lat); + state.lon = longitudeOr(source.lon, d.lon); + state.alt = intOr(source.alt, d.alt, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(source.heading, d.heading); + state.pitch = intOr(source.pitch, d.pitch, -90, 90); + state.roll = wrapAngle(source.roll, d.roll); + state.style = style; + // The style's own parameters, with any valid ones the source sets on top. + state.styleParams = normalizeStyleParams(style, { + ...d.styleParams, + ...normalizeStyleParams(style, source.styleParams), + }); + for (const flag of SHARE_VIEW_FLAGS) + state[flag.key] = + typeof source[flag.key] === 'boolean' ? source[flag.key] : d[flag.key]; + state.bloomIntensity = intOr( + source.bloomIntensity, + d.bloomIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.bloomVersion = intOr( + source.bloomVersion, + d.bloomVersion, + 0, + MAX_UNSIGNED_SETTING, + ); + state.sharpenIntensity = intOr( + source.sharpenIntensity, + d.sharpenIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.hudVariant = nameOr(source.hudVariant, d.hudVariant); + const detection = migrateDetectionState( + typeof source.detectionMode === 'string' && source.detectionMode + ? source.detectionMode + : d.detectionMode, + finite(source.detectionDensity) + ? Number(source.detectionDensity) + : d.detectionDensity, + d.detectionDensity, + ); + state.detectionMode = detection.enabled ? detection.profile : 'OFF'; + state.detectionDensity = detection.densityPct; + state.detectionAllocation = normalizeAllocationStrategy( + source.detectionAllocation, + d.detectionAllocation, + ); + state.detectionFadePct = intOr( + source.detectionFadePct, + d.detectionFadePct, + 0, + 40, + ); + state.detectionOutsideOpacityPct = intOr( + source.detectionOutsideOpacityPct, + d.detectionOutsideOpacityPct, + 0, + 100, + ); + state.scopeFeatherPct = finite(source.scopeFeatherPct) + ? clampScopeFeatherPct(Number(source.scopeFeatherPct)) + : d.scopeFeatherPct; + state.scopeTerminusPct = intOr( + source.scopeTerminusPct, + null, + SCOPE_TERMINUS_MIN_PCT, + SCOPE_TERMINUS_MAX_PCT, + ); + state.mapStack = nameOr(source.mapStack, d.mapStack); + state.layerState = source.layerState + ? normalizeLayerState(source.layerState) + : null; + state.panelState = normalizePanelState(source.panelState); + state.sharedAtMs = epochMsOr(source.sharedAtMs); + let view = normalizeView(source.view); + if (state.layerState) view = liftTrackedEntity(state.layerState, view); + if (view.kind === 'thing') { + // A fix without its own time is as of the link's creation. + if (view.lastKnown?.observedAtMs === null && state.sharedAtMs !== null) + view.lastKnown.observedAtMs = state.sharedAtMs; + // A thing view's camera is where following it puts the camera, and its + // layer is on: neither is stored separately. + if (view.lastKnown) + setCamera(state, followCameraFor(view.lastKnown, view.offset)); + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + if (state.layerState && !state.layerState.enabledLayerIds.includes(layerId)) + state.layerState = normalizeLayerState({ + ...state.layerState, + enabledLayerIds: [...state.layerState.enabledLayerIds, layerId], + }); + } + return { view, ...state }; +} + +/* ------------------------------------------------------------------ */ +/* Token and id helpers. */ +/* ------------------------------------------------------------------ */ + +/** + * The permanent number of a layer-state token: one character is its index in + * the base-36 alphabet, two characters are 36 + first × 36 + second. The + * token ledger never reassigns a token, so neither does this. + */ +export function layerTokenNumber(token) { + const a = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]); + if (token.length === 1) return a; + return 36 + a * 36 + LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]); +} + +export function layerTokenFromNumber(number) { + const base = LAYER_STATE_TOKEN_ALPHABET.length; + if (number < base) return LAYER_STATE_TOKEN_ALPHABET[number]; + const rest = number - base; + if (rest >= base * base) return null; + return ( + LAYER_STATE_TOKEN_ALPHABET[Math.floor(rest / base)] + + LAYER_STATE_TOKEN_ALPHABET[rest % base] + ); +} + +const REGISTRY_BY_TOKEN = new Map( + LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]), +); +const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char); +const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/; + +function writeEntityId(writer, entry, id) { + const field = entry.code; + if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id)) + return writer.uintField(field, Number.parseInt(id, 16)); + if (entry.idForm === 'decimal' && /^[1-9]\d{0,14}$/.test(id)) + return writer.uintField(field, Number(id)); + return writer.textField(field, id); +} + +/* ------------------------------------------------------------------ */ +/* Encoder. */ +/* ------------------------------------------------------------------ */ + +function writeEnum(writer, field, value) { + const code = SHARE_VIEW_ENUMS[field.enum].indexOf(value); + if (code >= 0) writer.uintField(field.number, code); + else writer.textField(field.number, value); +} + +function encodeLayers(writer, layerState, impliedLayerId) { + const bitset = []; + for (const id of layerState.enabledLayerIds) { + if (id === impliedLayerId) continue; + const number = layerTokenNumber(REGISTRY_BY_ID.get(id).token); + const byte = Math.floor(number / 8); + while (bitset.length <= byte) bitset.push(0); + bitset[byte] |= 1 << (number % 8); + } + writer.bytesField(fieldNumber('layerState'), bitset); + const blob = new WireWriter(); + for (const [ownerId, options] of Object.entries(layerState.options)) { + const ownerToken = REGISTRY_BY_ID.get(ownerId).token; + for (const spec of optionSpecs(ownerId)) { + const current = options[spec.key]; + if (current === null || current === layerOptionAbsentValue(ownerId, spec)) + continue; + const value = spec.encode(options[spec.key]); + blob.varint(layerTokenNumber(ownerToken)); + const isInteger = + CANONICAL_INTEGER.test(value) && + Math.abs(Number(value)) <= MAX_ZIGZAG_MAGNITUDE; + blob.varint(charNumber(spec.token) * 2 + (isInteger ? 0 : 1)); + if (isInteger) blob.varint(zigzag(Number(value))); + else { + const bytes = asciiBytes(value); + blob.varint(bytes.length); + blob.bytes.push(...bytes); + } + } + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('layerOptions'), blob.finish()); +} + +const triState = (value) => (value === null ? 0 : value ? 2 : 1); + +function encodePanels(writer, panelState) { + const byId = new Map(panelState.specs.map((spec) => [spec.id, spec])); + const blob = new WireWriter(); + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + const defaultPinned = panel.pinnable ? false : null; + if ( + spec && + spec.collapsed === panel.collapsed && + spec.pinned === defaultPinned + ) + continue; + const collapsed = spec ? triState(spec.collapsed) : 0; + const pinned = spec ? triState(spec.pinned) : 0; + blob.varint(panel.number * 16 + collapsed * 4 + pinned); + } + writer.bytesField(fieldNumber('panelState'), blob.finish()); +} + +function encodeStyleParams(writer, style, values) { + const defaults = SHARE_VIEW_STYLE_DEFAULTS[style].styleParams ?? {}; + const blob = new WireWriter(); + for (const spec of SHARE_VIEW_STYLE_PARAMS[style]) { + // Only the parameters that differ from the style's own. + if (!Object.hasOwn(values, spec.key)) continue; + if (values[spec.key] === defaults[spec.key]) continue; + blob + .varint(charNumber(spec.token)) + .varint(Math.round(values[spec.key] * 100)); + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('styleParams'), blob.finish()); +} + +const CAMERA_KEYS = new Set(['lat', 'lon', 'alt', 'heading', 'pitch', 'roll']); +const coordinateInt = (degrees) => Math.round(degrees * COORDINATE_SCALE); +const signedAngle = (degrees) => (degrees > 180 ? degrees - 360 : degrees); +const epochSeconds = (ms) => ms / 1000 - SHARE_VIEW_EPOCH_S; + +function encodeThing(writer, view, state) { + const entry = ENTITY_BY_KIND.get(view.entity.kind); + const f = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const thing = new WireWriter(); + writeEntityId(thing, entry, view.entity.id); + const { range, heading, pitch } = view.offset; + if (range !== d.range) thing.uintField(f.range, range); + if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading)); + if (pitch !== d.pitch) thing.sintField(f.pitch, pitch); + const fix = view.lastKnown; + if (fix) { + // Both coordinates are always written: their presence marks the fix. + thing.sintField(f.lat, coordinateInt(fix.lat)); + thing.sintField(f.lon, coordinateInt(fix.lon)); + if (fix.alt) thing.sintField(f.alt, fix.alt); + const observed = fix.observedAtMs; + // An absent age is 0: a fix taken as the link is made costs nothing. + if (observed !== null && state.sharedAtMs !== null) { + if (observed !== state.sharedAtMs) + thing.sintField(f.ageS, (state.sharedAtMs - observed) / 1000); + } else if (observed !== null) + thing.sintField(f.observedAt, epochSeconds(observed)); + } + writer.bytesField(fieldNumber('thing'), thing.finish()); +} + +/** The base64url text for `bytes` followed by their CRC-8. */ +function sealPayload(bytes) { + const sealed = new Uint8Array(bytes.length + 1); + sealed.set(bytes); + sealed[bytes.length] = crc8(bytes); + return encodeBase64Url(sealed); +} + +/** + * Encode a share-view state as its share code: base64url text, the `` + * in `/share/`. The state is normalized first; omitted or default + * fields cost nothing. + */ +export function encodeShareView(input) { + const state = normalizeShareViewState(input); + const thing = state.view.kind === 'thing' ? state.view : null; + // A thing view with a fix derives its camera from it (followCameraFor). + const cameraDerived = Boolean(thing?.lastKnown); + const impliedLayerId = thing + ? ENTITY_BY_KIND.get(thing.entity.kind).layerId + : null; + const writer = new WireWriter(); + writer.bytes.push(SHARE_VIEW_FORMAT_VERSION); + for (const field of SHARE_VIEW_FIELDS) { + if (cameraDerived && CAMERA_KEYS.has(field.key)) continue; + switch (field.key) { + case 'view': + // Absent means `thing` when field 13 is present, else `place`. + if (state.view.kind !== (thing ? 'thing' : 'place')) + writeEnum(writer, field, state.view.kind); + break; + case 'lat': + case 'lon': + if (state[field.key] !== field.default) + writer.sintField(field.number, coordinateInt(state[field.key])); + break; + case 'heading': + case 'roll': + if (state[field.key] !== field.default) + writer.sintField(field.number, signedAngle(state[field.key])); + break; + case 'sharedAtMs': + if (state.sharedAtMs !== null) + writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + break; + case 'flags': { + let bits = 0; + for (const flag of SHARE_VIEW_FLAGS) + if (state[flag.key] !== defaultOf(flag, state.style)) + bits += 2 ** flag.bit; + if (bits) writer.uintField(field.number, bits); + break; + } + case 'layerState': + if (state.layerState) + encodeLayers(writer, state.layerState, impliedLayerId); + break; + case 'layerOptions': + break; // written together with layerState + case 'panelState': + if (state.panelState) encodePanels(writer, state.panelState); + break; + case 'thing': + if (thing) encodeThing(writer, thing, state); + break; + case 'styleParams': + if (state.styleParams) + encodeStyleParams(writer, state.style, state.styleParams); + break; + default: + if (state[field.key] === defaultOf(field, state.style)) break; + if (field.type === 'enum') writeEnum(writer, field, state[field.key]); + else if (field.type === 'sint') + writer.sintField(field.number, state[field.key]); + else writer.uintField(field.number, state[field.key]); + } + } + return sealPayload(writer.finish()); +} + +/* ------------------------------------------------------------------ */ +/* Decoder. */ +/* ------------------------------------------------------------------ */ + +/** Collects every field, code or entry a v1 decoder did not understand. */ +class UnknownLog { + constructor() { + this.entries = []; + } + field(scope, field, wireType) { + this.entries.push({ scope, field, wireType }); + } + code(scope, code) { + this.entries.push({ scope, code }); + } +} + +/** + * Read one message's fields, enforcing each known field's wire types and + * rejecting repeats, skipping (and logging) unknown fields. + * `known` maps field number → allowed wire types. + */ +function readMessage(reader, known, scope, unknown) { + const values = new Map(); + while (!reader.done) { + const { field, wireType } = reader.tag(); + const allowed = known.get(field); + if (!allowed) { + unknown.field(scope, field, wireType); + reader.skip(wireType); + continue; + } + if (!allowed.includes(wireType)) + throw new ShareWireError('wire-type-mismatch', `${scope}.${field}`); + if (values.has(field)) + throw new ShareWireError('duplicate-field', `${scope}.${field}`); + values.set( + field, + wireType === WIRE_LEN + ? { len: reader.lengthDelimited() } + : { varint: reader.varint() }, + ); + } + return values; +} + +const WIRE_TYPES_BY_TYPE = Object.freeze({ + uint: [WIRE_VARINT], + sint: [WIRE_VARINT], + angle: [WIRE_VARINT], + coord: [WIRE_VARINT], + epoch: [WIRE_VARINT], + flags: [WIRE_VARINT], + enum: [WIRE_VARINT, WIRE_LEN], + bytes: [WIRE_LEN], +}); +const TOP_LEVEL_WIRE_TYPES = new Map( + SHARE_VIEW_FIELDS.filter((f) => !f.retired).map((f) => [ + f.number, + WIRE_TYPES_BY_TYPE[f.type], + ]), +); +const THING_WIRE_TYPES = new Map([ + ...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]), + ...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]), +]); + +const unz = unzigzag; +const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']); + +function readEnum(entry, field, unknown) { + if (!entry) return undefined; + if (entry.len) { + const text = asciiText(entry.len.rest()); + // Detection settings are closed sets the app interprets; an unknown name + // must not reach the long form's lenient migration (which turns it ON). + if ( + CLOSED_ENUMS.has(field.key) && + !SHARE_VIEW_ENUMS[field.enum].includes(text) + ) { + unknown.code(`enum:${field.key}`, text); + return undefined; + } + return text; + } + const name = SHARE_VIEW_ENUMS[field.enum][entry.varint]; + if (name === undefined) unknown.code(`enum:${field.key}`, entry.varint); + return name; +} + +function decodeLayers(bitsReader, optionsReader, unknown) { + const enabledLayerIds = []; + const bits = bitsReader.rest(); + for (let byte = 0; byte < bits.length; byte += 1) { + for (let bit = 0; bit < 8; bit += 1) { + if (!(bits[byte] & (1 << bit))) continue; + const number = byte * 8 + bit; + const entry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(number)); + if (entry) enabledLayerIds.push(entry.id); + else unknown.code('layer', number); + } + } + const options = {}; + const seen = new Set(); + const reader = optionsReader; + while (reader && !reader.done) { + const layerNumber = reader.varint(); + const key = reader.varint(); + let value; + if (key % 2 === 0) value = String(unz(reader.varint())); + else { + const length = reader.varint(); + if (length > MAX_OPTION_VALUE_CHARS || length > reader.end - reader.pos) + throw new ShareWireError('truncated', 'layer option'); + value = asciiText(reader.bytes.slice(reader.pos, reader.pos + length)); + reader.pos += length; + } + const slot = `${layerNumber}.${Math.floor(key / 2)}`; + if (seen.has(slot)) throw new ShareWireError('duplicate-entry', slot); + seen.add(slot); + // Options are filed under their owner layer only (never a mirror such as + // `military`), so each option has exactly one spelling. + const layerEntry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(layerNumber)); + const ownerId = + layerEntry?.optionOwner === layerEntry?.id + ? layerEntry?.optionOwner + : null; + const optionToken = LAYER_STATE_TOKEN_ALPHABET[Math.floor(key / 2)]; + const spec = ownerId + ? optionSpecs(ownerId).find( + (candidate) => candidate.token === optionToken, + ) + : null; + const decoded = spec ? spec.decode(value) : null; + if (decoded === null || decoded === undefined) { + unknown.code('layer-option', slot); + continue; + } + options[ownerId] ??= {}; + options[ownerId][spec.key] = decoded; + } + // Fill every option the payload did not carry with ITS frozen meaning, not + // the app's current default (normalization would substitute that). + for (const ownerId of Object.keys(SHARE_VIEW_LAYER_OPTION_ABSENT)) { + options[ownerId] ??= {}; + for (const spec of optionSpecs(ownerId)) { + if (!Object.hasOwn(options[ownerId], spec.key)) + options[ownerId][spec.key] = layerOptionAbsentValue(ownerId, spec); + } + } + return normalizeLayerState({ enabledLayerIds, options }); +} + +function decodePanels(reader, unknown) { + const entries = new Map(); + while (!reader.done) { + const value = reader.varint(); + const number = Math.floor(value / 16); + if (entries.has(number)) + throw new ShareWireError('duplicate-entry', `panel ${number}`); + entries.set(number, { + collapsed: Math.floor(value / 4) % 4, + pinned: value % 4, + }); + } + for (const [number, entry] of entries) { + if ( + !PANEL_BY_NUMBER.has(number) || + entry.collapsed === 3 || + entry.pinned === 3 + ) + unknown.code('panel', number); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const entry = entries.get(panel.number); + if (entry) { + // 0 is "no opinion"; 3 is a value from a newer format, also ignored. + if (entry.collapsed === 0 || entry.collapsed === 3 || entry.pinned === 3) + continue; + specs.push({ + id: panel.id, + collapsed: entry.collapsed === 2, + pinned: + panel.pinnable && entry.pinned !== 0 ? entry.pinned === 2 : null, + }); + } else { + specs.push({ + id: panel.id, + collapsed: panel.collapsed, + pinned: panel.pinnable ? false : null, + }); + } + } + return { specs }; +} + +function decodeStyleParams(reader, style, unknown) { + const registry = SHARE_VIEW_STYLE_PARAMS[style] || []; + const values = {}; + const seen = new Set(); + while (!reader.done) { + const index = reader.varint(); + const token = LAYER_STATE_TOKEN_ALPHABET[index]; + const scaled = reader.varint(); + if (seen.has(index)) + throw new ShareWireError('duplicate-entry', 'style parameter'); + seen.add(index); + const spec = registry.find((candidate) => candidate.token === token); + if (!spec) { + unknown.code('style-param', `${style}.${token ?? '?'}`); + continue; + } + values[spec.key] = clamp(scaled / 100, spec.min, spec.max); + } + return values; +} + +function decodeEntityId(entry, value) { + if (!entry) return null; + if (value.len) return normalizeEntityId(entry, asciiText(value.len.rest())); + if (entry.idForm === 'hex24') + return value.varint <= 0xffffff + ? value.varint.toString(16).padStart(6, '0') + : null; + return normalizeEntityId(entry, String(value.varint)); +} + +function decodeThing(reader, createdAtS, unknown) { + const unknownBefore = unknown.entries.length; + const fields = readMessage(reader, THING_WIRE_TYPES, 'thing', unknown); + const F = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const signed = (n) => unz(fields.get(n)?.varint ?? 0); + const offset = { + range: fields.get(F.range)?.varint ?? d.range, + heading: fields.has(F.heading) ? signed(F.heading) : d.heading, + pitch: fields.has(F.pitch) ? signed(F.pitch) : d.pitch, + }; + let lastKnown = null; + if (fields.has(F.lat) || fields.has(F.lon)) { + let observedAtMs = null; + if (fields.has(F.observedAt)) + observedAtMs = (SHARE_VIEW_EPOCH_S + signed(F.observedAt)) * 1000; + else if (createdAtS !== null) + observedAtMs = (createdAtS - signed(F.ageS)) * 1000; + lastKnown = { + lat: signed(F.lat) / COORDINATE_SCALE, + lon: signed(F.lon) / COORDINATE_SCALE, + alt: signed(F.alt), + observedAtMs, + }; + if (Math.abs(lastKnown.lat) > 90 || Math.abs(lastKnown.lon) > 180) + throw new ShareWireError('out-of-range', 'thing fix'); + } + // The camera this view falls back to, whatever happens to the entity. + const camera = lastKnown + ? followCameraFor(lastKnown, normalizeFollowOffset(offset)) + : null; + const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code)); + if (present.length > 1) + throw new ShareWireError('duplicate-field', 'thing entity id'); + const entry = present[0]; + if (!entry) { + // A newer app's entity kind uses a thing field this decoder skipped. + const newerKind = unknown.entries.length > unknownBefore; + return { + camera, + fallback: newerKind ? 'unknown-entity-kind' : 'missing-entity-kind', + }; + } + const id = decodeEntityId(entry, fields.get(entry.code)); + if (!id) return { camera, fallback: 'invalid-entity-id' }; + return { camera, view: thingView(entry.kind, id, offset, lastKnown) }; +} + +/** + * Decode a share code (the `` in `/share/`). + * + * Returns `{ ok: true, state, unknownFields?, fallback? }` with a normalized + * state, or `{ ok: false, reason }`. It never throws. A code whose check byte + * does not match is rejected (`checksum`), so a mistyped code never opens a + * different view. `unknownFields` lists what a newer encoder wrote that this + * decoder skipped; `fallback` says why a view that asked to follow something + * opens as a place at its camera instead. A created-at time in the future + * (after `nowMs`) is dropped. + */ +export function decodeShareView(code, { nowMs = Date.now() } = {}) { + try { + if (typeof code !== 'string') return { ok: false, reason: 'not-a-string' }; + if (code.length > MAX_SHARE_VIEW_CHARS) + return { ok: false, reason: 'too-long' }; + const sealed = decodeBase64Url(code); + if (sealed.length < 2) return { ok: false, reason: 'truncated' }; + const bytes = sealed.subarray(0, sealed.length - 1); + if (crc8(bytes) !== sealed[sealed.length - 1]) + return { ok: false, reason: 'checksum' }; + if (bytes[0] !== SHARE_VIEW_FORMAT_VERSION) + return { ok: false, reason: 'unsupported-version' }; + const unknown = new UnknownLog(); + const fields = readMessage( + new WireReader(bytes, 1), + TOP_LEVEL_WIRE_TYPES, + 'view', + unknown, + ); + const get = (key) => fields.get(fieldNumber(key)); + + const raw = {}; + for (const field of SHARE_VIEW_FIELDS) { + const entry = fields.get(field.number); + if ( + !entry || + field.type === 'bytes' || + field.type === 'flags' || + field.key === 'view' + ) + continue; + if (field.type === 'enum') + raw[field.key] = readEnum(entry, field, unknown); + else if (field.type === 'coord') + raw[field.key] = unz(entry.varint) / COORDINATE_SCALE; + else if (field.type === 'sint' || field.type === 'angle') + raw[field.key] = unz(entry.varint); + else if (field.type === 'uint') raw[field.key] = entry.varint; + } + for (const key of ['lat', 'lon']) { + raw[key] ??= 0; + if (Math.abs(raw[key]) > (key === 'lat' ? 90 : 180)) + return { ok: false, reason: 'out-of-range' }; + } + + if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) { + if (get('style').len) unknown.code('enum:style', raw.style); + raw.style = undefined; + } + for (const key of ['hudVariant', 'mapStack']) { + if (raw[key] !== undefined && !NAME_GRAMMAR.test(raw[key])) + unknown.code(`enum:${key}`, raw[key]); + } + + const flags = get('flags')?.varint ?? 0; + for (const flag of SHARE_VIEW_FLAGS) + raw[flag.key] = + (Math.floor(flags / 2 ** flag.bit) % 2 === 1) !== + defaultOf(flag, raw.style ?? 'normal'); + const knownBits = 2 ** SHARE_VIEW_FLAGS.length - 1; + if (flags > knownBits) + unknown.code('flags', flags - (flags % (knownBits + 1))); + + let createdAtS = null; + if (get('sharedAtMs')) { + createdAtS = SHARE_VIEW_EPOCH_S + unz(get('sharedAtMs').varint); + const ms = createdAtS * 1000; + raw.sharedAtMs = + createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; + } + + if (get('layerState')) + raw.layerState = decodeLayers( + get('layerState').len, + get('layerOptions')?.len ?? null, + unknown, + ); + else if (get('layerOptions')) + unknown.code('layer-option', 'without-layers'); + if (get('panelState')) + raw.panelState = decodePanels(get('panelState').len, unknown); + if (get('styleParams')) + raw.styleParams = decodeStyleParams( + get('styleParams').len, + raw.style ?? 'normal', + unknown, + ); + + let fallback; + const thing = get('thing') + ? decodeThing(get('thing').len, createdAtS, unknown) + : null; + // A thing with a last-known fix carries the view's camera. + if (thing?.camera) Object.assign(raw, thing.camera); + const viewEntry = get('view'); + const viewKind = viewEntry + ? viewEntry.len + ? asciiText(viewEntry.len.rest()) + : (SHARE_VIEW_ENUMS.viewKind[viewEntry.varint] ?? viewEntry.varint) + : thing + ? 'thing' + : 'place'; + if (viewKind === 'thing') { + if (thing?.view) raw.view = thing.view; + else fallback = thing?.fallback ?? 'missing-thing'; + } else if (viewKind !== 'place') { + unknown.code('enum:view', viewKind); + fallback = 'unknown-view-kind'; + } + + const state = normalizeShareViewState(raw); + const result = { ok: true, state }; + if (unknown.entries.length) result.unknownFields = unknown.entries; + if (fallback) result.fallback = fallback; + return result; + } catch (error) { + if (error instanceof ShareWireError) + return { ok: false, reason: error.reason }; + return { ok: false, reason: 'internal-error' }; + } +} diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs new file mode 100644 index 000000000..4ec2b967c --- /dev/null +++ b/src/share/viewCodec.test.mjs @@ -0,0 +1,1307 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_RESERVATIONS, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { VIEW_FOLLOW_KINDS, VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { ALLOCATION_STRATEGIES } from '../data/detectionPolicy.js'; +import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from '../bloom.js'; +import { + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SCOPE_TERMINUS_MAX_PCT, + SCOPE_TERMINUS_MIN_PCT, +} from '../scopeMask.js'; +import { + decodePanelStateParams, + decodeStyleParamState, + encodeStyleParamState, +} from '../sharelink.js'; +import { + WIRE_FIXED32, + WIRE_FIXED64, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, +} from './wire.js'; +import { + MAX_SHARE_VIEW_CHARS, + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_ENUMS, + SHARE_VIEW_EPOCH_S, + SHARE_VIEW_FIELDS, + BY_STYLE, + SHARE_VIEW_FLAGS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + SHARE_VIEW_STYLE_DEFAULTS, + SHARE_VIEW_LAYER_OPTION_ABSENT, + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + createDefaultShareViewState, + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, + layerTokenFromNumber, + layerTokenNumber, + normalizeShareViewState, +} from './viewCodec.js'; + +const NOW_MS = Date.UTC(2026, 9, 26, 12, 0, 0); +const decode = (hash) => decodeShareView(hash, { nowMs: NOW_MS }); +/** The payload bytes of a code, without its check byte. */ +const payloadBytes = (code) => decodeBase64Url(code).slice(0, -1); +/** The code for payload bytes: base64url of the bytes and their CRC-8. */ +const hashOf = (bytes) => encodeBase64Url([...bytes, crc8(bytes)]); + +/** Decode and require success, returning the result. */ +function decodeOk(hash) { + const result = decode(hash); + assert.equal(result.ok, true, `decode failed: ${result.reason}`); + return result; +} + +function assertRoundTrip(state) { + const hash = encodeShareView(state); + const result = decodeOk(hash); + assert.deepEqual(result.state, normalizeShareViewState(state)); + assert.equal(result.unknownFields, undefined); + assert.equal(result.fallback, undefined); + assert.equal(encodeShareView(result.state), hash, 'encoding is canonical'); + return hash; +} + +function randomState(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + const maybe = (p, value) => (random() < p ? value : undefined); + const style = pick(random, VIEW_STYLES); + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const lat = int(-900000, 900000) / 1e4; + const lon = int(-1800000, 1800000) / 1e4; + const state = { + lat, + lon, + alt: pick(random, [800, int(-400, 50000), int(0, 40_000_000)]), + heading: int(0, 359), + pitch: int(-90, 90), + roll: pick(random, [0, int(0, 359)]), + style, + styleParams: + params && random() < 0.6 + ? Object.fromEntries( + params + .filter(() => random() < 0.7) + .map((s) => [s.key, int(s.min * 100, s.max * 100) / 100]), + ) + : null, + bloomIntensity: maybe(0.5, int(0, 100)), + bloomVersion: maybe(0.3, pick(random, [1, 2])), + sharpenIntensity: maybe(0.5, int(0, 100)), + hudVariant: maybe(0.5, pick(random, [...HUD_LAYOUTS, 'future-hud'])), + detectionMode: maybe(0.6, pick(random, SHARE_VIEW_ENUMS.detectionMode)), + detectionDensity: maybe(0.6, pick(random, [0, 25, 50, 75, 100])), + detectionAllocation: maybe(0.4, pick(random, ALLOCATION_STRATEGIES)), + detectionFadePct: maybe(0.5, int(0, 40)), + detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), + scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeTerminusPct: maybe( + 0.3, + int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), + ), + mapStack: maybe(0.5, pick(random, [...VIEW_MAPS, 'future-map'])), + layerState: random() < 0.7 ? randomLayerState(random) : null, + panelState: + random() < 0.6 + ? { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.8).map( + (panel) => ({ + id: panel.id, + collapsed: random() < 0.5, + pinned: panel.pinnable + ? pick(random, [true, false, null]) + : null, + }), + ), + } + : null, + sharedAtMs: maybe(0.7, int(1, 2_100_000_000) * 1000), + }; + for (const flag of SHARE_VIEW_FLAGS) + if (random() < 0.5) state[flag.key] = random() < 0.5; + if (random() < 0.4) { + const entity = pick(random, SHARE_VIEW_ENTITY_KINDS); + const id = + entity.kind === 'satellite' + ? String(int(1, 99999)) + : entity.kind === 'vessel' + ? pick(random, [ + String(int(100000000, 999999999)), + '002442000', + 'tmp-7', + ]) + : pick(random, [ + int(0, 0xffffff).toString(16).padStart(6, '0'), + '~abc123', + 'adsb_x-1', + ]); + state.view = { + kind: 'thing', + entity: { kind: entity.kind, id }, + offset: + random() < 0.6 + ? { range: int(0, 200000), heading: int(0, 359), pitch: int(-90, 90) } + : null, + lastKnown: + random() < 0.6 + ? { + lat: Math.max(-90, Math.min(90, lat + int(-5000, 5000) / 1e4)), + lon: Math.max(-180, Math.min(180, lon + int(-5000, 5000) / 1e4)), + alt: int(-100, 500000), + observedAtMs: maybe(0.8, int(1, 2_100_000_000) * 1000), + } + : null, + }; + } + return state; +} + +test('an all-default view is the version byte and its check byte', () => { + const hash = encodeShareView(createDefaultShareViewState()); + assert.equal(hash, 'AQc'); + assert.deepEqual(decodeOk(hash).state, normalizeShareViewState({})); + assert.deepEqual(normalizeShareViewState({}), createDefaultShareViewState()); +}); + +test('each setting costs bytes only when it differs from its default', () => { + const base = payloadBytes(encodeShareView({})).length; + const changes = { + lat: 10, + lon: 10, + alt: 900, + heading: 1, + pitch: -36, + roll: 1, + sharedAtMs: NOW_MS, + style: 'noir', + mapStack: 'osm', + hudVariant: 'cyber', + bloomIntensity: 1, + bloomVersion: 1, + sharpenIntensity: 50, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 8, + detectionOutsideOpacityPct: 2, + scopeFeatherPct: 99, + scopeTerminusPct: 95, + }; + const defaults = createDefaultShareViewState(); + for (const [key, value] of Object.entries(changes)) { + assert.equal( + payloadBytes(encodeShareView({ [key]: defaults[key] })).length, + base, + `${key} at its default is omitted`, + ); + const changed = { [key]: value }; + if (key === 'detectionMode') changed.detectionDensity = 75; + assert.ok( + payloadBytes(assertRoundTrip(changed)).length > base, + `${key} is encoded`, + ); + } + for (const flag of SHARE_VIEW_FLAGS) { + assert.equal( + payloadBytes(encodeShareView({ [flag.key]: defaults[flag.key] })).length, + base, + ); + assert.equal( + payloadBytes(assertRoundTrip({ [flag.key]: !defaults[flag.key] })).length, + base + 2, + ); + } + // All five booleans flipped still cost one field. + const flipped = Object.fromEntries( + SHARE_VIEW_FLAGS.map((f) => [f.key, !defaults[f.key]]), + ); + assert.equal(payloadBytes(assertRoundTrip(flipped)).length, base + 2); +}); + +test('a view just switched to any style costs its camera and style only', () => { + const camera = { lat: 48.8584, lon: 2.2945, alt: 1200, heading: 120 }; + const cameraOnly = payloadBytes(encodeShareView(camera)).length; + for (const style of VIEW_STYLES) { + // What the app holds right after selecting the style in a fresh session. + const fresh = { ...camera, ...SHARE_VIEW_STYLE_DEFAULTS[style], style }; + const bytes = payloadBytes(assertRoundTrip(fresh)).length; + assert.equal(bytes, cameraOnly + (style === 'normal' ? 0 : 2), style); + // Changes on top of the style's look still travel and round-trip. + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const changed = { + ...fresh, + sharpen: false, + detectionMode: 'OFF', + detectionFadePct: 16, + ...(params + ? { + styleParams: { + ...fresh.styleParams, + [params[0].key]: params[0].min, + }, + } + : {}), + }; + assert.ok(payloadBytes(assertRoundTrip(changed)).length > bytes, style); + } +}); + +test('invalid style parameters keep the style’s own value', () => { + for (const bad of [undefined, NaN, 'x', null]) + assertRoundTrip({ style: 'noir', styleParams: { contrastAmt: bad } }); + assert.equal( + normalizeShareViewState({ style: 'noir', styleParams: { grainAmt: NaN } }) + .styleParams.grainAmt, + 0.5, + ); +}); + +test('every registered field round-trips at once', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [ + 'flights', + 'satellites', + 'radio', + 'recent-imagery', + 'street-level', + ]; + Object.assign(layerState.options.flights, { + models3d: true, + models3dMode: 'all', + }); + Object.assign(layerState.options.radio, { + filter: 'genre:jazz & blues', + volume: 0.35, + }); + Object.assign(layerState.options['recent-imagery'], { + west: -12345678, + east: 12345678, + south: -8505110, + north: 8505110, + a: 'S30:2026-09-18', + b: 'VIIRS:2025-01-02', + mode: 2, + split: 37, + viirs: true, + }); + Object.assign(layerState.options['street-level'], { + mapillary: false, + providerSwitches: 'abc-1*def-0', + sinceDays: 365, + }); + const state = { + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { range: 1500, heading: 300, pitch: -20 }, + lastKnown: { + lat: -33.8601, + lon: 151.2101, + alt: 10668, + observedAtMs: NOW_MS - 12_000, + }, + }, + lat: -33.8568, + lon: 151.2153, + alt: 12000, + heading: 270, + pitch: -12, + roll: 2, + style: 'thermal', + styleParams: { + sensitivity: 0.42, + bloom: 1, + mode: 0, + pixelation: 3.5, + palette: 0.25, + }, + bloom: true, + sharpen: false, + bloomIntensity: 84, + bloomVersion: 1, + sharpenIntensity: 65, + hudVariant: 'operator', + hudVisible: false, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 16, + detectionOutsideOpacityPct: 5, + celestialRing: true, + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + mapStack: 'esri-imagery', + layerState, + panelState: { + specs: SHARE_VIEW_PANELS.map((panel) => ({ + id: panel.id, + collapsed: !panel.collapsed, + pinned: panel.pinnable ? true : null, + })), + }, + sharedAtMs: NOW_MS, + }; + // A thing view derives its camera (roll 0), so the camera fields are + // checked on the same state as a place view. + const place = { ...state }; + delete place.view; + for (const subject of [state, place]) { + const normalized = normalizeShareViewState(subject); + for (const key of Object.keys(subject)) { + if (subject === state && key === 'roll') continue; + assert.notDeepEqual( + normalized[key], + createDefaultShareViewState()[key] ?? undefined, + key, + ); + } + assertRoundTrip(subject); + } +}); + +test('generated states round-trip exactly and encode canonically', () => { + const random = rng(0x5eed); + let things = 0; + for (let i = 0; i < 1500; i += 1) { + const state = randomState(random); + if (state.view?.kind === 'thing') things += 1; + const hash = encodeShareView(state); + const result = decodeShareView(hash, { nowMs: Number.MAX_SAFE_INTEGER }); + assert.equal(result.ok, true, `case ${i}: ${result.reason}`); + assert.deepEqual(result.state, normalizeShareViewState(state), `case ${i}`); + assert.equal(encodeShareView(result.state), hash, `case ${i} canonical`); + assert.ok(hash.length < MAX_SHARE_VIEW_CHARS, `case ${i} size`); + } + assert.ok(things > 400, 'generator covers thing views'); +}); + +test('normalization is idempotent', () => { + const random = rng(42); + for (let i = 0; i < 300; i += 1) { + const once = normalizeShareViewState(randomState(random)); + assert.deepEqual(normalizeShareViewState(once), once); + } +}); + +test('precision: coordinates 1e-4 degrees, angles and lengths whole units', () => { + const { state } = decodeOk( + encodeShareView({ + lat: 30.26724, + lon: -97.74316, + alt: 600.6, + heading: 15.4, + pitch: -30.5, + roll: 359.6, + }), + ); + assert.equal(state.lat, 30.2672); + assert.equal(state.lon, -97.7432); + assert.equal(state.alt, 601); + assert.equal(state.heading, 15); + assert.equal(state.pitch, -30); + assert.equal(state.roll, 0, '359.6 rounds to 360, which is 0'); + // Angles wrap into [0, 360) and are stored as the nearer signed turn. + assert.equal(decodeOk(encodeShareView({ heading: -10 })).state.heading, 350); + assert.equal(payloadBytes(encodeShareView({ heading: 350 })).length, 3); + // Out-of-range input is clamped or wrapped, never rejected. + const wild = decodeOk( + encodeShareView({ lat: 95, lon: 190, pitch: -120 }), + ).state; + assert.deepEqual([wild.lat, wild.lon, wild.pitch], [90, -170, -90]); +}); + +test('defaults travel as frozen meanings that match today’s app defaults', () => { + const defaults = createDefaultShareViewState(); + // When an app default moves, these tests should be updated to document the + // drift; the codec's defaults table must NOT change (see SHARE-LINK-FORMAT). + assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT); + assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION); + assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]); + assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [ + ...HUD_LAYOUTS, + ]); + assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]); + assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]); + assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [ + ...ALLOCATION_STRATEGIES, + ]); + for (const kind of VIEW_FOLLOW_KINDS) + assert.ok( + SHARE_VIEW_ENTITY_KINDS.some((entry) => entry.kind === kind), + kind, + ); +}); + +test('the field registry is well formed and permanent', () => { + const numbers = SHARE_VIEW_FIELDS.map((f) => f.number); + assert.equal(new Set(numbers).size, numbers.length); + assert.equal( + new Set(SHARE_VIEW_FIELDS.map((f) => f.key)).size, + numbers.length, + ); + // Snapshot: changing a number, key or default breaks links in the wild. + assert.deepEqual( + SHARE_VIEW_FIELDS.map((f) => [f.number, f.key, f.default]), + [ + [1, 'view', 'place'], + [2, 'lat', 0], + [3, 'lon', 0], + [4, 'alt', 800], + [5, 'heading', 0], + [6, 'pitch', -35], + [7, 'roll', 0], + [8, 'sharedAtMs', null], + [9, 'flags', 0], + [10, 'layerState', null], + [11, 'layerOptions', null], + [12, 'panelState', null], + [13, 'thing', null], + [14, 'style', 'normal'], + [15, 'mapStack', 'photoreal'], + [16, 'hudVariant', BY_STYLE], + [17, 'bloomIntensity', BY_STYLE], + [18, 'bloomVersion', 2], + [19, 'sharpenIntensity', BY_STYLE], + [20, 'detectionMode', BY_STYLE], + [21, 'detectionDensity', BY_STYLE], + [22, 'detectionAllocation', BY_STYLE], + [23, 'detectionFadePct', BY_STYLE], + [24, 'detectionOutsideOpacityPct', BY_STYLE], + [25, 'scopeFeatherPct', 100], + [26, 'scopeTerminusPct', null], + [27, 'styleParams', BY_STYLE], + ], + ); + assert.deepEqual( + SHARE_VIEW_FLAGS.map((f) => [f.bit, f.key, f.default]), + [ + [0, 'bloom', BY_STYLE], + [1, 'sharpen', BY_STYLE], + [2, 'hudVisible', BY_STYLE], + [3, 'celestialRing', BY_STYLE], + [4, 'scopeEnabled', true], + ], + ); + // The style-applied defaults are frozen meanings too. If the app's style + // tables move and this fails, freeze these values in the codec instead of + // updating the snapshot: links already shared depend on them. + const common = { + bloom: false, + bloomIntensity: 0, + sharpen: true, + sharpenIntensity: 49, + hudVariant: 'tactical', + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, + }; + assert.deepEqual(JSON.parse(JSON.stringify(SHARE_VIEW_STYLE_DEFAULTS)), { + normal: { ...common, styleParams: null }, + retro: { + ...common, + styleParams: { pixelation: 1, distortion: 0, instability: 0.42 }, + }, + surveillance: { + ...common, + styleParams: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1, + }, + }, + thermal: { + ...common, + styleParams: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1, + palette: 0, + }, + }, + anime: { ...common, styleParams: { saturation: 1, edgeThick: 0.5 } }, + noir: { + ...common, + styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 }, + }, + snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } }, + }); + assert.deepEqual( + SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]), + [ + [1, 'aircraft'], + [2, 'military_aircraft'], + [3, 'vessel'], + [4, 'satellite'], + ], + ); + assert.deepEqual( + SHARE_VIEW_PANELS.map((p) => [p.number, p.id]), + SHARE_VIEW_PANELS.map((p, i) => [i, p.id]), + ); +}); + +test('the panel table agrees with the long form’s panel tokens', () => { + for (const panel of SHARE_VIEW_PANELS) { + const decoded = decodePanelStateParams( + new URLSearchParams(`v=2&ui=${panel.token}.c.1_${panel.token}.p.1`), + ); + assert.deepEqual(decoded.specs, [ + { id: panel.id, collapsed: true, pinned: panel.pinnable ? true : null }, + ]); + } +}); + +test('the style parameter table agrees with the long form’s `sp` codec', () => { + for (const [style, specs] of Object.entries(SHARE_VIEW_STYLE_PARAMS)) { + const values = Object.fromEntries( + specs.map((s, i) => [s.key, s.min + ((s.max - s.min) * (i + 1)) / 7]), + ); + const params = new URLSearchParams('v=2'); + encodeStyleParamState(params, style, values); + const legacy = decodeStyleParamState(params, style); + assert.deepEqual( + normalizeShareViewState({ style, styleParams: values }).styleParams, + legacy, + style, + ); + assert.deepEqual( + decodeOk(encodeShareView({ style, styleParams: values })).state + .styleParams, + legacy, + style, + ); + } +}); + +test('every layer option has a frozen absent meaning', () => { + for (const entry of LAYER_STATE_REGISTRY) { + if (!entry.optionOwner) continue; + for (const spec of optionSpecs(entry.optionOwner)) { + assert.ok( + Object.hasOwn( + SHARE_VIEW_LAYER_OPTION_ABSENT[entry.optionOwner] || {}, + spec.key, + ), + `${entry.optionOwner}.${spec.key} needs an entry in SHARE_VIEW_LAYER_OPTION_ABSENT`, + ); + } + } + // A default-only layer state writes no option bytes at all. + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + const bytes = payloadBytes(encodeShareView({ layerState })); + assert.equal(bytes.includes(0x5a), false, 'no layer-options field'); +}); + +test('layer tokens map to permanent numbers covering the whole ledger', () => { + for (const token of Object.values(LAYER_STATE_TOKEN_RESERVATIONS)) { + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + } + for (const token of ['0', '9', 'a', 'z', '00', '0z', 'zz']) + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + assert.equal(layerTokenNumber('00'), 36); + assert.equal(layerTokenFromNumber(36 + 36 * 36), null); + const numbers = LAYER_STATE_REGISTRY.map((e) => layerTokenNumber(e.token)); + assert.equal(new Set(numbers).size, numbers.length); +}); + +test('layer sets: absent, explicitly empty and populated stay distinct', () => { + assert.equal( + decodeOk(encodeShareView({ layerState: null })).state.layerState, + null, + ); + const empty = decodeOk( + encodeShareView({ layerState: createDefaultLayerState() }), + ).state.layerState; + assert.deepEqual(empty, createDefaultLayerState()); + const all = createDefaultLayerState(); + all.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + assertRoundTrip({ layerState: all }); +}); + +test('panels: only differences from the frozen layout are written', () => { + const defaults = { + specs: SHARE_VIEW_PANELS.map((p) => ({ + id: p.id, + collapsed: p.collapsed, + pinned: p.pinnable ? false : null, + })), + }; + const base = payloadBytes(encodeShareView({})).length; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: defaults })).length, + base + 2, + ); + const oneChanged = structuredClone(defaults); + oneChanged.specs[3].collapsed = false; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: oneChanged })).length, + base + 3, + ); + // A partial panel list (an older link) keeps its gaps as "no opinion". + const partial = { + specs: [{ id: 'cctv-panel', collapsed: false, pinned: null }], + }; + assert.deepEqual( + decodeOk(encodeShareView({ panelState: partial })).state.panelState, + partial, + ); +}); + +test('a place view opens at its camera; a thing view also names what to follow', () => { + const place = decodeOk(encodeShareView({ lat: 1, lon: 2 })).state; + assert.deepEqual(place.view, { kind: 'place' }); + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const id = entry.idForm === 'hex24' ? '3c6444' : '25544'; + const { state } = decodeOk( + encodeShareView({ + lat: 1, + lon: 2, + view: { kind: 'thing', entity: { kind: entry.kind, id } }, + }), + ); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: entry.kind, id }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + // Without a last-known fix the view keeps its stored camera. + assert.equal(state.lat, 1); + assert.equal(state.lon, 2); + } +}); + +test('thing ids use the compact numeric form when they can, text otherwise', () => { + const size = (kind, id) => + payloadBytes( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).length; + assert.equal(size('aircraft', 'ffffff'), size('aircraft', '000001') + 3); + assert.ok(size('aircraft', '~abc123') > size('aircraft', 'abc123')); + assert.ok( + size('vessel', '366999712') < size('vessel', '002442000'), + 'leading zeros keep the text form', + ); + for (const [kind, id] of [ + ['aircraft', '000000'], + ['vessel', '002442000'], + ['military_aircraft', 'adsb_x-1'], + ['satellite', '1'], + ]) + assert.equal( + decodeOk( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).state.view.entity.id, + id, + ); + // Invalid ids and kinds normalize to a place view. + for (const entity of [ + { kind: 'aircraft', id: 'NOT VALID' }, + { kind: 'satellite', id: 'abc' }, + { kind: 'ufo', id: '1' }, + ]) + assert.deepEqual( + normalizeShareViewState({ view: { kind: 'thing', entity } }).view, + { kind: 'place' }, + ); + assert.equal( + normalizeShareViewState({ + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'ABC123' } }, + }).view.entity.id, + 'abc123', + ); +}); + +test('last-known time is stored relative to the created-at time when there is one', () => { + const lastKnown = { + lat: 10.0012, + lon: 20.0034, + alt: 11000, + observedAtMs: NOW_MS - 15_000, + }; + const view = { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + lastKnown, + }; + const withCreated = assertRoundTrip({ + lat: 10, + lon: 20, + sharedAtMs: NOW_MS, + view, + }); + const withoutCreated = assertRoundTrip({ lat: 10, lon: 20, view }); + assert.deepEqual(decodeOk(withoutCreated).state.view.lastKnown, lastKnown); + // Relative age (15 s) is 1 byte; the absolute time is 4. + assert.ok( + payloadBytes(withCreated).length - 5 < payloadBytes(withoutCreated).length, + ); +}); + +test('tracking selections in layer options become a thing view', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['military']; + layerState.options.flights.selectedMilitaryTrackingId = 'AE1234'; + const state = normalizeShareViewState({ layerState }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'military_aircraft', id: 'ae1234' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + assert.equal( + state.layerState.options.flights.selectedMilitaryTrackingId, + null, + ); + assertRoundTrip({ layerState }); +}); + +test('a created-at time in the future is dropped, like the long form’s `at`', () => { + const hash = encodeShareView({ sharedAtMs: NOW_MS + 60_000 }); + assert.equal(decode(hash).state.sharedAtMs, null); + assert.equal( + decodeShareView(hash, { nowMs: NOW_MS + 60_000 }).state.sharedAtMs, + NOW_MS + 60_000, + ); + // Times on either side of the format epoch survive. + for (const ms of [1000, (SHARE_VIEW_EPOCH_S - 86400) * 1000, NOW_MS]) + assert.equal( + decode(encodeShareView({ sharedAtMs: ms })).state.sharedAtMs, + ms, + ); +}); + +test('a code is the bare base64url payload; URL forms are not codes', () => { + const code = encodeShareView({ lat: 51.5, lon: -0.12 }); + assert.match(code, /^[A-Za-z0-9_-]+$/); + for (const form of [`#${code}`, `s=${code}`, `/share/${code}`]) + assert.equal(decode(form).ok, false, form); +}); + +/* ---------------- forward compatibility ---------------- */ + +/** Append raw fields to an encoded hash, as a newer encoder would. */ +function withExtraFields(hash, write) { + const writer = new WireWriter(); + writer.bytes.push(...payloadBytes(hash)); + write(writer); + return hashOf(writer.finish()); +} + +test('unknown top-level fields of every wire type are skipped and reported', () => { + const state = { + lat: 48.8584, + lon: 2.2945, + style: 'noir', + layerState: { enabledLayerIds: ['flights'] }, + }; + const hash = encodeShareView(state); + const future = withExtraFields(hash, (w) => { + w.uintField(40, 123456); + w.bytesField(41, [9, 9, 9, 0xff, 0]); + w.tag(42, WIRE_FIXED32).bytes.push(1, 2, 3, 4); + w.tag(43, WIRE_FIXED64).bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + w.sintField(2000, -5); + }); + const result = decodeOk(future); + assert.deepEqual(result.state, decodeOk(hash).state); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field, u.wireType]), + [ + ['view', 40, 0], + ['view', 41, 2], + ['view', 42, 5], + ['view', 43, 1], + ['view', 2000, 0], + ], + ); +}); + +test('unknown fields inside a thing view, codes and bits are skipped too', () => { + const thing = new WireWriter(); + thing.uintField(1, 0xa1b2c3).uintField(5, 900); + thing.textField(20, 'cockpit'); // a future thing field + const writer = new WireWriter(); + writer.bytes.push(1); + writer.sintField(2, 100000).uintField(9, 0b100001); // flag bit 5 unknown + writer.bytesField( + 10, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); // layer 160 + writer.bytesField(12, new WireWriter().varint(11 * 16 + 4 + 1).finish()); // panel 11 + writer.uintField(14, 99); // style code 99 + writer.bytesField(13, thing.finish()); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, range: 900 }, + lastKnown: null, + }); + assert.equal(result.state.bloom, true); + assert.equal(result.state.style, 'normal'); + // The followed entity's layer is implied. + assert.deepEqual(result.state.layerState.enabledLayerIds, ['flights']); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field ?? u.code]), + [ + ['enum:style', 99], + ['flags', 32], + ['layer', 160], + ['panel', 11], + ['thing', 20], + ], + ); +}); + +test('an unknown view kind falls back to the stored camera', () => { + const camera = { + lat: 35.6762, + lon: 139.6503, + alt: 2500, + heading: 45, + pitch: -20, + }; + const base = payloadBytes(encodeShareView(camera)); + const expected = decodeOk(encodeShareView(camera)).state; + + const futureKind = new WireWriter(); + futureKind.bytes.push(...base); + futureKind.uintField(1, 7); + futureKind.bytesField(60, [1, 2, 3]); + const kindResult = decodeOk(hashOf(futureKind.finish())); + assert.deepEqual(kindResult.state, expected); + assert.equal(kindResult.fallback, 'unknown-view-kind'); + + const missing = new WireWriter(); + missing.bytes.push(...base); + missing.uintField(1, 1); + assert.equal(decodeOk(hashOf(missing.finish())).fallback, 'missing-thing'); +}); + +test('an unknown or invalid entity falls back to the camera its fix implies', () => { + const fix = { lat: 35.6762, lon: 139.6503, alt: 900, observedAtMs: null }; + const offset = { range: 3000, heading: 90, pitch: -40 }; + const thingOf = (write) => { + const thing = new WireWriter(); + write(thing); + thing + .uintField(5, offset.range) + .sintField(6, offset.heading) + .sintField(7, offset.pitch) + .sintField(8, Math.round(fix.lat * 1e4)) + .sintField(9, Math.round(fix.lon * 1e4)) + .sintField(10, fix.alt); + const writer = new WireWriter(); + writer.bytes.push(1); + writer.bytesField(13, thing.finish()); + return decodeOk(hashOf(writer.finish())); + }; + const expected = normalizeShareViewState(followCameraFor(fix, offset)); + + // A newer app's entity kind: its id field is unknown here. + const future = thingOf((thing) => thing.textField(30, 'cam-77')); + assert.equal(future.fallback, 'unknown-entity-kind'); + assert.deepEqual(future.state, expected); + assert.deepEqual(future.unknownFields, [ + { scope: 'thing', field: 30, wireType: 2 }, + ]); + + const none = thingOf(() => {}); + assert.equal(none.fallback, 'missing-entity-kind'); + assert.deepEqual(none.state, expected); + + const badId = thingOf((thing) => thing.uintField(1, 0x1000000)); + assert.equal(badId.fallback, 'invalid-entity-id'); + assert.deepEqual(badId.state, expected); +}); + +test('enum names outside the code tables travel as text', () => { + const hash = encodeShareView({ + hudVariant: 'future-hud', + mapStack: 'future-map', + }); + const { state, unknownFields } = decodeOk(hash); + assert.equal(state.hudVariant, 'future-hud'); + assert.equal(state.mapStack, 'future-map'); + assert.equal(unknownFields, undefined); + const style = new WireWriter(); + style.bytes.push(1); + style.textField(14, 'thermal'); + assert.equal(decodeOk(hashOf(style.finish())).state.style, 'thermal'); + const unknownStyle = new WireWriter(); + unknownStyle.bytes.push(1); + unknownStyle.textField(14, 'sepia'); + const result = decodeOk(hashOf(unknownStyle.finish())); + assert.equal(result.state.style, 'normal'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:style', code: 'sepia' }, + ]); +}); + +/* ---------------- integrity ---------------- */ + +test('malformed input is rejected with a reason, never thrown', () => { + const good = payloadBytes( + encodeShareView({ + lat: 10, + lon: 20, + layerState: { enabledLayerIds: ['flights'] }, + }), + ); + const cases = { + 'not-a-string': [null, undefined, 42, {}], + 'too-long': ['A'.repeat(MAX_SHARE_VIEW_CHARS + 1)], + 'bad-base64': ['AQ==', 'A*Q', 'AQAAA', 's=AQc', '#AQc'], + empty: [''], + // A lone byte has no room for a check byte. + truncated: ['AQ'], + checksum: [ + encodeBase64Url([1, crc8([1]) ^ 1]), + encodeBase64Url([...good, (crc8(good) + 1) % 256]), + ], + 'unsupported-version': [hashOf([0]), hashOf([2, 8, 1])], + 'truncated ': [ + hashOf(good.slice(0, good.length - 1)), + hashOf([1, 0x10]), // field 2 varint with no value + hashOf([1, 0x52, 0x7f, 1]), // field 10 claims 127 bytes + hashOf([1, 0x52, 0xff, 0xff, 0xff, 0x7f]), // absurd length + ], + 'varint-overflow': [ + hashOf([1, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]), + ], + 'non-canonical-varint': [hashOf([1, 0x10, 0x82, 0x00])], + 'bad-wire-type': [hashOf([1, 0x13]), hashOf([1, 0x16])], + 'bad-field-number': [hashOf([1, 0x00])], + 'wire-type-mismatch': [ + hashOf([1, 0x12, 0x01, 0x00]), + hashOf([1, 0x50, 0x01]), + ], + 'duplicate-field': [hashOf([1, 0x10, 0x02, 0x10, 0x04])], + 'duplicate-entry': [hashOf([1, 0x62, 0x02, 0x31, 0x31])], + 'out-of-range': [ + hashOf([1, ...new WireWriter().sintField(2, 900001).finish()]), + ], + 'bad-text': [ + hashOf([1, ...new WireWriter().bytesField(14, [0xc3, 0xa9]).finish()]), + ], + }; + for (const [label, inputs] of Object.entries(cases)) { + const reason = label.trim(); + for (const input of inputs) { + const result = decodeShareView(input, { nowMs: NOW_MS }); + assert.deepEqual( + result, + { ok: false, reason }, + `${reason}: ${String(input).slice(0, 40)}`, + ); + } + } +}); + +test('every truncation and single-byte corruption of real payloads is handled', () => { + const random = rng(7); + for (let i = 0; i < 60; i += 1) { + const bytes = payloadBytes(encodeShareView(randomState(random))); + for (let cut = 1; cut < bytes.length; cut += 1) { + const result = decode(hashOf(bytes.slice(0, cut))); + assert.equal(typeof result.ok, 'boolean'); + } + for (let j = 0; j < 40; j += 1) { + const corrupt = Uint8Array.from(bytes); + corrupt[1 + Math.floor(random() * (corrupt.length - 1))] = Math.floor( + random() * 256, + ); + const result = decode(hashOf(corrupt)); + assert.equal(typeof result.ok, 'boolean'); + if (result.ok) + assert.deepEqual(normalizeShareViewState(result.state), result.state); + } + } +}); + +/* ---------------- sizes ---------------- */ + +test('size: a camera-only place view', () => { + const hash = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + }); + assert.equal(hash.length, 24); + const copied = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + sharedAtMs: NOW_MS, + }); + assert.equal(copied.length, 31); +}); + +test('size: following an aircraft', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + layerState.options.flights.models3d = true; + const view = (offset) => ({ + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset, + lastKnown: { + lat: 37.6213, + lon: -122.379, + alt: 3200, + observedAtMs: NOW_MS - 4000, + }, + }); + // The app's own follow camera looks north at −35°; only the range is new. + const followed = assertRoundTrip({ + view: view({ range: 5800, heading: 0, pitch: -35 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(followed.length, 44); + // Made by the share button: the fix is as of the link, so it has no age. + const fresh = view({ range: 5800, heading: 0, pitch: -35 }); + fresh.lastKnown.observedAtMs = NOW_MS; + assert.equal( + assertRoundTrip({ view: fresh, layerState, sharedAtMs: NOW_MS }).length, + 42, + ); + // In Noir with Noir's own look, only the style is added. + assert.equal( + assertRoundTrip({ + ...SHARE_VIEW_STYLE_DEFAULTS.noir, + style: 'noir', + view: fresh, + layerState, + sharedAtMs: NOW_MS, + }).length, + 44, + ); + // After orbiting the aircraft, heading and pitch are written too. + const orbited = assertRoundTrip({ + view: view({ range: 1500, heading: 30, pitch: -20 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(orbited.length, 50); + // No camera is stored: it is where following the fix puts it. + const { state } = decodeOk(orbited); + assert.deepEqual( + { + lat: state.lat, + lon: state.lon, + alt: state.alt, + heading: state.heading, + pitch: state.pitch, + }, + { lat: 37.6103, lon: -122.387, alt: 3713, heading: 30, pitch: -20 }, + ); +}); + +test('the follow offset converts to and from east/north/up metres', () => { + for (const offset of [ + { range: 5800, heading: 0, pitch: -35 }, + { range: 1500, heading: 30, pitch: -20 }, + { range: 750000, heading: 225, pitch: -60 }, + { range: 300, heading: 359, pitch: 10 }, + ]) + assert.deepEqual(followOffsetFromEnu(followOffsetToEnu(offset)), offset); + const { east, north, up } = followOffsetToEnu({ + range: 1000, + heading: 90, + pitch: -30, + }); + // Looking east and down: the camera is west of the entity and above it. + assert.ok(east < -800 && Math.abs(north) < 1e-9 && Math.abs(up - 500) < 1e-9); + assert.equal(followOffsetFromEnu({ east: 0, north: 0, up: 0 }), null); +}); + +test('the derived camera looks at the fix from the offset, even from orbit', () => { + const R = 6_371_000; + const ecef = ({ lat, lon, alt }) => { + const r = R + alt; + const c = Math.cos((lat * Math.PI) / 180); + return [ + r * c * Math.cos((lon * Math.PI) / 180), + r * c * Math.sin((lon * Math.PI) / 180), + r * Math.sin((lat * Math.PI) / 180), + ]; + }; + for (const [fix, offset] of [ + // A satellite followed from the app's own distance. + [ + { lat: 51.6, lon: -0.1, alt: 420_000 }, + { range: 750_000, heading: 225, pitch: -35 }, + ], + // Near a pole, and across the antimeridian. + [ + { lat: 89.9, lon: 179.95, alt: 10_000 }, + { range: 20_000, heading: 90, pitch: -20 }, + ], + [ + { lat: 37.6213, lon: -122.379, alt: 3200 }, + { range: 5800, heading: 0, pitch: -35 }, + ], + ]) { + const camera = followCameraFor(fix, offset); + const [a, b] = [ecef(camera), ecef(fix)]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + assert.ok(Math.abs(distance - offset.range) < 1e-3 * offset.range); + assert.ok(Math.abs(camera.lon) <= 180 && Math.abs(camera.lat) <= 90); + // Short ranges look along the offset itself. + if (offset.range < 10_000) { + assert.ok(Math.abs(camera.heading - offset.heading) < 0.1); + assert.ok(Math.abs(camera.pitch - offset.pitch) < 0.1); + } + } +}); + +test('a fix without its own time is as of the link', () => { + const view = { + kind: 'thing', + entity: { kind: 'satellite', id: '25544' }, + lastKnown: { lat: 1, lon: 2, alt: 420000 }, + }; + const state = normalizeShareViewState({ view, sharedAtMs: NOW_MS }); + assert.equal(state.view.lastKnown.observedAtMs, NOW_MS); + assertRoundTrip({ view, sharedAtMs: NOW_MS }); + // With no created-at time either, the fix has no time. + assert.equal( + decodeOk(encodeShareView({ view })).state.view.lastKnown.observedAtMs, + null, + ); +}); + +test('the check byte rejects every single-character typo', () => { + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const random = rng(0xc0de); + let typos = 0; + for (let i = 0; i < 40; i += 1) { + const code = encodeShareView(randomState(random)); + for (let at = 0; at < code.length; at += 1) { + for (const char of ALPHABET) { + if (char === code[at]) continue; + const typo = code.slice(0, at) + char + code.slice(at + 1); + const result = decode(typo); + assert.equal(result.ok, false, `${code} → ${typo}`); + typos += 1; + } + } + } + assert.ok(typos > 50_000); +}); + +/* ---------------- review regressions ---------------- */ + +test('unknown detection names fall back to their defaults and are reported', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + writer.textField(20, 'FUTURE').textField(22, 'GREEDY'); + const result = decodeOk(hashOf(writer.finish())); + assert.equal(result.state.detectionMode, 'DENSE'); + assert.equal(result.state.detectionAllocation, 'ELASTIC'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:detectionMode', code: 'FUTURE' }, + { scope: 'enum:detectionAllocation', code: 'GREEDY' }, + ]); +}); + +test('extreme and negative-zero numbers normalize to values the decoder returns', () => { + assert.ok(Number.isFinite(normalizeShareViewState({ lon: 1e305 }).lon)); + assertRoundTrip({ lon: 1e305, lat: -1e305 }); + for (const state of [ + { lat: -0.00001 }, + { lon: -0.00001 }, + { alt: -0.2 }, + { pitch: -0.2 }, + ]) { + assertRoundTrip(state); + for (const value of Object.values(normalizeShareViewState(state))) + assert.equal(Object.is(value, -0), false); + } +}); + +test('future panel values and style tokens are skipped, not applied', () => { + const panels = new WireWriter(); + panels.bytes.push(1); + panels.bytesField( + 12, + new WireWriter() + .varint(3 * 16 + 3 * 4) + .varint(0 * 16 + 2 * 4 + 3) + .finish(), + ); + const panelResult = decodeOk(hashOf(panels.finish())); + const ids = panelResult.state.panelState.specs.map((spec) => spec.id); + assert.equal(ids.includes('cctv-panel'), false); + assert.equal(ids.includes('control-panel'), false); + assert.equal(panelResult.unknownFields.length, 2); + + const style = new WireWriter(); + style.bytes.push(1); + style.uintField(14, 3); // thermal + style.bytesField( + 27, + new WireWriter().varint(36).varint(5).varint(37).varint(6).finish(), + ); + const styleResult = decodeOk(hashOf(style.finish())); + // Unknown tokens change nothing: the style keeps its own parameters. + assert.deepEqual( + styleResult.state.styleParams, + SHARE_VIEW_STYLE_DEFAULTS.thermal.styleParams, + ); + assert.equal(styleResult.unknownFields.length, 2); +}); + +test('a layer option filed under a mirror layer is ignored', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + const military = layerTokenNumber('m'); + writer.bytesField(10, [0, 0, 0b01000000]); // m = 22 + writer.bytesField( + 11, + new WireWriter() + .varint(military) + .varint(layerTokenNumber('m') * 2) // option `m` (models3dMode) as integer + .varint(0) + .finish(), + ); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.layerState.enabledLayerIds, ['military']); + assert.deepEqual(result.unknownFields, [ + { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, + ]); +}); diff --git a/src/share/wire.js b/src/share/wire.js new file mode 100644 index 000000000..f0b72f301 --- /dev/null +++ b/src/share/wire.js @@ -0,0 +1,259 @@ +/** + * Byte-level primitives for the compact share-view format: base64url text, + * protobuf-style varints, and a tag-length-value reader and writer. + * + * Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a + * {@link ShareWireError} with a stable `reason`, so the view codec can turn it + * into a rejected link instead of an exception at app start. + */ + +/** Wire types. Field numbers and their wire types are permanent. */ +export const WIRE_VARINT = 0; +export const WIRE_FIXED64 = 1; +export const WIRE_LEN = 2; +export const WIRE_FIXED32 = 5; + +/** Field numbers stay inside protobuf's range so tags fit in five bytes. */ +export const MAX_FIELD_NUMBER = 2 ** 29 - 1; +const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer. + +/** A malformed or out-of-bounds payload. `reason` is a stable code. */ +export class ShareWireError extends Error { + constructor(reason, detail = '') { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'ShareWireError'; + this.reason = reason; + } +} + +const BASE64URL = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const BASE64URL_INDEX = new Map( + [...BASE64URL].map((char, index) => [char, index]), +); + +/** Unpadded base64url text for `bytes`. */ +export function encodeBase64Url(bytes) { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const triple = (a << 16) | (b << 8) | c; + out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63]; + if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63]; + if (i + 2 < bytes.length) out += BASE64URL[triple & 63]; + } + return out; +} + +/** + * Bytes for unpadded base64url text. Rejects padding, foreign characters, an + * impossible length and non-zero trailing bits, so every payload has exactly + * one spelling. + */ +export function decodeBase64Url(text) { + if (typeof text !== 'string' || text.length === 0) + throw new ShareWireError('empty'); + if (text.length % 4 === 1) throw new ShareWireError('bad-base64'); + const bytes = new Uint8Array(Math.floor((text.length * 3) / 4)); + let buffer = 0; + let bits = 0; + let out = 0; + for (const char of text) { + const value = BASE64URL_INDEX.get(char); + if (value === undefined) throw new ShareWireError('bad-base64'); + buffer = ((buffer << 6) | value) & 0xffffff; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes[out++] = (buffer >> bits) & 0xff; + } + } + if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) + throw new ShareWireError('bad-base64', 'non-zero trailing bits'); + return bytes; +} + +/** + * CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection; + * no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to + * 8 bits, so any one mistyped base64url character (6 bits) is caught. + */ +export function crc8(bytes) { + let crc = 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) + crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff; + } + return crc; +} + +/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */ +export function zigzag(value) { + return value >= 0 ? value * 2 : -value * 2 - 1; +} + +/** Inverse of {@link zigzag}. */ +export function unzigzag(value) { + return value % 2 === 0 ? value / 2 : -(value + 1) / 2; +} + +/** Largest magnitude a zigzag field can carry without losing precision. */ +export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2); + +/** Append-only byte sink with protobuf-style field helpers. */ +export class WireWriter { + constructor() { + this.bytes = []; + } + + varint(value) { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError(`varint out of range: ${value}`); + let rest = value; + while (rest >= 0x80) { + this.bytes.push((rest % 0x80) | 0x80); + rest = Math.floor(rest / 0x80); + } + this.bytes.push(rest); + return this; + } + + tag(field, wireType) { + if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER) + throw new RangeError(`field number out of range: ${field}`); + return this.varint(field * 8 + wireType); + } + + uintField(field, value) { + return this.tag(field, WIRE_VARINT).varint(value); + } + + sintField(field, value) { + if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE) + throw new RangeError(`signed value out of range: ${value}`); + return this.tag(field, WIRE_VARINT).varint(zigzag(value)); + } + + bytesField(field, bytes) { + this.tag(field, WIRE_LEN).varint(bytes.length); + for (const byte of bytes) this.bytes.push(byte); + return this; + } + + /** ASCII-only text; every share-view string is drawn from ASCII grammars. */ + textField(field, text) { + return this.bytesField(field, asciiBytes(text)); + } + + finish() { + return Uint8Array.from(this.bytes); + } +} + +/** Printable-ASCII bytes for `text`; anything else is a programming error. */ +export function asciiBytes(text) { + const bytes = []; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + throw new RangeError('share-view text must be printable ASCII'); + bytes.push(code); + } + return bytes; +} + +/** Printable-ASCII text from bytes; anything else is a malformed payload. */ +export function asciiText(bytes) { + let out = ''; + for (const byte of bytes) { + if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text'); + out += String.fromCharCode(byte); + } + return out; +} + +/** Bounds-checked cursor over one message (or one packed blob). */ +export class WireReader { + constructor(bytes, start = 0, end = bytes.length) { + this.bytes = bytes; + this.pos = start; + this.end = end; + } + + get done() { + return this.pos >= this.end; + } + + /** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */ + varint() { + let value = 0; + let scale = 1; + for (let count = 0; count < MAX_VARINT_BYTES; count += 1) { + if (this.pos >= this.end) throw new ShareWireError('truncated'); + const byte = this.bytes[this.pos++]; + value += (byte & 0x7f) * scale; + if ((byte & 0x80) === 0) { + // A zero final byte after a continuation is an overlong spelling. + if (count > 0 && byte === 0) + throw new ShareWireError('non-canonical-varint'); + if (!Number.isSafeInteger(value)) + throw new ShareWireError('varint-overflow'); + return value; + } + scale *= 0x80; + } + throw new ShareWireError('varint-overflow'); + } + + svarint() { + return unzigzag(this.varint()); + } + + /** The next field's number and wire type, rejecting reserved wire types. */ + tag() { + const key = this.varint(); + const field = Math.floor(key / 8); + const wireType = key % 8; + if (field < 1 || field > MAX_FIELD_NUMBER) + throw new ShareWireError('bad-field-number'); + if ( + wireType !== WIRE_VARINT && + wireType !== WIRE_FIXED64 && + wireType !== WIRE_LEN && + wireType !== WIRE_FIXED32 + ) + throw new ShareWireError('bad-wire-type', String(wireType)); + return { field, wireType }; + } + + /** A length-delimited body as a sub-reader over the same bytes. */ + lengthDelimited() { + const length = this.varint(); + if (length > this.end - this.pos) + throw new ShareWireError('truncated', 'length exceeds payload'); + const reader = new WireReader(this.bytes, this.pos, this.pos + length); + this.pos += length; + return reader; + } + + /** Remaining bytes of this reader as a fresh array. */ + rest() { + const out = this.bytes.slice(this.pos, this.end); + this.pos = this.end; + return out; + } + + /** Skip one value of `wireType`, so newer fields cost older decoders nothing. */ + skip(wireType) { + if (wireType === WIRE_VARINT) this.varint(); + else if (wireType === WIRE_LEN) this.lengthDelimited(); + else { + const size = wireType === WIRE_FIXED64 ? 8 : 4; + if (size > this.end - this.pos) throw new ShareWireError('truncated'); + this.pos += size; + } + } +} diff --git a/src/share/wire.test.mjs b/src/share/wire.test.mjs new file mode 100644 index 000000000..5383293b3 --- /dev/null +++ b/src/share/wire.test.mjs @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ShareWireError, + WIRE_FIXED32, + WIRE_FIXED64, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +const reasonOf = (fn) => { + try { + fn(); + } catch (error) { + assert.ok(error instanceof ShareWireError, String(error)); + return error.reason; + } + assert.fail('expected a ShareWireError'); +}; + +test('base64url round-trips every length and byte value without padding', () => { + for (let length = 0; length < 70; length += 1) { + const bytes = Uint8Array.from( + { length }, + (_, i) => (i * 97 + length) & 0xff, + ); + const text = encodeBase64Url(bytes); + assert.match(text, /^[A-Za-z0-9_-]*$/); + if (length) assert.deepEqual(decodeBase64Url(text), bytes); + } + const all = Uint8Array.from({ length: 256 }, (_, i) => i); + assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all); + assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8'); +}); + +test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => { + for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) { + assert.ok( + ['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))), + text, + ); + } + // "AR" carries one byte (0x01) plus four trailing bits that must be zero. + assert.equal( + reasonOf(() => decodeBase64Url('AR')), + 'bad-base64', + ); + assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1)); +}); + +test('zigzag maps small magnitudes of either sign to small codes', () => { + assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]); + for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) { + assert.equal(unzigzag(zigzag(value)), value); + } +}); + +test('varints round-trip up to the largest safe integer', () => { + const values = [ + 0, + 1, + 127, + 128, + 300, + 2 ** 21, + 2 ** 31, + 2 ** 32 + 5, + Number.MAX_SAFE_INTEGER, + ]; + const writer = new WireWriter(); + for (const value of values) writer.varint(value); + const reader = new WireReader(writer.finish()); + for (const value of values) assert.equal(reader.varint(), value); + assert.ok(reader.done); + assert.deepEqual( + new WireWriter().varint(300).finish(), + Uint8Array.of(0xac, 0x02), + ); +}); + +test('the writer refuses values the format cannot carry', () => { + assert.throws(() => new WireWriter().varint(-1), RangeError); + assert.throws(() => new WireWriter().varint(1.5), RangeError); + assert.throws(() => new WireWriter().varint(2 ** 53), RangeError); + assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError); + assert.throws(() => new WireWriter().textField(1, 'é'), RangeError); +}); + +test('the reader rejects truncated, overlong and overflowing varints', () => { + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()), + 'truncated', + ); + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()), + 'non-canonical-varint', + ); + const tooLong = new Uint8Array(9).fill(0xff); + assert.equal( + reasonOf(() => new WireReader(tooLong).varint()), + 'varint-overflow', + ); + const beyondSafe = Uint8Array.of( + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0x7f, + ); + assert.equal( + reasonOf(() => new WireReader(beyondSafe).varint()), + 'varint-overflow', + ); +}); + +test('the reader bounds lengths and rejects group and reserved wire types', () => { + const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish(); + const reader = new WireReader(absurd); + reader.tag(); + assert.equal( + reasonOf(() => reader.lengthDelimited()), + 'truncated', + ); + for (const wireType of [3, 4, 6, 7]) { + const bytes = new WireWriter().varint(8 + wireType).finish(); + assert.equal( + reasonOf(() => new WireReader(bytes).tag()), + 'bad-wire-type', + ); + } + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()), + 'bad-field-number', + ); +}); + +test('skip steps over each wire type and stops at a truncated value', () => { + const writer = new WireWriter() + .uintField(1, 300) + .bytesField(2, [1, 2, 3]) + .tag(3, WIRE_FIXED32); + writer.bytes.push(1, 2, 3, 4); + writer.tag(4, WIRE_FIXED64); + writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + writer.uintField(5, 9); + const reader = new WireReader(writer.finish()); + for (let field = 1; field <= 4; field += 1) + reader.skip(reader.tag().wireType); + assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT }); + assert.equal(reader.varint(), 9); + const short = new WireWriter().tag(1, WIRE_FIXED64).finish(); + const shortReader = new WireReader(Uint8Array.of(...short, 1, 2)); + assert.equal( + reasonOf(() => shortReader.skip(shortReader.tag().wireType)), + 'truncated', + ); +}); + +test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => { + // The catalogued check value for the ASCII digits 1–9. + assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4); + assert.equal(crc8([]), 0); + const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33]; + const sum = crc8(bytes); + for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) { + for (let pattern = 1; pattern < 256; pattern += 1) { + // Flip a burst starting at `bit` (its first bit always set). + if (!(pattern & 1)) continue; + const flipped = [...bytes]; + for (let k = 0; k < 8; k += 1) { + if (!(pattern & (1 << k))) continue; + const at = bit + k; + flipped[at >> 3] ^= 0x80 >> (at & 7); + } + assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`); + } + } +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index e862aba66..91961e0b1 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -2,14 +2,18 @@ import { DisplayBindings } from './ui/displayBindings.js'; import { LocationNavigation } from './ui/locationNavigation.js'; import { VisualSettings } from './ui/visualSettings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; +import { parseSharePath } from './share/shareLinkPath.js'; +import { decodeShareView } from './share/viewCodec.js'; + +/** The decoded view of a copied `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; const uiSource = readShellSource(); @@ -101,6 +105,22 @@ test('Nepal locator token is valid in v2 share links', () => { assert.equal(parsed.layerStateInvalid, false); }); +test('scope feather above 100% survives share parsing and serialization', () => { + const manager = makeManager('#v=2&lat=10&lon=20&sc=0&scf=200'); + const parsed = manager.parseInitialHash(); + assert.equal(parsed.scopeEnabled, false); + assert.equal(parsed.scopeFeatherPct, 200); + assert.equal(makeManager('#lat=10&lon=20&scf=175').parseInitialHash().scopeFeatherPct, 175); + assert.equal(makeManager('#lat=10&lon=20&scf=999').parseInitialHash().scopeFeatherPct, 200); + manager.onToggleChange(false, true, { scopeEnabled: false, scopeFeatherPct: 200 }); + const params = manager._buildHashParams(); + assert.equal(params.get('sc'), '0'); + assert.equal(params.get('scf'), '200'); + manager.onToggleChange(false, true, { scopeFeatherPct: 999 }); + assert.equal(manager._buildHashParams().get('scf'), '200'); + clearTimeout(manager._debounceTimer); +}); + test('share-link serialization emits the current celestial state', () => { const manager = makeManager(); manager.onToggleChange(false, false, { celestialRingEnabled: false }); @@ -296,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async ( assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true); assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true); - assert.equal(new URL(copied[0]).hash.includes('at=2000'), true); - assert.equal(new URL(copied[1]).hash.includes('at=2002'), true); + assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000); + assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000); assert.equal(window.location.hash, liveHash); assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false); }); @@ -309,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress manager.parseInitialHash(); assert.equal(manager._initialRestorePending, true); assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true); - const params = new URL(copied).hash.slice(1); - assert.equal(new URLSearchParams(params).get('at'), '3000'); + assert.equal(sharedState(copied).sharedAtMs, 3_000_000); assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100'); }); @@ -500,8 +519,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate ['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'], ['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'], ['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'], - ['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'], - ['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'], + ['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'], + ['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'], ['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'], ['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'], ['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'], @@ -778,3 +797,20 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/); }); + + +test('share restore rejects inherited styles while continuing later fields', () => { + for (const style of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown']) { + const restored = makeManager(`#lat=10&lon=20&style=${style}&cr=1`).parseInitialHash(); + assert.equal(restored.style, 'normal'); + assert.equal(restored.celestialRing, true); + } +}); + +test('programmatic invalid styles are ignored before changing visual state', () => { + const target = { activeStyle: 'normal' }; + for (const value of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown', {}, null, () => {}]) { + assert.doesNotThrow(() => VisualSettings.prototype.setStyle.call(target, value)); + assert.equal(target.activeStyle, 'normal'); + } +}); diff --git a/src/sharelink.js b/src/sharelink.js index 6072fb1bd..4a0291a84 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -5,17 +5,36 @@ import { normalizeAllocationStrategy, } from './data/detectionPolicy.js'; import { clampScopeTerminusPct } from './scopeMask.js'; +import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { decodeLayerStateParams, encodeLayerStateParams, } from './data/layerState.js'; import { STYLE_URL_NAMES } from './view/index.js'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './share/viewCodec.js'; +import { + fromLegacyShareState, + toLegacyShareState, +} from './share/legacyShareState.js'; +import { + appRootUrl, + parseSharePath, + shareLinkUrl, +} from './share/shareLinkPath.js'; /** * Share Links — URL Hash State Management * - * Encodes camera position + style into the URL hash so links can be shared. - * Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * The address bar follows the live view in the long hash form: + * #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * + * A shared link is the short form, `/share/`, with the code from + * src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here. */ const DEBOUNCE_MS = 500; @@ -28,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; const SHARE_CREATED_AT_PARAM = 'at'; -const SHARE_PANEL_STATE_REGISTRY = Object.freeze([ - { id: 'control-panel', token: 'c', pinnable: true }, - { id: 'location-bar', token: 'l', pinnable: true }, - { id: 'data-panel', token: 'd', pinnable: false }, - { id: 'cctv-panel', token: 'v', pinnable: false }, - { id: 'recent-imagery-panel', token: 'i', pinnable: false }, - { id: 'street-level-panel', token: 't', pinnable: false }, - { id: 'radio-panel', token: 'r', pinnable: false }, - { id: 'scene-panel', token: 's', pinnable: false }, - { id: 'global-context-panel', token: 'g', pinnable: false }, - { id: 'pp-toggles', token: 'p', pinnable: false }, - { id: 'param-slider-panel', token: 'm', pinnable: false }, -]); - +// The panel and visual-preset parameter tables are the share format's own +// (src/share/viewCodec.js); the long form spells their entries by token. +const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS; const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze( new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])), ); @@ -50,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries( Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]), ); -const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ - retro: Object.freeze([ - { key: 'pixelation', token: 'p', min: 1, max: 10 }, - { key: 'distortion', token: 'd', min: 0, max: 1 }, - { key: 'instability', token: 'i', min: 0, max: 1 }, - ]), - surveillance: Object.freeze([ - { key: 'gain', token: 'g', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'scanlineStr', token: 's', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - ]), - thermal: Object.freeze([ - { key: 'sensitivity', token: 's', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'mode', token: 'm', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - { key: 'palette', token: 'a', min: 0, max: 1 }, - ]), - anime: Object.freeze([ - { key: 'saturation', token: 's', min: 0, max: 2 }, - { key: 'edgeThick', token: 'e', min: 0, max: 1 }, - ]), - noir: Object.freeze([ - { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, - { key: 'grainAmt', token: 'g', min: 0, max: 1 }, - { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, - ]), - snow: Object.freeze([ - { key: 'density', token: 'd', min: 0, max: 1 }, - { key: 'wind', token: 'w', min: 0, max: 1 }, - ]), -}); +const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS; export class ShareLinkManager { constructor( viewer, - { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, + { + onRestore, + isNavigationCurrent, + cancelOwnedNavigation, + initialShareCode = null, + } = {}, ) { this.viewer = viewer; + // A host may hand over the share code to open instead of the address. + this._initialShareCode = + typeof initialShareCode === 'string' ? initialShareCode : null; + // Why the share code this page opened with was refused, if it was. + this.rejectedShareCode = null; + this._followSubjectProvider = null; this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._debounceTimer = null; this._currentStyle = 'normal'; @@ -112,10 +99,7 @@ export class ShareLinkManager { this._detectionOutsideOpacityPct = 1; this._celestialRingEnabled = false; this._scopeEnabled = true; - // Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24, - // superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors - // SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value. - this._scopeFeatherPct = 11; + this._scopeFeatherPct = 100; // null = the altitude-adaptive terminus (the default). A number pins the // outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17) this._scopeTerminusPct = null; @@ -124,6 +108,13 @@ export class ShareLinkManager { this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; + // Each incoming share (start-up or a later hash navigation) gets a token; + // only the newest one may release URL-write suppression. + this._restoreToken = 0; + // The hash this page last read or wrote. A location change back to it is + // the app's own state, never an incoming share. + this._knownHash = null; + this._unwatchNavigation = null; this._restoreAuthority = { visual: 0, map: 0, @@ -150,10 +141,44 @@ export class ShareLinkManager { } /** - * Parse URL hash on page load. Returns parsed state or null. + * Parse the share this page opened with: the host's `initialShareCode`, a + * `/share/` address, or a long-form hash. Returns the parsed state, or + * null for none. A share code that cannot be opened is recorded in + * `rejectedShareCode` and restores nothing. */ parseInitialHash() { const hash = window.location.hash.slice(1); + this._knownHash = hash; + const code = + this._initialShareCode ?? + parseSharePath(window.location.pathname)?.code ?? + null; + this._initialShareCode = null; + const state = + code === null ? this._parseShareHash(hash) : this._parseShareCode(code); + if (!state) return null; + this._beginIncomingRestore(state); + return state; + } + + /** + * The restore state for a share code, in the long form's parsed shape plus + * `shareView` (what the view follows, with its offset and last-known fix), + * or null when the code is refused. + */ + _parseShareCode(code) { + const result = decodeShareView(code); + if (!result.ok) { + this.rejectedShareCode = result.reason; + return null; + } + const state = toLegacyShareState(result.state); + state.shareView = result.state.view; + return state; + } + + /** Parse one hash without side effects. Returns null unless it is a share. */ + _parseShareHash(hash) { if (!hash) return null; const params = new URLSearchParams(hash); @@ -175,7 +200,9 @@ export class ShareLinkManager { parseOr(params.get('dd'), 50), 50, ); - const style = URL_TO_STYLE[params.get('style')] || 'normal'; + const style = Object.hasOwn(URL_TO_STYLE, params.get('style')) + ? URL_TO_STYLE[params.get('style')] + : 'normal'; const decodedLayerState = decodeLayerStateParams(params); const state = { lat, @@ -225,10 +252,7 @@ export class ShareLinkManager { // (`_scopeFeatherPct` in the constructor tracks the default: that one // mirrors live state for the link this session generates, so it must match // the mask, not the archive.) - scopeFeatherPct: Math.max( - 0, - Math.min(100, Math.round(parseOr(params.get('scf'), 35))), - ), + scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)), // Absent (or non-numeric) `sce` = adaptive (null), the default behavior; // a value pins the terminus opacity percent, clamped into the SUPPORTED // 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in @@ -245,15 +269,55 @@ export class ShareLinkManager { panelState: decodePanelStateParams(params), sharedAtMs: decodeShareCreatedAtMs(params), }; + return state; + } + + /** Reserve restore lanes and hold URL writes for one incoming share. */ + _beginIncomingRestore(state) { state.restoreAuthority = { visual: this._restoreAuthority.visual, map: this._restoreAuthority.map, panels: new Map(this._restoreAuthority.panels), }; - - // Hold URL writes until the complete incoming state has been restored. + state.restoreToken = ++this._restoreToken; + // Hold URL writes until the complete incoming state has been restored. A + // debounced write of the previous view must not land over the new link. this._initialRestorePending = true; - return state; + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + + /** + * Restore a share link opened in this tab after start-up: a pasted address, + * a followed link, or Back/Forward. The app's own replaceState writes never + * fire these events, and a hash equal to the last one read or written is + * ignored anyway, so only a genuinely different incoming share restores. + */ + watchNavigation(onNavigate) { + this._unwatchNavigation?.(); + if (this._destroyed || typeof onNavigate !== 'function') return; + const target = globalThis.window; + if (typeof target?.addEventListener !== 'function') return; + const handler = () => this._handleLocationChange(onNavigate); + target.addEventListener('hashchange', handler); + target.addEventListener('popstate', handler); + this._unwatchNavigation = () => { + target.removeEventListener('hashchange', handler); + target.removeEventListener('popstate', handler); + this._unwatchNavigation = null; + }; + } + + _handleLocationChange(onNavigate) { + if (this._destroyed) return; + const hash = window.location.hash.slice(1); + // Back/Forward fires both popstate and hashchange for one entry. + if (hash === this._knownHash) return; + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return; + this._beginIncomingRestore(state); + onNavigate(state); } /** @@ -377,9 +441,13 @@ export class ShareLinkManager { }; } - /** Release initial hash suppression only after every restore owner settles. */ - completeInitialRestore() { + /** + * Release hash suppression only after every restore owner settles. A token + * from a superseded share restore leaves the newer one holding the URL. + */ + completeInitialRestore(restoreToken = this._restoreToken) { if (!this._initialRestorePending) return; + if (restoreToken !== this._restoreToken) return; this._initialRestorePending = false; this._scheduleUpdate(); } @@ -402,6 +470,16 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the source of the entity the camera follows, used for share links: + * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share + * format's terms (src/share/follow.js), with the fix as of `nowMs`. + */ + setFollowSubjectProvider(provider) { + this._followSubjectProvider = + typeof provider === 'function' ? provider : null; + } + /** Install the finalized panel-state source used by URL generation. */ setPanelStateProvider(provider) { this._panelStateProvider = typeof provider === 'function' ? provider : null; @@ -492,10 +570,7 @@ export class ShareLinkManager { if (typeof extras.scopeEnabled === 'boolean') this._scopeEnabled = extras.scopeEnabled; if (typeof extras.scopeFeatherPct === 'number') { - this._scopeFeatherPct = Math.max( - 0, - Math.min(100, Math.round(extras.scopeFeatherPct)), - ); + this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct); } if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null; else if (typeof extras.scopeTerminusPct === 'number') { @@ -505,17 +580,41 @@ export class ShareLinkManager { this._scheduleUpdate(); } + /** + * The share link for the current state, `/share/`, stamped with + * its creation time, or null before the camera has a position. While the + * camera follows an entity, the link follows it too. + */ + buildLink({ nowMs = Date.now() } = {}) { + const params = this._buildHashParams(); + if (!params) return null; + // The long form is the one description of the live state; the code is + // that state in the share format. + const state = fromLegacyShareState(this._parseShareHash(params.toString())); + state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + let subject = null; + try { + subject = this._followSubjectProvider?.({ nowMs }) ?? null; + } catch { + subject = null; + } + if (subject) { + state.view = { + kind: 'thing', + entity: { kind: subject.kind, id: subject.id }, + offset: subject.offset ?? null, + lastKnown: subject.lastKnown ?? null, + }; + } + return shareLinkUrl(encodeShareView(state), window.location.href); + } + /** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */ async copyLink({ nowMs = Date.now() } = {}) { - const params = this._buildHashParams(); - if (!params) return false; - params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000))); - const copiedUrl = new URL(window.location.href); - // ?lang is the sender's language override; the recipient keeps their own. - copiedUrl.searchParams.delete('lang'); - copiedUrl.hash = params.toString(); try { - await navigator.clipboard.writeText(copiedUrl.href); + const href = this.buildLink({ nowMs }); + if (!href) return false; + await navigator.clipboard.writeText(href); return true; } catch { return false; @@ -533,12 +632,15 @@ export class ShareLinkManager { const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the - // app may load under. - history.replaceState( - null, - '', - new URL(`#${params.toString()}`, window.location.href).href, - ); + // app may load under. A `/share/` address gives way to the app's + // own address, so a reload follows the live view; replacing it adds no + // history entry. + const url = parseSharePath(new URL(window.location.href).pathname) + ? appRootUrl(window.location.href) + : new URL(window.location.href); + url.hash = params.toString(); + this._knownHash = url.hash.slice(1); + history.replaceState(null, '', url.href); } /** Build a deterministic snapshot without mutating history. */ @@ -597,8 +699,8 @@ export class ShareLinkManager { this._styleParamStateProvider?.(this._currentStyle), ); - // Copy-time metadata is intentionally absent here. `copyLink()` adds a - // fresh timestamp to its ephemeral URL without aging the live address. + // Creation-time metadata is intentionally absent here. `buildLink()` + // stamps its own link without aging the live address. params.delete(SHARE_CREATED_AT_PARAM); return params; } @@ -621,9 +723,11 @@ export class ShareLinkManager { this._debounceTimer = null; this._removeCameraChanged?.(); this._removeCameraChanged = null; + this._unwatchNavigation?.(); this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; + this._followSubjectProvider = null; this._onRestore = null; } } diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs new file mode 100644 index 000000000..e4e70b629 --- /dev/null +++ b/src/sharelink.navigation.test.mjs @@ -0,0 +1,425 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from './sharelink.js'; +import { ShareRestoration } from './ui/shareRestoration.js'; +import { DataLayerManager } from './data/manager.js'; +import { + LAYER_STATE_REGISTRY, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + decodeLayerStateParams, +} from './data/layerState.js'; +import { LayerStateCoordinator } from './data/layerStateCoordinator.js'; + +const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e'; +const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f'; + +/** + * A window whose address behaves like a tab's: `navigate` is a hash-only + * navigation (new history entry plus hashchange), `back`/`forward` traverse + * entries (popstate plus hashchange), and `history.replaceState` rewrites the + * current entry without firing anything. + */ +function installTab(t, initialHash = '') { + const prior = { + window: globalThis.window, + history: globalThis.history, + }; + const target = new EventTarget(); + const location = new URL( + `http://localhost/${initialHash ? `#${initialHash}` : ''}`, + ); + const entries = [location.hash]; + let index = 0; + const listeners = { hashchange: 0, popstate: 0 }; + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + target.addEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] += 1; + add(type, handler, options); + }; + target.removeEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] -= 1; + remove(type, handler, options); + }; + target.location = location; + globalThis.window = target; + globalThis.history = { + replaceState(_state, _title, next) { + location.hash = new URL(next, location.href).hash; + entries[index] = location.hash; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + const go = (nextIndex) => { + index = nextIndex; + location.hash = entries[index]; + target.dispatchEvent(new Event('popstate')); + target.dispatchEvent(new Event('hashchange')); + }; + return { + listeners, + navigate(hash) { + entries.splice(index + 1); + entries.push(`#${hash}`); + index = entries.length - 1; + location.hash = hash; + target.dispatchEvent(new Event('hashchange')); + }, + back: () => go(index - 1), + forward: () => go(index + 1), + }; +} + +/** Queue timers instead of running them, so delayed work runs only on demand. */ +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return timers; +} + +function fakeViewer() { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; +} + +test('a hash navigation to a different share restores it and holds URL writes', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + const initial = manager.parseInitialHash(); + manager.completeInitialRestore(initial.restoreToken); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + assert.equal(navigated[0].lat, 48.8584); + assert.equal(navigated[0].style, 'normal'); + assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']); + assert.equal(manager._initialRestorePending, true); + // Nothing from the previous view may overwrite the incoming link. + manager._updateHash(); + assert.equal(window.location.hash, `#${SHARE_B}`); + manager.completeInitialRestore(navigated[0].restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test("the app's own hash writes never trigger a restore", (t) => { + installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + manager._updateHash(); + assert.notEqual(window.location.hash, `#${SHARE_A}`); + // replaceState fires nothing, but a stray event for our own hash is ignored. + window.dispatchEvent(new Event('hashchange')); + window.dispatchEvent(new Event('popstate')); + assert.equal(restores, 0); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + // The app rewrites entry 0 with its own state; Back must return to that. + manager._updateHash(); + const ownHash = window.location.hash.slice(1); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate('not-a-share'); + assert.equal(navigated.length, 0); + assert.equal(manager._initialRestorePending, false); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + manager.completeInitialRestore(navigated[0].restoreToken); + + tab.back(); // not-a-share + tab.back(); // the app's own rewritten first entry + assert.equal(navigated.length, 2, 'popstate and hashchange restore once'); + assert.equal(window.location.hash.slice(1), ownHash); + assert.equal(navigated[1].lat, 0); + manager.completeInitialRestore(navigated[1].restoreToken); + + tab.forward(); + tab.forward(); + assert.equal(navigated.length, 3); + assert.equal(navigated[2].lat, 48.8584); + manager.destroy(); +}); + +test('a newer navigation supersedes an older restore and its pending write', (t) => { + const tab = installTab(t, SHARE_A); + const timers = installManualTimers(t); + + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + assert.equal(timers.size, 1, 'releasing suppression schedules a write'); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal( + timers.size, + 0, + 'the pending write of the old view is cancelled', + ); + tab.navigate(SHARE_A); + const [older, newer] = navigated; + assert.ok(newer.restoreToken > older.restoreToken); + manager.completeInitialRestore(older.restoreToken); + assert.equal( + manager._initialRestorePending, + true, + 'a superseded restore cannot release writes', + ); + manager.completeInitialRestore(newer.restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('destroy removes the navigation listeners', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.parseInitialHash(); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 }); + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual( + tab.listeners, + { hashchange: 1, popstate: 1 }, + 'rewatching replaces', + ); + manager.destroy(); + assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 }); + tab.navigate(SHARE_B); + assert.equal(restores, 0); +}); + +function deferred() { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */ +function restorationHarness(t, initialHash, navigationOverrides = {}) { + const tab = installTab(t, initialHash); + const timers = installManualTimers(t); + + const restores = []; + let navigationGeneration = 0; + const manager = new ShareLinkManager(fakeViewer(), { + onRestore: (state) => { + const gate = deferred(); + restores.push({ state, gate }); + return gate.promise; + }, + isNavigationCurrent: () => false, + }); + const layerCalls = []; + const handoffs = []; + const owner = new ShareRestoration({ + viewer: { canvas: new EventTarget() }, + navigation: { + _beginDeferredNavigation: () => ++navigationGeneration, + _reassertNavigationHandoff: (generation, options) => { + handoffs.push({ generation, options }); + return true; + }, + _stampNavigation() {}, + ...navigationOverrides, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + }); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState(options) { + layerCalls.push(options); + return Promise.resolve([]); + }, + getDurableState: () => null, + cancelPendingShareTracking: () => false, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { tab, timers, manager, owner, restores, layerCalls, handoffs }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => { + const { tab, timers, manager, owner, restores, layerCalls } = + restorationHarness(t, SHARE_A); + assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight'); + + tab.navigate(SHARE_B); + assert.equal(timers.size, 0, 'the delayed start-up restore never runs'); + assert.equal((await owner.initialRestorePromise).status, 'superseded'); + assert.equal(restores.length, 1); + assert.equal(restores[0].state.style, 'normal'); + assert.equal(restores[0].state.mapStack, 'photoreal'); + assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']); + + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); +}); + +test('a navigated share releases the old Follow passively, never as a saved preference', (t) => { + const { tab, handoffs } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + assert.equal(handoffs.length, 1); + assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' }); +}); + +test('a navigated share that fails to start never freezes the address bar', async (t) => { + const settled = []; + let calls = 0; + const { tab, manager } = restorationHarness(t, SHARE_A, { + // Start-up reserves the camera; the navigated share then fails to. + _beginDeferredNavigation: () => { + calls += 1; + if (calls > 1) throw new Error('navigation unavailable'); + return calls; + }, + }); + window.addEventListener('gev:share-navigation-restore-settled', (event) => + settled.push(event.detail), + ); + tab.navigate(SHARE_B); + assert.equal(manager._initialRestorePending, false); + assert.equal(settled[0]?.status, 'failed'); +}); + +test('a second navigation during a restore supersedes it end to end', async (t) => { + const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + tab.navigate(SHARE_A); + assert.equal(restores.length, 2); + assert.equal(layerCalls.length, 2); + assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [ + 'earthquakes', + ]); + + // The newer restore finishes first; the older one settling late must not + // release or reopen anything. + restores[1].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); + manager._initialRestorePending = true; // observe any late release + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, true); +}); + +test('the layer coordinator replaces in-flight share layers with the navigated share', async () => { + const enableGates = new Map(); + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) { + manager.register({ + id, + name: id, + icon: '', + source: 'test', + async init() { + return true; + }, + async enable() { + const gate = enableGates.get(id); + if (gate) await gate.promise; + return true; + }, + async update() { + return true; + }, + async disable() { + return true; + }, + }); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const quakeGate = deferred(); + enableGates.set('earthquakes', quakeGate); + const writes = []; + const storage = { + getItem: () => null, + setItem: (...args) => writes.push(args), + }; + const sink = { + provider: null, + setLayerStateProvider(provider) { + this.provider = provider; + }, + onLayerStateChange() {}, + }; + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + const first = coordinator.start({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)), + allowLocalState: false, + }); + const second = coordinator.applyShareLayerState({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)), + }); + quakeGate.resolve(); + await Promise.all([first, second]); + const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true; + assert.equal(enabled('earthquakes'), false); + assert.equal(enabled('flights'), true); + assert.deepEqual(sink.provider().enabledLayerIds, ['flights']); + assert.equal(coordinator.source, 'share'); + assert.deepEqual( + writes, + [], + 'a passive share restore never writes local preferences', + ); + + // A share without a layer payload restores the defaults, as a reload does. + await coordinator.applyShareLayerState({ shareLayerState: null }); + assert.equal(coordinator.source, 'legacy-share'); + assert.deepEqual( + sink.provider().enabledLayerIds, + createDefaultLayerState().enabledLayerIds, + ); + assert.equal( + enabled('flights'), + createDefaultLayerState().enabledLayerIds.includes('flights'), + ); + coordinator.destroy(); +}); diff --git a/src/sky/starrySky.js b/src/sky/starrySky.js new file mode 100644 index 000000000..02e5c7ee3 --- /dev/null +++ b/src/sky/starrySky.js @@ -0,0 +1,451 @@ +import * as Cesium from 'cesium'; +import { loadBundledJson } from '../data/bundledJson.js'; +import { governorRequestRender } from '../renderGovernor.js'; +import { buildStarVertices, STAR_VERTEX_FLOATS } from './stars.js'; + +/** + * Space background in the `Scene#skyBox` slot: catalogue stars as point + * sprites over a soft Milky Way cube map, both turned by one ICRF→fixed + * rotation at the scene clock (TEME until Cesium's tables load). Scene runs + * the command from `update()` first in the environment pass, so the sky + * stays behind everything without depth. Revalidate on engine upgrades. + */ +export const STARRY_SKY_CESIUM_VERSION = '1.138.0'; + +/** Brightness of the Milky Way glow; the stars carry the sky. */ +export const MILKY_WAY_INTENSITY = 0.4; +/** Clock change, in seconds, below which the sky rotation is reused. */ +export const SKY_ROTATION_EPSILON_SECONDS = 1; + +const STAR_PACK_URL = new URL( + '../data/local_data/sky/bright_stars.json', + import.meta.url, +); +const MILKY_WAY_FACE_URLS = Object.freeze({ + positiveX: new URL( + '../data/local_data/sky/milkyway_px.webp', + import.meta.url, + ), + negativeX: new URL( + '../data/local_data/sky/milkyway_nx.webp', + import.meta.url, + ), + positiveY: new URL( + '../data/local_data/sky/milkyway_py.webp', + import.meta.url, + ), + negativeY: new URL( + '../data/local_data/sky/milkyway_ny.webp', + import.meta.url, + ), + positiveZ: new URL( + '../data/local_data/sky/milkyway_pz.webp', + import.meta.url, + ), + negativeZ: new URL( + '../data/local_data/sky/milkyway_nz.webp', + import.meta.url, + ), +}); + +// Directions, not points: w = 0 drops the camera translation, and z just +// under w keeps every fragment at the far plane, inside any frustum. +const SKY_DIRECTION_GLSL = ` +uniform mat3 u_icrfToFixed; +vec4 skyClipPosition(vec3 icrfDirection) +{ + vec3 eye = czm_viewRotation * (u_icrfToFixed * icrfDirection); + vec4 clip = czm_projection * vec4(eye, 0.0); + clip.z = clip.w * 0.99999; + return clip; +} +`; + +const MILKY_WAY_VS = `${SKY_DIRECTION_GLSL} +in vec3 position; +out vec3 v_direction; +void main() +{ + gl_Position = skyClipPosition(position); + v_direction = position; +} +`; + +const MILKY_WAY_FS = ` +uniform samplerCube u_milkyWay; +uniform float u_intensity; +in vec3 v_direction; +void main() +{ + vec3 color = czm_textureCube(u_milkyWay, normalize(v_direction)).rgb * u_intensity; + // Interleaved-gradient dither: the dim glow spans only a few 8-bit steps. + float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + color = max(color + (noise - 0.5) / 255.0, 0.0); + out_FragColor = vec4(czm_gammaCorrect(color), 1.0); +} +`; + +const STAR_VS = `${SKY_DIRECTION_GLSL} +in vec3 position; +in float sigma; +in vec4 color; +out vec4 v_color; +out float v_sigma; +out float v_radius; +void main() +{ + gl_Position = skyClipPosition(position); + // σ is in CSS pixels; the sprite holds ±3σ plus a pixel of margin. + v_sigma = sigma * czm_pixelRatio; + v_radius = ceil(3.0 * v_sigma) + 1.0; + gl_PointSize = 2.0 * v_radius; + v_color = color; +} +`; + +const STAR_FS = ` +in vec4 v_color; +in float v_sigma; +in float v_radius; +void main() +{ + vec2 offset = (gl_PointCoord - 0.5) * (2.0 * v_radius); + float alpha = v_color.a * + exp(-0.5 * dot(offset, offset) / (v_sigma * v_sigma)); + if (alpha < 1.0 / 512.0) discard; + out_FragColor = vec4(czm_gammaCorrect(v_color.rgb) * alpha, alpha); +} +`; + +/** Pick the ICRF→fixed rotation for `time`, or TEME while tables load. */ +export function computeSkyRotation(time, result) { + const { Transforms } = Cesium; + const icrf = Transforms.computeIcrfToFixedMatrix(time, result); + if (icrf) return { matrix: icrf, precise: true }; + return { + matrix: Transforms.computeTemeToPseudoFixedMatrix(time, result), + precise: false, + }; +} + +/** Whether a cached rotation at `cachedTime` still serves `time`. */ +export function isSkyRotationCurrent(cachedTime, time) { + return ( + cachedTime !== undefined && + Math.abs(Cesium.JulianDate.secondsDifference(time, cachedTime)) < + SKY_ROTATION_EPSILON_SECONDS + ); +} + +export class StarrySky { + /** + * @param {object} [options] + * @param {() => void} [options.requestRender] renders one frame when an + * asset or the precise rotation arrives; the idle governor by default. + */ + constructor({ + requestRender = () => governorRequestRender('sky-assets'), + } = {}) { + /** Matches `SkyBox#show`. */ + this.show = true; + this.milkyWayIntensity = MILKY_WAY_INTENSITY; + /** 0..1 multiplier a style sets to hide the glow (see visualEffects.js). */ + this.milkyWayVisibility = 1; + this._requestRender = requestRender; + this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY); + this._rotationTime = undefined; + this._rotationPrecise = false; + this._icrfRequest = undefined; + this._icrfUnavailable = false; + this._icrfMissing = undefined; + this._loading = false; + this._starData = undefined; + this._faceImages = undefined; + // Scene reads `_cubeMap` for model lighting. Keep it dark as on main; + // Cesium's fallback is white, and the ICRF glow is only a background. + this._cubeMap = undefined; + this._milkyWayCubeMap = undefined; + this._milkyWayCommand = undefined; + this._milkyWayAttributes = undefined; + this._starCommand = undefined; + this._useHdr = undefined; + this._destroyed = false; + const uniforms = { + u_icrfToFixed: () => this._rotation, + }; + this._milkyWayUniforms = { + ...uniforms, + u_milkyWay: () => this._milkyWayCubeMap, + u_intensity: () => this.milkyWayIntensity * this.milkyWayVisibility, + }; + this._starUniforms = uniforms; + const commands = []; + this._commands = commands; + // The environment pass reads `derivedCommands` (log depth, HDR, picking) + // before calling `execute`; none apply to a far-plane background. + this._compositeCommand = { + derivedCommands: {}, + execute(context, passState) { + for (const command of commands) command.execute(context, passState); + }, + }; + } + + /** Start loading assets; failures leave a plain black sky. */ + _load() { + if (this._loading) return; + this._loading = true; + loadBundledJson(STAR_PACK_URL) + .then((pack) => { + if (this._destroyed) return; + this._starData = buildStarVertices(pack); + this._requestRender(); + }) + .catch((error) => + console.warn('[sky] star catalogue unavailable', error), + ); + const faces = Object.entries(MILKY_WAY_FACE_URLS); + Promise.all( + faces.map(([, url]) => Cesium.Resource.fetchImage({ url: url.href })), + ) + .then((images) => { + if (this._destroyed) return; + this._faceImages = Object.fromEntries( + faces.map(([face], index) => [face, images[index]]), + ); + this._requestRender(); + }) + .catch((error) => console.warn('[sky] Milky Way unavailable', error)); + } + + _updateRotation(time) { + // An approximate rotation is final only while nothing better can come. + const settled = + this._rotationPrecise || this._icrfRequest || !this._canPreloadIcrf(time); + if (settled && isSkyRotationCurrent(this._rotationTime, time)) return; + const { precise } = computeSkyRotation(time, this._rotation); + this._rotationPrecise = precise; + this._rotationTime = Cesium.JulianDate.clone(time, this._rotationTime); + if (!precise) this._preloadIcrf(time); + } + + /** Whether loading Cesium's IAU 2006 tables could still help at `time`. */ + _canPreloadIcrf(time) { + return ( + !this._icrfUnavailable && + !( + this._icrfMissing && + Cesium.TimeInterval.contains(this._icrfMissing, time) + ) + ); + } + + /** Fetch Cesium's IAU 2006 tables around `time` once per span. */ + _preloadIcrf(time) { + if (this._icrfRequest || !this._canPreloadIcrf(time)) return; + const interval = new Cesium.TimeInterval({ + start: Cesium.JulianDate.addDays(time, -1, new Cesium.JulianDate()), + stop: Cesium.JulianDate.addDays(time, 1, new Cesium.JulianDate()), + }); + this._icrfRequest = Promise.resolve( + Cesium.Transforms.preloadIcrfFixed(interval), + ) + .then( + () => { + // The tables cover roughly 1975–2050; outside them the preload + // resolves with nothing, and TEME stays for this span. + if (!Cesium.Transforms.computeIcrfToFixedMatrix(time)) { + this._icrfMissing = interval; + return; + } + this._rotationTime = undefined; + if (!this._destroyed) this._requestRender(); + }, + (error) => { + // TEME is within a fraction of a degree; do not retry every frame. + this._icrfUnavailable = true; + console.warn('[sky] ICRF tables unavailable, using TEME', error); + }, + ) + .finally(() => { + this._icrfRequest = undefined; + }); + } + + _createMilkyWayCommand(context) { + const cubeMap = new Cesium.CubeMap({ + context, + source: this._faceImages, + flipY: false, + }); + cubeMap.generateMipmap(); + cubeMap.sampler = new Cesium.Sampler({ + minificationFilter: Cesium.TextureMinificationFilter.LINEAR_MIPMAP_LINEAR, + }); + this._milkyWayCubeMap = cubeMap; + this._faceImages = undefined; + const geometry = Cesium.BoxGeometry.createGeometry( + Cesium.BoxGeometry.fromDimensions({ + dimensions: new Cesium.Cartesian3(2, 2, 2), + vertexFormat: Cesium.VertexFormat.POSITION_ONLY, + }), + ); + const attributeLocations = + Cesium.GeometryPipeline.createAttributeLocations(geometry); + this._milkyWayCommand = new Cesium.DrawCommand({ + owner: this, + vertexArray: Cesium.VertexArray.fromGeometry({ + context, + geometry, + attributeLocations, + bufferUsage: Cesium.BufferUsage.STATIC_DRAW, + }), + renderState: Cesium.RenderState.fromCache({}), + uniformMap: this._milkyWayUniforms, + }); + this._milkyWayAttributes = attributeLocations; + } + + _createStarCommand(context) { + const { count, vertices } = this._starData; + this._starData = undefined; + if (count === 0) return; + const vertexBuffer = Cesium.Buffer.createVertexBuffer({ + context, + typedArray: vertices, + usage: Cesium.BufferUsage.STATIC_DRAW, + }); + const stride = STAR_VERTEX_FLOATS * Float32Array.BYTES_PER_ELEMENT; + const attribute = (index, components, offsetFloats) => ({ + index, + vertexBuffer, + componentsPerAttribute: components, + componentDatatype: Cesium.ComponentDatatype.FLOAT, + offsetInBytes: offsetFloats * Float32Array.BYTES_PER_ELEMENT, + strideInBytes: stride, + }); + this._starCommand = new Cesium.DrawCommand({ + owner: this, + primitiveType: Cesium.PrimitiveType.POINTS, + count, + vertexArray: new Cesium.VertexArray({ + context, + attributes: [ + attribute(0, 3, 0), + attribute(1, 1, 3), + attribute(2, 4, 4), + ], + }), + // Premultiplied additive: starlight adds to the Milky Way glow and + // leaves the framebuffer alpha alone. + renderState: Cesium.RenderState.fromCache({ + blending: { + enabled: true, + equationRgb: Cesium.BlendEquation.ADD, + equationAlpha: Cesium.BlendEquation.ADD, + functionSourceRgb: Cesium.BlendFunction.ONE, + functionDestinationRgb: Cesium.BlendFunction.ONE, + functionSourceAlpha: Cesium.BlendFunction.ZERO, + functionDestinationAlpha: Cesium.BlendFunction.ONE, + }, + }), + uniformMap: this._starUniforms, + }); + } + + _updateShaders(context, useHdr) { + if (this._useHdr === useHdr) return; + this._useHdr = useHdr; + const defines = useHdr ? ['HDR'] : []; + for (const [command, vs, fs, attributeLocations] of [ + [ + this._milkyWayCommand, + MILKY_WAY_VS, + MILKY_WAY_FS, + this._milkyWayAttributes, + ], + [ + this._starCommand, + STAR_VS, + STAR_FS, + { position: 0, sigma: 1, color: 2 }, + ], + ]) { + if (!command) continue; + command.shaderProgram?.destroy(); + command.shaderProgram = Cesium.ShaderProgram.fromCache({ + context, + vertexShaderSource: vs, + fragmentShaderSource: new Cesium.ShaderSource({ + defines, + sources: [fs], + }), + attributeLocations, + }); + } + } + + /** `Scene#skyBox` contract: the command to draw this frame, if any. */ + update(frameState, useHdr) { + if (!this.show) return undefined; + const { mode, passes, context } = frameState; + if (mode !== Cesium.SceneMode.SCENE3D) return undefined; + if (!passes.render) return undefined; + if (!this._cubeMap) { + const face = { + width: 1, + height: 1, + arrayBufferView: new Uint8Array([0, 0, 0, 255]), + }; + this._cubeMap = new Cesium.CubeMap({ + context, + source: { + positiveX: face, + negativeX: face, + positiveY: face, + negativeY: face, + positiveZ: face, + negativeZ: face, + }, + flipY: false, + }); + } + this._load(); + let rebuilt = false; + if (this._faceImages && !this._milkyWayCommand) { + this._createMilkyWayCommand(context); + rebuilt = true; + } + if (this._starData && !this._starCommand) { + this._createStarCommand(context); + rebuilt = true; + } + if (rebuilt) this._useHdr = undefined; + this._updateShaders(context, Boolean(useHdr)); + this._commands.length = 0; + if (this._milkyWayCommand) this._commands.push(this._milkyWayCommand); + if (this._starCommand) this._commands.push(this._starCommand); + if (this._commands.length === 0) return undefined; + this._updateRotation(frameState.time); + return this._compositeCommand; + } + + isDestroyed() { + return this._destroyed; + } + + destroy() { + for (const command of [this._milkyWayCommand, this._starCommand]) { + if (!command) continue; + command.vertexArray?.destroy(); + command.shaderProgram?.destroy(); + } + this._milkyWayCubeMap = this._milkyWayCubeMap?.destroy(); + this._cubeMap = this._cubeMap?.destroy(); + this._milkyWayCommand = undefined; + this._starCommand = undefined; + this._commands.length = 0; + this._destroyed = true; + return undefined; + } +} diff --git a/src/sky/starrySky.test.mjs b/src/sky/starrySky.test.mjs new file mode 100644 index 000000000..4febeb255 --- /dev/null +++ b/src/sky/starrySky.test.mjs @@ -0,0 +1,200 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + MILKY_WAY_INTENSITY, + STARRY_SKY_CESIUM_VERSION, + SKY_ROTATION_EPSILON_SECONDS, + StarrySky, + computeSkyRotation, + isSkyRotationCurrent, +} from './starrySky.js'; + +const close = (actual, expected, epsilon = 1e-9) => + assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} vs ${expected}`); + +test('the sky-box adapter was validated against this engine version', () => { + assert.equal( + Cesium.VERSION, + STARRY_SKY_CESIUM_VERSION, + 'engine upgrades require revalidating the Scene#skyBox contract', + ); +}); + +test('the ICRF rotation is preferred and TEME fills in while tables load', () => { + const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z'); + const icrf = Cesium.Matrix3.fromRotationZ(0.3); + const teme = Cesium.Matrix3.fromRotationZ(0.2); + const { Transforms } = Cesium; + const original = { ...Transforms }; + try { + Transforms.computeIcrfToFixedMatrix = (_, result) => + Cesium.Matrix3.clone(icrf, result); + Transforms.computeTemeToPseudoFixedMatrix = () => + assert.fail('TEME not needed'); + const precise = computeSkyRotation(time, new Cesium.Matrix3()); + assert.equal(precise.precise, true); + assert.ok(Cesium.Matrix3.equals(precise.matrix, icrf)); + Transforms.computeIcrfToFixedMatrix = () => undefined; + Transforms.computeTemeToPseudoFixedMatrix = (_, result) => + Cesium.Matrix3.clone(teme, result); + const approximate = computeSkyRotation(time, new Cesium.Matrix3()); + assert.equal(approximate.precise, false); + assert.ok(Cesium.Matrix3.equals(approximate.matrix, teme)); + } finally { + Object.assign(Transforms, original); + } +}); + +test('the TEME fallback keeps the celestial pole on the Earth axis', () => { + // TEME only spins about Z; ICRF differs from it by precession (~0.36° by + // 2026), so the fallback sky is right up to a fraction of a degree. + const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z'); + const teme = Cesium.Transforms.computeTemeToPseudoFixedMatrix(time); + const pole = Cesium.Matrix3.multiplyByVector( + teme, + Cesium.Cartesian3.UNIT_Z, + new Cesium.Cartesian3(), + ); + assert.ok(Math.abs(pole.z - 1) < 1e-9); +}); + +test('the rotation is reused only within the clock epsilon', () => { + const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z'); + const later = (seconds) => + Cesium.JulianDate.addSeconds(time, seconds, new Cesium.JulianDate()); + assert.equal(isSkyRotationCurrent(undefined, time), false); + assert.equal(isSkyRotationCurrent(time, later(0.5)), true); + assert.equal(isSkyRotationCurrent(time, later(-0.5)), true); + assert.equal( + isSkyRotationCurrent(time, later(SKY_ROTATION_EPSILON_SECONDS)), + false, + ); +}); + +test('the sky draws nothing when hidden, outside 3D, or in a pick pass', () => { + const sky = new StarrySky({ requestRender: () => {} }); + const frameState = (overrides) => ({ + mode: Cesium.SceneMode.SCENE3D, + passes: { render: true }, + ...overrides, + }); + sky._loading = true; // keep the test off the network + sky.show = false; + assert.equal(sky.update(frameState()), undefined); + sky.show = true; + assert.equal( + sky.update(frameState({ mode: Cesium.SceneMode.SCENE2D })), + undefined, + ); + assert.equal( + sky.update(frameState({ passes: { render: false, pick: true } })), + undefined, + ); + // Nothing has loaded yet, so even a render pass has no command. + sky._cubeMap = { destroy() {} }; // no WebGL context in this unit test + assert.equal(sky.update(frameState()), undefined); + assert.equal(sky.isDestroyed(), false); + assert.equal(sky.destroy(), undefined); + assert.equal(sky.isDestroyed(), true); +}); + +const frame = () => new Promise((resolve) => setImmediate(resolve)); + +for (const iso of ['1970-01-01T00:00:00Z', '2060-01-01T00:00:00Z']) { + test(`a clock outside the IAU 2006 tables (${iso}) settles on TEME instead of re-rendering every frame`, async () => { + const time = Cesium.JulianDate.fromIso8601(iso); + let renders = 0; + let preloads = 0; + const original = Cesium.Transforms.preloadIcrfFixed; + Cesium.Transforms.preloadIcrfFixed = (interval) => { + preloads += 1; + return original(interval); + }; + try { + const sky = new StarrySky({ requestRender: () => (renders += 1) }); + for (let i = 0; i < 20; i += 1) { + sky._updateRotation(time); // what update() does once per frame + await frame(); + } + assert.ok( + renders <= 1 && preloads <= 1, + `20 frames at a frozen clock -> ${preloads} preloads, ${renders} render requests (expected <= 1 each)`, + ); + } finally { + Cesium.Transforms.preloadIcrfFixed = original; + } + }); +} + +test('the Milky Way glow scales with its visibility, for styles that hide it', () => { + const sky = new StarrySky({ requestRender: () => {} }); + close(sky._milkyWayUniforms.u_intensity(), MILKY_WAY_INTENSITY); + sky.milkyWayVisibility = 0; + assert.equal(sky._milkyWayUniforms.u_intensity(), 0); + sky.destroy(); +}); + +test('destroy releases both the background and model environment maps', () => { + const sky = new StarrySky({ requestRender: () => {} }); + let destroyed = 0; + sky._cubeMap = { + destroy: () => { + destroyed++; + }, + }; + sky._milkyWayCubeMap = { + destroy: () => { + destroyed++; + }, + }; + sky.destroy(); + assert.equal(destroyed, 2); + assert.equal(sky._cubeMap, undefined); + assert.equal(sky._milkyWayCubeMap, undefined); + sky.destroy(); + assert.equal(destroyed, 2); +}); + +test('a render update uploads six opaque black environment faces only once', () => { + const uploads = []; + const gl = { + ...Cesium.WebGLConstants, + createTexture: () => ({}), + activeTexture() {}, + bindTexture() {}, + texParameteri() {}, + pixelStorei() {}, + texImage2D: (...args) => uploads.push(args), + deleteTexture() {}, + }; + const previous = Cesium.ContextLimits._maximumCubeMapSize; + Cesium.ContextLimits._maximumCubeMapSize = 16; + const sky = new StarrySky({ requestRender: () => {} }); + sky._loading = true; + const state = { + context: { _gl: gl }, + mode: Cesium.SceneMode.SCENE3D, + passes: { render: true }, + }; + try { + sky.update(state); + const environment = sky._cubeMap; + assert.ok(environment instanceof Cesium.CubeMap); + assert.equal(uploads.length, 6); + assert.equal(new Set(uploads.map((args) => args[0])).size, 6); + for (const args of uploads) { + assert.equal(args[3], 1); + assert.equal(args[4], 1); + assert.deepEqual([...args[8]], [0, 0, 0, 255]); + } + sky.update(state); + assert.equal(sky._cubeMap, environment); + assert.equal(uploads.length, 6); + sky.destroy(); + assert.equal(environment.isDestroyed(), true); + } finally { + if (!sky.isDestroyed()) sky.destroy(); + Cesium.ContextLimits._maximumCubeMapSize = previous; + } +}); diff --git a/src/sky/stars.js b/src/sky/stars.js new file mode 100644 index 000000000..f4139c3c5 --- /dev/null +++ b/src/sky/stars.js @@ -0,0 +1,121 @@ +/** + * Bright-star appearance: catalogue pack → GPU vertex data. + * + * Pure functions, no Cesium. The pack is `local_data/sky/bright_stars.json` + * (Yale Bright Star Catalogue, built by scripts/build-bright-stars.mjs): a + * flat `[raDeg, decDeg, vmag, bv, …]` array in ICRF/J2000. Each star is a + * small Gaussian sprite, sized in CSS pixels so it stays a crisp dot at any DPR. + * + * @module sky/stars + */ + +/** Brightness reference; fainter catalogue stars are drawn like it. */ +export const STAR_LIMIT_MAGNITUDE = 6.5; +/** Gaussian σ of the faintest star, CSS pixels. */ +export const STAR_MIN_SIGMA_PX = 0.55; +/** Gaussian σ cap for the brightest star, CSS pixels. */ +export const STAR_MAX_SIGMA_PX = 1.35; +/** Peak opacity of the faintest star. */ +export const STAR_MIN_ALPHA = 0.22; +/** B−V for stars the catalogue gives no colour (a solar-type white). */ +const STAR_DEFAULT_BV = 0.6; +/** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */ +export const STAR_VERTEX_FLOATS = 8; + +const DEG = Math.PI / 180; +// Fraction of the colour kept: the eye sees all but the most extreme stars +// as nearly white, and saturated dots read as UI markers on the globe. +const COLOR_SATURATION = 0.55; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +/** ICRF unit vector for J2000 right ascension and declination in degrees. */ +export function raDecToUnitVector(raDeg, decDeg) { + const ra = raDeg * DEG; + const dec = decDeg * DEG; + const cosDec = Math.cos(dec); + return [cosDec * Math.cos(ra), cosDec * Math.sin(ra), Math.sin(dec)]; +} + +/** + * Perceived brightness relative to the limit magnitude. Flux grows 2.512× + * per magnitude; the square root compresses Sirius-to-6.5m (≈ 1600×) into a + * range a display can show without the bright stars turning into discs. + */ +export function starBrightness(vmag) { + const magnitude = Number.isFinite(vmag) ? vmag : STAR_LIMIT_MAGNITUDE; + return ( + 10 ** + (-0.2 * (Math.min(magnitude, STAR_LIMIT_MAGNITUDE) - STAR_LIMIT_MAGNITUDE)) + ); +} + +/** + * Sprite parameters for a visual magnitude: `sigmaPx` is the Gaussian σ in + * CSS pixels and `alpha` its peak opacity. Faint stars keep the minimum width + * and fade; bright stars saturate and grow slowly. + */ +export function starAppearance(vmag) { + const brightness = starBrightness(vmag); + return { + sigmaPx: clamp( + STAR_MIN_SIGMA_PX * brightness ** 0.3, + STAR_MIN_SIGMA_PX, + STAR_MAX_SIGMA_PX, + ), + alpha: clamp(STAR_MIN_ALPHA * brightness, STAR_MIN_ALPHA, 1), + }; +} + +/** Effective temperature from B−V (Ballesteros 2012). */ +export function bvToTemperature(bv) { + const index = clamp(Number.isFinite(bv) ? bv : STAR_DEFAULT_BV, -0.4, 2.0); + return 4600 * (1 / (0.92 * index + 1.7) + 1 / (0.92 * index + 0.62)); +} + +/** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */ +function temperatureToRgb(kelvin) { + const t = clamp(kelvin, 1000, 40000) / 100; + const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592; + const green = + t <= 66 + ? 99.4708025861 * Math.log(t) - 161.1195681661 + : 288.1221695283 * (t - 60) ** -0.0755148492; + const blue = + t >= 66 + ? 255 + : t <= 19 + ? 0 + : 138.5177312231 * Math.log(t - 10) - 305.0447927307; + return [red, green, blue].map((channel) => clamp(channel, 0, 255) / 255); +} + +/** + * Display colour for a B−V index: the black-body hue, desaturated towards + * white and normalized so the brightest channel is 1 (brightness belongs to + * the magnitude alone). + */ +export function bvToRgb(bv) { + const rgb = temperatureToRgb(bvToTemperature(bv)).map( + (channel) => 1 - COLOR_SATURATION + COLOR_SATURATION * channel, + ); + const peak = Math.max(...rgb); + return rgb.map((channel) => channel / peak); +} + +/** Decode a catalogue pack into interleaved vertex data, `STAR_VERTEX_FLOATS` per star. */ +export function buildStarVertices({ stars }) { + const count = stars.length / 4; + const vertices = new Float32Array(count * STAR_VERTEX_FLOATS); + for (let i = 0; i < count; i++) { + const [raDeg, decDeg, vmag, bv] = stars.slice(i * 4, i * 4 + 4); + const { sigmaPx, alpha } = starAppearance(vmag); + vertices.set( + [...raDecToUnitVector(raDeg, decDeg), sigmaPx, ...bvToRgb(bv), alpha], + i * STAR_VERTEX_FLOATS, + ); + } + return { count, vertices }; +} diff --git a/src/sky/stars.test.mjs b/src/sky/stars.test.mjs new file mode 100644 index 000000000..2a48c341b --- /dev/null +++ b/src/sky/stars.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, statSync } from 'node:fs'; +import { + STAR_LIMIT_MAGNITUDE, + STAR_MAX_SIGMA_PX, + STAR_MIN_ALPHA, + STAR_MIN_SIGMA_PX, + STAR_VERTEX_FLOATS, + buildStarVertices, + bvToRgb, + bvToTemperature, + raDecToUnitVector, + starAppearance, + starBrightness, +} from './stars.js'; + +const close = (actual, expected, epsilon = 1e-9) => + assert.ok( + Math.abs(actual - expected) <= epsilon, + `${actual} is not within ${epsilon} of ${expected}`, + ); + +const skyFile = (name) => + new URL(`../data/local_data/sky/${name}`, import.meta.url); + +test('right ascension and declination map onto the ICRF axes', () => { + const equinox = raDecToUnitVector(0, 0); + close(equinox[0], 1); + close(equinox[1], 0); + close(equinox[2], 0); + const sixHours = raDecToUnitVector(90, 0); + close(sixHours[0], 0); + close(sixHours[1], 1); + const pole = raDecToUnitVector(123, 90); + close(pole[2], 1); + const south = raDecToUnitVector(200, -30); + close(Math.hypot(...south), 1); + close(south[2], -0.5); +}); + +test('brightness grows with each magnitude and stops at the catalogue limit', () => { + close(starBrightness(STAR_LIMIT_MAGNITUDE), 1); + close(starBrightness(STAR_LIMIT_MAGNITUDE + 1.5), 1); + close(starBrightness(STAR_LIMIT_MAGNITUDE - 5), 10); + close(starBrightness(Number.NaN), 1); +}); + +test('brighter stars are never smaller or fainter, and both ends clamp', () => { + let previous = starAppearance(8); + assert.deepEqual(previous, { + sigmaPx: STAR_MIN_SIGMA_PX, + alpha: STAR_MIN_ALPHA, + }); + for (let vmag = 7.5; vmag >= -1.5; vmag -= 0.25) { + const next = starAppearance(vmag); + assert.ok(next.sigmaPx >= previous.sigmaPx, `σ shrank at ${vmag}`); + assert.ok(next.alpha >= previous.alpha, `alpha dropped at ${vmag}`); + previous = next; + } + const sirius = starAppearance(-1.46); + assert.equal(sirius.alpha, 1); + assert.ok(sirius.sigmaPx <= STAR_MAX_SIGMA_PX); + assert.ok(sirius.sigmaPx > starAppearance(2).sigmaPx); + // Sprites stay point-like: ±3σ of the brightest star is a few pixels. + assert.ok(6 * STAR_MAX_SIGMA_PX < 10); +}); + +test('B−V colours run from blue-white to orange with the peak channel at 1', () => { + close(bvToTemperature(0.65), 5_800, 120); + close(bvToTemperature(0), 10_100, 200); + const hot = bvToRgb(-0.3); + const sunLike = bvToRgb(0.65); + const cool = bvToRgb(1.6); + for (const rgb of [hot, sunLike, cool]) { + close(Math.max(...rgb), 1); + assert.ok(Math.min(...rgb) > 0.5, 'colours stay desaturated'); + } + assert.ok(hot[2] > hot[0], 'hot stars are blue'); + assert.ok(cool[0] > cool[2], 'cool stars are orange'); + assert.deepEqual(bvToRgb(null), bvToRgb(0.6)); + assert.deepEqual(bvToRgb(9), bvToRgb(2)); +}); + +test('vertex data is interleaved per star', () => { + const { count, vertices } = buildStarVertices({ + stars: [0, 0, 1, 0.5, 90, 45, 3, null], + }); + assert.equal(count, 2); + assert.equal(vertices.length, 2 * STAR_VERTEX_FLOATS); + assert.deepEqual(Array.from(vertices.subarray(0, 3)), [1, 0, 0]); + close(vertices[STAR_VERTEX_FLOATS + 1], Math.SQRT1_2, 1e-6); + close(vertices[STAR_VERTEX_FLOATS + 2], Math.SQRT1_2, 1e-6); + close(vertices[3], starAppearance(1).sigmaPx, 1e-6); + close(vertices[7], starAppearance(1).alpha, 1e-6); +}); + +test('the committed catalogue holds the Yale Bright Star Catalogue', () => { + const pack = JSON.parse(readFileSync(skyFile('bright_stars.json'), 'utf8')); + assert.deepEqual(pack.meta.columns, ['raDeg', 'decDeg', 'vmag', 'bv']); + assert.equal(pack.stars.length, pack.meta.count * 4); + assert.equal(pack.meta.count, 9096); + let brightest = 0; + for (let i = 0; i < pack.stars.length; i += 4) { + const [ra, dec, vmag] = pack.stars.slice(i, i + 4); + assert.ok(ra >= 0 && ra < 360 && dec >= -90 && dec <= 90); + assert.ok(Number.isFinite(vmag)); + if (vmag < pack.stars[brightest + 2]) brightest = i; + } + // Sirius, α CMa: 06h45m08.9s −16°42′58″, V = −1.46. + assert.deepEqual( + pack.stars.slice(brightest, brightest + 4), + [101.287, -16.716, -1.46, 0], + ); + assert.equal(buildStarVertices(pack).count, pack.meta.count); +}); + +test('sky assets stay inside the 5 MB budget', () => { + const files = [ + 'bright_stars.json', + ...['px', 'nx', 'py', 'ny', 'pz', 'nz'].map( + (face) => `milkyway_${face}.webp`, + ), + ]; + const total = files.reduce( + (sum, name) => sum + statSync(skyFile(name)).size, + 0, + ); + assert.ok(total < 5 * 1024 * 1024, `${total} bytes`); +}); diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 9c0082700..814da60b2 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -41,12 +41,12 @@ const CATEGORY_PATTERN = /^[A-D][0-7]$/; /** A previous accepted fix older than this is no reference for a new one. */ export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000; /** Speed limit (kt) for an aircraft that has not reported a ground speed. */ -export const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; +const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; /** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */ -export const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; +const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; /** Reported ground speed is multiplied by this, plus the margin below. */ -export const LOCAL_ADSB_SPEED_FACTOR = 1.5; -export const LOCAL_ADSB_SPEED_MARGIN_KT = 50; +const LOCAL_ADSB_SPEED_FACTOR = 1.5; +const LOCAL_ADSB_SPEED_MARGIN_KT = 50; /** Distance always allowed between two fixes (CPR and reception error). */ export const LOCAL_ADSB_POSITION_MARGIN_M = 500; /** diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index 429dbe826..b2ccc9630 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -77,7 +77,7 @@ export function simplifyRing(ring, tolM) { return out.length >= 4 ? out : ring; } -export function douglasPeucker(points, tol) { +function douglasPeucker(points, tol) { const n = points.length; if (n < 3) return points.slice(); // Iterative, index-based (no per-call array slicing / recursion), so a large @@ -113,7 +113,7 @@ export function douglasPeucker(points, tol) { return out; } -export function perpDistance(p, a, b) { +function perpDistance(p, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; @@ -149,7 +149,7 @@ export function stitchRing(ways) { return endpointGapM(largest) <= allow ? largest : []; } -export function largestByArea(components) { +function largestByArea(components) { let best = components[0]; let bestArea = approximateAreaM2(best); for (let i = 1; i < components.length; i += 1) { @@ -162,7 +162,7 @@ export function largestByArea(components) { return best; } -export function ringSpanM(chain) { +function ringSpanM(chain) { let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; @@ -176,7 +176,7 @@ export function ringSpanM(chain) { return approximateDistanceM(minLat, minLon, maxLat, maxLon); } -export function buildRingComponents(ways) { +function buildRingComponents(ways) { const same = (a, b) => Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7; const remaining = ways.map((w) => w.slice()); @@ -207,7 +207,7 @@ export function buildRingComponents(ways) { return components; } -export function endpointGapM(chain) { +function endpointGapM(chain) { if (!chain || chain.length < 2) return Infinity; const a = chain[0]; const b = chain[chain.length - 1]; @@ -250,10 +250,13 @@ export function approximateAreaM2(coords) { return Math.abs(area) / 2; } +/** Local equirectangular distance in metres, the short way round 180°. */ export function approximateDistanceM(latA, lonA, latB, lonB) { const latScale = 111_320; const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180)); - return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); + return Math.hypot((latB - latA) * latScale, dLon * lonScale); } export function pointInPolygon(lon, lat, coords) { diff --git a/src/sources/featureGeometry.test.mjs b/src/sources/featureGeometry.test.mjs new file mode 100644 index 000000000..6da3ff92f --- /dev/null +++ b/src/sources/featureGeometry.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { approximateDistanceM } from './featureGeometry.js'; + +test('approximate distance takes the short way round 180°', () => { + // Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart. + const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33); + assert.ok(across > 150_000 && across < 200_000, String(across)); + assert.equal( + Math.round(approximateDistanceM(0, 179.9, 0, -179.9)), + Math.round(approximateDistanceM(0, -0.1, 0, 0.1)), + ); + // Away from 180° nothing changes. + assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320); +}); diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index 74f2efd23..c942579db 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -51,15 +51,25 @@ export function extractStationsArray(payload) { return []; } +/** + * Station name text as a reader expects it. JCDecaux's Spanish contracts + * (Valenbisi, Sevici) prefix every name with "_" and Valenbisi joins words + * with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so an underscore reads as a word + * break. Other spacing is the operator's own and stays as published. + */ +function tidyStationName(text) { + return text.replace(/\s*_+\s*/g, ' ').trim(); +} + /** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */ function stationName(value) { - if (typeof value === 'string') return value.trim(); + if (typeof value === 'string') return tidyStationName(value); if (!Array.isArray(value)) return ''; let fallback = ''; for (const translation of value) { if (typeof translation?.text !== 'string') continue; - const text = translation.text.trim(); + const text = tidyStationName(translation.text); if (!text) continue; if (!fallback) fallback = text; if ( @@ -71,6 +81,30 @@ function stationName(value) { return fallback; } +/** + * Docks a station physically has. PBSC adds the bikes a station accepts in its + * geofenced area around the docks to `capacity` and reports that part as + * `geofenced_capacity` (not a GBFS field): Bilbao Bizi publishes 39 docks as + * capacity 839 with geofenced_capacity 800. Without the extension, or when it + * would exceed the total, `capacity` is read as given. PBSC also counts the + * bikes parked in that area (`num_bikes_overflow`) in `num_bikes_available`, so + * a crowded station can report more bikes than docks. + */ +function dockCapacity(raw) { + const capacity = toNonNegativeInteger(raw?.capacity); + const geofenced = toNonNegativeInteger(raw?.geofenced_capacity); + if (capacity == null || geofenced == null || geofenced > capacity) + return capacity; + return capacity - geofenced; +} + +/** Read a numeric coordinate without treating blank text or other JSON types as zero. */ +function coordinateNumber(value) { + if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) + return NaN; + return Number(value); +} + /** * Parse a GBFS station_information payload into a Map of station metadata. * Handles field-name variations across different GBFS providers @@ -86,16 +120,23 @@ export function parseStationInformation(payload) { for (const raw of stations) { // Accept both GBFS (station_id) and alternate (id) field names const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const lat = coordinateNumber(raw?.lat ?? raw?.latitude); + const lon = coordinateNumber(raw?.lon ?? raw?.longitude); + if ( + !stationId || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; stationMap.set(stationId, { stationId, name: stationName(raw?.name) || stationName(raw?.short_name), lat, lon, - capacity: toNonNegativeInteger(raw?.capacity), + capacity: dockCapacity(raw), isInstalled: normalizeGbfsBool(raw?.is_installed, true), isRenting: normalizeGbfsBool(raw?.is_renting, true), isReturning: normalizeGbfsBool(raw?.is_returning, true), diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs index 8c60da90c..1ab273556 100644 --- a/src/sources/gbfsStations.test.mjs +++ b/src/sources/gbfsStations.test.mjs @@ -15,6 +15,92 @@ const parseName = (fields, version) => data: { stations: [station(fields)] }, }).get('s1').name; +const parseCapacity = (fields) => + parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') + .capacity; + +test('station information skips missing, blank and malformed coordinates', () => { + for (const field of ['lat', 'lon', 'latitude', 'longitude']) { + for (const value of [ + undefined, + null, + '', + ' \t ', + false, + true, + [], + [0], + {}, + 'bad', + NaN, + Infinity, + ]) { + const raw = { station_id: 'bad', lat: 0, lon: 0 }; + if (field === 'latitude') delete raw.lat; + if (field === 'longitude') delete raw.lon; + raw[field] = value; + const parsed = parseStationInformation({ + data: { stations: [raw, station({ station_id: 'good' })] }, + }); + assert.deepEqual( + [...parsed.keys()], + ['good'], + `${field} = ${JSON.stringify(value)}`, + ); + } + } +}); + +test('station information skips coordinates outside latitude and longitude bounds', () => { + for (const fields of [ + { lat: 90.001 }, + { lat: -90.001 }, + { lon: 180.001 }, + { lon: -180.001 }, + { lat: undefined, latitude: '91' }, + { lon: undefined, longitude: '-181' }, + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }); + assert.equal(parsed.size, 0, JSON.stringify(fields)); + } +}); + +test('station information preserves zero, boundary coordinates and numeric aliases', () => { + for (const [fields, expected] of [ + [{ lat: 0, lon: 0 }, [0, 0]], + [{ lat: ' 0 ', lon: '0' }, [0, 0]], + [{ lat: 90, lon: 180 }, [90, 180]], + [{ lat: '-90', lon: '-180' }, [-90, -180]], + [ + { lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' }, + [30.2681, -97.7469], + ], + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }).get('s1'); + assert.ok(parsed, JSON.stringify(fields)); + assert.deepEqual([parsed.lat, parsed.lon], expected); + } +}); + +test('capacity counts docks, not the geofenced area PBSC adds to it', () => { + // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. + assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); + assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23); + assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19); +}); + +test('capacity is read as given without a usable geofenced_capacity', () => { + assert.equal(parseCapacity({ capacity: 27 }), 27); + assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27); + assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27); + assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10); + assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null); +}); + test('GBFS v2 station names remain trimmed strings', () => { assert.equal( parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'), @@ -22,6 +108,42 @@ test('GBFS v2 station names remain trimmed strings', () => { ); }); +test('underscores in station names read as word breaks', () => { + // As published by JCDecaux's Spanish contracts (checked 2026-10-17). + for (const [published, shown] of [ + ['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'], + ['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'], + ['_CAMPAMENTO_81', 'CAMPAMENTO 81'], + ['Plaza__Mayor_ ', 'Plaza Mayor'], + ]) { + assert.equal(parseName({ name: published }), shown); + } +}); + +test('spacing without underscores stays as the operator published it', () => { + assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82'); +}); + +test('a name of only underscores falls back to short_name, then to empty', () => { + assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1'); + assert.equal(parseName({ name: '__', short_name: '_' }), ''); +}); + +test('underscores are read the same in GBFS v3 localized names', () => { + assert.equal( + parseName( + { + name: [ + { language: 'es', text: '_' }, + { language: 'en', text: '_COLON_I' }, + ], + }, + '3.0', + ), + 'COLON I', + ); +}); + test('GBFS v3 station names prefer English over the first translation', () => { assert.equal( parseName( diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 658db1596..b8b1787da 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -214,11 +214,10 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo assert.equal(mil.observedAtMs, now - 60000); assert.equal(mil.records[0].positionTimeMs, now - 75000); assert.equal(mil.stale, true); - const track = await military.getTrack('abc123'); - assert.equal(track.complete, false); - assert.equal(track.records[0].observedAtMs, now - 50000); - assert.equal(track.records[0].baroAltitudeM, 304.8); - assert.equal(track.records[0].ellipsoidAltitudeM, null); + assert.equal(military.getTrack, undefined); + assert.ok( + requests.every(({ url }) => !url.startsWith('/api/military/track')), + ); }); test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => { diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 55670c629..d76ecf3fa 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -1,5 +1,4 @@ import { - epoch, finite, httpError, LiveSourceError, @@ -32,9 +31,7 @@ function openSkyError(response) { const reasons = { oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', - basic_invalid_credentials: 'OpenSky username/password rejected', - missing_basic_creds: 'OpenSky auth missing', - missing_oauth_and_basic_creds: 'OpenSky auth missing', + missing_oauth_creds: 'OpenSky auth missing', auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required', }; @@ -159,26 +156,8 @@ export function createMilitarySource({ status: response.status, }; }, - async getTrack(reference, { signal } = {}) { - const { response, payload } = await readResponse( - fetchImpl, - '/api/military/track?hex=' + encodeURIComponent(reference), - { signal }, - 'Military aircraft', - ); - if (!response.ok) throw httpError(response, 'Military aircraft'); - const baseTimeMs = epoch(payload?.timestamp, 1000); - return { - records: - baseTimeMs == null - ? [] - : normalizeAircraftTrack(payload?.trace, { - baseTimeMs, - readsb: true, - }), - complete: false, - }; - }, + // ADSB.lol does not expose a supported live-history endpoint. Omitting + // optional getTrack keeps the tracking layer on its accumulated trail. }; } diff --git a/src/sources/openFreeMap.js b/src/sources/openFreeMap.js index 274c2ace7..00c288a8b 100644 --- a/src/sources/openFreeMap.js +++ b/src/sources/openFreeMap.js @@ -24,7 +24,7 @@ const ROAD_TYPES = Object.freeze({ * OpenMapTiles folds private access into `no`. Tunnels cannot be placed on * the surface above them; ramps/bridges remain valid only on a motor road. */ -export function isDrivableOpenMapRoad(properties = {}) { +function isDrivableOpenMapRoad(properties = {}) { return ( Object.hasOwn(ROAD_TYPES, properties.class) && !properties.subclass && diff --git a/src/sources/radioBrowser.js b/src/sources/radioBrowser.js index 8511b6beb..8329f6257 100644 --- a/src/sources/radioBrowser.js +++ b/src/sources/radioBrowser.js @@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) { .trim(); } +/** Normalize a station/filter tag and cap its final, case-folded representation. */ +export function normalizeRadioTag(value) { + return cleanRadioText( + String(value ?? '') + .toLowerCase() + .replace(/[_-]+/g, ' '), + 80, + ); +} + export function isNonGlobalIpv4(hostname) { const pieces = hostname.split('.'); if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) @@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation( if (!name) return null; const tags = String(raw?.tags ?? '') .split(',') - .map((tag) => - cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(), - ) + .map(normalizeRadioTag) .filter(Boolean) .filter((tag, index, all) => all.indexOf(tag) === index) .slice(0, 24); diff --git a/src/sources/tle.js b/src/sources/tle.js index ac7aedc2c..61c409a2b 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -13,7 +13,14 @@ export function parseTleText(text) { const name = lines[i]; const line1 = lines[i + 1]; const line2 = lines[i + 2]; - if (line1.startsWith('1 ') && line2.startsWith('2 ')) { + const cat1 = tleCatalogNumber(line1); + const cat2 = tleCatalogNumber(line2); + if ( + line1.startsWith('1 ') && + line2.startsWith('2 ') && + cat1 !== null && + cat1 === cat2 + ) { result.push({ name, line1, line2 }); i += 2; } @@ -21,8 +28,47 @@ export function parseTleText(text) { return result; } -/** The NORAD catalog number from TLE line 1, or null. */ -export function tleCatalogNumber(line1) { - const number = Number.parseInt(String(line1).slice(2, 7), 10); - return Number.isInteger(number) ? number : null; +// Alpha-5: a letter A–Z, skipping I and O, then exactly four digits. +const ALPHA5 = /^[A-HJ-NP-Z]\d{4}$/; +const DIGITS = /^\d+$/; + +/** + * The catalog number a catalog field stands for, or null for any other field. + * + * A field is either plain digits or Alpha-5, the TLE spelling of numbers above + * 99999: the letter stands for 10–33 in the ten-thousands place, skipping I + * and O, so 100000 is "A0000" and 339999 is "Z9999". Anything else (a sign, a + * decimal point, hex, an embedded space, a lowercase or trailing letter) is + * not a catalog number. + * @param {string} field the field with any leading spaces already removed + * @returns {number | null} + */ +function decodeCatalogField(field) { + if (DIGITS.test(field)) return Number(field); + if (!ALPHA5.test(field)) return null; + const letter = field[0]; + let tens = letter.charCodeAt(0) - 65 + 10; + if (letter > 'I') tens -= 1; + if (letter > 'O') tens -= 1; + return tens * 10000 + Number(field.slice(1)); +} + +/** + * NORAD catalog number of a satrec as a number, or NaN. + * + * satellite.js keeps the TLE's five-character field verbatim in + * `satrec.satnum`, and `Number("A0000")` is NaN, so every Alpha-5 satellite + * would share one NaN key. Digits of any length (an OMM satrec carries the + * number unpadded) and Alpha-5 decode; anything else, blank included, is NaN. + * @param {string} satnum + * @returns {number} + */ +export function noradIdFromSatnum(satnum) { + return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN; +} + +/** The NORAD catalog number from TLE line 1 or 2 (columns 3–7), or null. */ +export function tleCatalogNumber(line) { + const field = String(line).slice(2, 7); + return decodeCatalogField(field.replace(/^ +/, '')); } diff --git a/src/sources/transitHistoryStore.js b/src/sources/transitHistoryStore.js index 9fe29f59d..cf82835c1 100644 --- a/src/sources/transitHistoryStore.js +++ b/src/sources/transitHistoryStore.js @@ -1,7 +1,7 @@ import { validTransitIdentifier } from './transitHistory.js'; export { validTransitIdentifier } from './transitHistory.js'; /** Bounded, process-local operator position history. No upstream or scene access. */ -export const TRANSIT_HISTORY_LIMITS = Object.freeze({ +const TRANSIT_HISTORY_LIMITS = Object.freeze({ retentionMs: 900000, fixes: 128, feedBytes: 16 * 1024 * 1024, diff --git a/src/splitFlap.js b/src/splitFlap.js index 17d677999..9b78feeba 100644 --- a/src/splitFlap.js +++ b/src/splitFlap.js @@ -72,7 +72,7 @@ export const FLAP_STAGGER_MS = 26; */ export const FLAP_MAX_TOTAL_MS = 620; /** Slack after the last character lands before the cells are stripped. */ -export const FLAP_SETTLE_SLACK_MS = 60; +const FLAP_SETTLE_SLACK_MS = 60; /** * Fraction of a character's flip at which the outgoing glyph has rotated * away and the incoming one takes over as what the eye reads. MUST track the diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 935351685..693ddd47f 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -19,6 +19,7 @@ import { createWeatherSource } from '../layers/weather/source.js'; import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; +import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -50,6 +51,7 @@ export function createStandaloneLayerSources(overrides = {}) { launches: createLaunchSource(), alpr: createAlprTileSource(), firms: createFirmsSource(), + 's2-ghostmaps': createGhostMapsSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), diff --git a/src/standalone/tools.js b/src/standalone/tools.js index fb127d598..fdeb08d37 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -17,6 +17,11 @@ export function createStandaloneTools(options) { }, }, ...options, - voice: { toolCatalog: loadToolCatalog, ...options?.voice }, + voice: { + supportedProviders: ['openai', 'gemini'], + panel: true, + toolCatalog: loadToolCatalog, + ...options?.voice, + }, }); } diff --git a/src/standalone/ui.js b/src/standalone/ui.js deleted file mode 100644 index 780d515cf..000000000 --- a/src/standalone/ui.js +++ /dev/null @@ -1 +0,0 @@ -export { StyleManager } from '../ui/composition.js'; diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 4617eb4d9..8d316c120 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -11,12 +11,11 @@ const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8'); const html = expandApplicationHtml(read('index.html')); const css = readStylesheet(path.join(ROOT, 'style.css')); +const panelCss = read('src/ui/styles/street-level.css'); const panelChrome = read('src/ui/panelChrome.js'); const layoutController = read('src/ui/panelLayoutController.js'); -const panelCss = read('src/ui/styles/street-level.css'); -const controls = read('src/ui/streetLevelControls.js'); -test('Street Level is an ordinary collapsible GEV panel that starts collapsed', () => { +test('Street Level is a collapsible right-rail panel that starts collapsed', () => { assert.match( html, / +
diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html index 290726417..9e88d20f4 100644 --- a/src/ui/templates/context.html +++ b/src/ui/templates/context.html @@ -121,7 +121,7 @@
-
@@ -180,7 +180,7 @@
- @@ -221,7 +221,7 @@
- +
diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index 220dc373b..f6af31127 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -13,7 +13,7 @@
Layout - @@ -27,27 +27,27 @@
- + 8
- + 100%
- + 70%
- + 84%
- + 24°
@@ -58,24 +58,24 @@
- + 50%
Allocation -
- - +
+ +
- + 7%
- + 1%
@@ -99,26 +99,22 @@
Models -
- - +
+ +
- + +
Feather - - - 11% + + 100%
@@ -128,15 +124,15 @@
Shape -
- - - +
+ + +
- @@ -172,7 +168,7 @@ Bloom
- + 0%
@@ -182,7 +178,7 @@ Sharpen
- + 49%
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 363cfe0af..9d8d15a97 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -27,25 +27,25 @@ CCTV feed frame
-
SOURCE · UNKNOWN
+
SOURCE · UNKNOWN
Enable CCTV to load camera intersections
- +
- - - + + +
- +
- CAL · -- + CAL · --
@@ -62,8 +62,8 @@
- - + +
SCENE SUMMARY
@@ -83,64 +83,60 @@
-
-