diff --git a/.env.example b/.env.example index fdfd8bf48..06068b306 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,13 @@ 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_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams +# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured 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..356289841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,707 @@ # Changelog +## [Unreleased] + +## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language + +- Show a Street Level view facing 359.5° or more as 0°. The viewer caption + rounded the bearing without wrapping, so a view facing 359.6° read "360°" + (Aditya, @Rohilalala, #1431). + +- The visual styles and their presets mark their labels with the catalog-free + `msg()`, so the share-link codec, and any server that loads it, no longer + bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). + +- Keep a location search that found nothing, or failed, in the field so it can + be corrected: the query stays, focused and selected, and a persistent + announced message under the search row says what to do next, instead of a + toast followed by a cleared and closed field (samehkhamis, #1420). + +- Hosts can read share links on their server: the share-link codec and the + `/share/` address helpers are now package exports, + `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` + (samehkhamis, #1419). + +- Share links are now short: the share button makes `/share/` links + that open on any copy of the app, follow the aircraft, military aircraft or + satellite you were following, and fall back to its last-known position with + a notice if it isn't reporting. A mistyped link is refused instead of + opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). + +- Make Clear All clear the saved and shared layer selection. Layers that a + context mode had already switched off no longer stay in the share link and + come back on reload (samehkhamis, #1417). + +- Restore a share link opened in an already-open tab. Pasting a link into the + address bar, following one, or using Back/Forward now moves the camera and + restores the style, map, panels and layers instead of changing only the URL + (samehkhamis, #1416). + +- Add a Scope checkbox and extend feathering to 200%, retaining scope settings + across HUD layouts, share links and scenes. First run opens with Scope ON at + 100% feather; explicit settings and historical links retain their values. Give + Tactical, Operator and Minimal text black backplates only when Scope is OFF, + including vertical metadata, without changing the original HUD text color, glow or + opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). + Keep title and tagline positions stable when toggling Scope, include the eye + in the title backing, and brighten the two secondary labels only with Scope OFF. + +- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, + `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. + The local GeoJSON layer tests now build layers the way the catalog does, so + they keep covering `localGeojsonCore.js`. Package exports are unchanged + (samehkhamis, #1403). + +- Aircraft on the ground keep getting ground-height samples after the + computer's clock steps back, instead of waiting for it to catch up + (quanticsoul4772, #1399). + +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). + +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). + +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). + +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). + +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). + +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). + +- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° + printed "N · 360°" on the local wind direction + (Rudy Celekli, @rudycelekli, #1389). + +- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1388). + +- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1387). + +- Show a route leg of 995 m or more as 1.0 km. The route summary and step + cards kept meters below 1000, then rounded 996 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1386). + +- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step + cards kept seconds below 60, then rounded 59.6 s up to "60 s" + (Rudy Celekli, @rudycelekli, #1385). + +- The HUD's AI place summary drops a reply that arrives after the camera has + started moving, so a summary asked for before a flight no longer names the + old place mid-flight. Cancelled or partially typed summaries retry even if the place + context stays the same (quanticsoul4772, #1384). + +- Reduce repeated work in aircraft detection candidates by caching unchanged + callsign/registration text and reusing resolved fleet models. Metadata updates, + live positions and tracked-aircraft/model handoffs remain immediate + (samehkhamis, #1379). + +- Increase touch-first globe zoom sensitivity to three times the default while + retaining Cesium collision and distance limits. +- Reuse one delayed display timestamp per civil-aircraft fleet update and cache + extrapolation coordinate frames until their fix coordinates change. Preserve + contact counts, movement cadence, tracked motion and ground corrections. + +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). + +- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's + dynamic environment map, a sky render and GPU readback per model. With + ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at + rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison + showed no visible change (quanticsoul4772, #1366). + +- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW + detection was labeled 10.0 MW because the one-decimal cutoff ran before + rounding (Rudy Celekli, @rudycelekli, #1354). + +- Count a transit report that is 90 seconds old as one minute. The card kept + seconds through 89s, then rounded 90s up to "Reported 2 min ago" + (Rudy Celekli, @rudycelekli, #1353). + +- Read a destination heading that rounds to north as 000. 359.6° was shown as + 360° on the destination bearing while the compass tape already uses 000 + (Rudy Celekli, @rudycelekli, #1352). + +- Keep a tracked vessel's speed, course, heading and fix time when AISStream + static data arrives; the static message only merges name, type, destination + and IMO (rudycelekli, #1351). +- Apply changed static metadata immediately, retaining known fields when a + later static report omits them, without refreshing the vessel's position + (samehkhamis, #1351). + +- Convert NHC cyclone movement speed from miles per hour to knots, so + `movement.speedKt` in cyclone query results is no longer about 15% high + (rudycelekli, #1350). + +- Label NHC cyclone classifications by their published meanings: `PTC` is a + post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical + systems arrive as `STS` and `STD` (rudycelekli, #1349). + +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). + +- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" + instead of "on the slightly left", and a lane names its side in both + the proxy response and localized browser instructions + (Rudy Celekli, @rudycelekli, #1343). + +- Keep an impossible wildfire acquisition date off the detection clock, so a + fire is not labeled with a later day it did not happen + (Rudy Celekli, @rudycelekli, #1342). + +- Reject impossible KartaView photo timestamps, so 31 February is unknown + instead of rolling forward into March and passing the imagery date filter + (Rudy Celekli, @rudycelekli, #1341). + +- Read a hemisphere letter that is separated from its number, so a search for + `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders + (Rudy Celekli, @rudycelekli, #1340). + +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). + +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). + +- Evict terrain-height cache entries in fetch-time order without repeatedly + sorting the full cache, while keeping refreshed points newest and ordering + restored entries by their fetch time (daikaginza, #1326). + +- Add a phone layout for viewports 720px and narrower: the globe stays full + screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one + panel sheet at a time, keeping attribution visible and scene STOP reachable. + Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). + +- Export the KartaView and Panoramax Street Level providers, so a host composing + its own provider list can offer the keyless providers without Mapillary + (samehkhamis, #1324). +- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the + token itself (such as a same-origin proxy). Its sequence lookups go there + through the source's transport, and the photo viewer uses MapillaryJS's + Graph provider at that endpoint, so no client token is needed in the page + (samehkhamis, #1324). + +- Remove dead code across the repository: three modules nothing imports + (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports + and locals nothing reads, a duplicate copy of the bundled dams data and an + unused ship model. The app no longer builds an unused default wind layer + and Bhote Koshi locator layer when it loads. Package exports are unchanged + (samehkhamis, #1321). + +- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). + +- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, + while Radio and Cyber supply explicit field palettes without changing the + current appearance or native control behavior (mazy1998, #1309). + +- Share read-only status badge presentation across CCTV source/frame labels, + calibration provenance and Weather coverage, preserving status meanings, + palettes, translations and announcements while preparing the view contract + for a future Vue migration (mazy1998, #1306). + +- Share native dropdown and numeric-field styling across Scene, CCTV, Display + and Radio controls, with a shared CCTV number-input builder that preserves + existing precision, validation and commit behavior (mazy1998, #1305). + +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). + +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). + +- Give ordinary Scene and CCTV actions shared native buttons with consistent + default, destructive and compact styling while preserving their existing + behavior and specialized controls (mazy1998, #1302). + +- Unify segmented selectors for model coverage, detection allocation, drawing + shape, Street View and imagery comparison while preserving their existing + behavior. Keep compact labels such as FILL and FLAT on one line, and let + collapsed Weather release space to the open rail panel. Reserve the full mode + button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). + +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts; + recover inactivity after confirmed audio-less replies, release retired audio + observers and keep cost warnings visible outside Settings + (manjunath22466, #1299). + +- Add Simplified Chinese (zh-CN) to the language picker, with localized dates + and readable Chinese labels. Based on translations by @abiyeenzo (#914) + and @EDIT-Aeterna (#979). + (German, @gekh, #1298). + +- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products + (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran + and Yemen products and the newest incident drop) from + github.com/s2underground/GhostMaps, with one chip per product and report + cards on click. `/api/ghostmaps` watches the repository's branch head every + 15 s and pushes each new commit to open pages, which reload only the + products that changed (Black Pine Defense). +- Clear a temporary GhostMaps upstream error and notify open pages when the + repository becomes reachable again, including when no new edition was published. +- Release packed-image resources when a load is cancelled or fails, respect + injected or omitted GhostMaps sources, and wait for background cache warm-up + before removing provider test fixtures. +- Include Simplified Chinese translations and keep the combined voice prompt + within its existing size limit while retaining all spoken aliases. + +- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the + shape before the core went slim, draws its lines again instead of a silent + blank; an overview level or any answer that is not a whole zoom shows the + zoom-in hint (asturksever, #1288). + +- Give ordinary sliders a shared native control and consistent track, thumb, + value, focus and disabled styling while preserving feature behavior and + specialized controls (mazy1998, #1287). + +- The Street Level SINCE note sits on the value's line, in its type and a + size smaller: with KartaView lit it reads ANY DATE (by upload date). It + had lost its size variable in the Street Level slim-down and rendered + larger than the value, on a line of its own (asturksever, #1286). + +- Keep aircraft route labels on the scheduled airport leg instead of accepting + its distant great-circle extensions, preserving generous endpoint tolerance + across the signed-angle branch cut (rudycelekli, #1284). + +- Report cached CelesTrak satellite records as stale in the Data Layers row, + preserving separate core and dense catalog provenance through refresh, + removal and lifecycle cleanup (rudycelekli, #1283). + +- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). + +- Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). +- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). +- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). +- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). +- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). + +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). + +- Reserve singleflight before disk cache reads (asturksever, #1278). + +- Bound each height request to one upstream chunk (asturksever, #1276). + +- Preserve enable during first-load replacement (asturksever, #1275). +- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). +- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275). + +- Scan the kml root delimiter in bounded time (asturksever, #1274). + +- Retire unavailable history and keep local trails (asturksever, #1273). + +- Normalize geocoder bias across the antimeridian (asturksever, #1272). + +- Reject inherited style keys during restore (asturksever, #1271). + +- Respect provider cooldowns and bound tle groups (asturksever, #1270). + +- Coalesce refreshes and cool down opensky failures (asturksever, #1269). + +- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). +- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). + +- Honor Docker Compose trusted-peer and allowed-host settings from the shell + or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway + access to Provider Settings before Vite starts (asturksever, #1265). + +- Prevent malformed Overpass filters from blocking the local server, and reject + cross-site or excessive query requests before reading their bodies + (asturksever, #1264; fixes #1248). +- Release unread responses from the optional Google-token probe so keyless + browser validation can settle; the Overpass gate recognizes only that probe's + expected 404 as an unavailable capability (asturksever, #1264). +- Make tracking arrival checks respect the aircraft update cadence while checking + orientation on the first reveal, avoiding false failures at faster render + rates (asturksever, #1264). + +- The terrain-heights bounds tests no longer read the checkout's + `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so + `npm test` no longer fails after the dev server has cached terrain near + Austin (KnottyDyes, #1244). + +- Replace the blocky JPEG sky around the globe with the 9,096 stars of the + Yale Bright Star Catalogue, drawn as sharp points where they are at the + scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun + and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh, + #1243). + +- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest + tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata + released on failed or cancelled tile reads (quanticsoul4772, #1239). + +- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: + ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, + with on-demand snapshots and attribution. No API key is required; set + `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). + +- Require a geocoded containment anchor for ambiguous analyst region names, + preserve boundary provenance and cancellation safety, and retain exact-name + precedence for natural outlines (manjunath22466, #1235). + +- Serve the camera catalog while it refreshes. An expired catalog made every + camera request wait for the slowest pack to reload; it is now answered at + once while one background refresh runs, so only the first load waits. Estonia's + slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out + on about a third of loads (quanticsoul4772, #1234). + +- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, + cancel both response streams when either response fails or overflows the 4 MB + byte cap, and deduplicate camera IDs before per-pack prioritization + (yaka0007, #1233). + +- Skip bike-share stations with missing, blank or invalid coordinates instead + of placing them at coerced zero coordinates or accepting out-of-range values. + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). + +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. + +- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a + free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is + otherwise skipped with one log line saying where to get a key + (quanticsoul4772, #1228). + +- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is + now a button that closes the card the same way ESC does; touch screens read + DISMISS, and phones no longer hide it, so choosing a mission is not the only + way out (KnottyDyes, #1226). + +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). + +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). + +- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI + command, while preserving an earlier accepted deferred input + (Manjunath, #1220). + +- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results + (`track_entity`, `get_entity_context`, pointer context); the voice card still + shows them in the interface language (daikaginza, #1207). + +- Report actual outcomes when voice enables or disables CCTV through the shared + layer lifecycle, including failed and cancelled changes (manjunath22466, + #1205). + +- Draw CCTV stills at their source aspect on the monitor plane instead of + stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so + every still was widened; they are now centered and pillarboxed on the dark + the placeholder already ends on (Danielslay86, #1201). + +- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or + WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen + video camera could only be found by opening it. Stills stay cyan and the + active camera stays amber, and focus de-emphasis fades the violet the same + way (Danielslay86, #1200). + +- Keep Space Missions marked STALE when Launch Library 2 falls back to cached + launch data, and return to NOMINAL after a fresh response without discarding + retained missions (rudycelekli, #1199). + +- Share Launch Library request cache keys across installations refreshing in the + same UTC minute, identify the app to the provider, and explain that standard + space-missions access needs no key. Optional higher-rate LL2 tokens are paid + upgrades; existing local cache and server-only token handling are unchanged + (rudycelekli, #1197). + +- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD + (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI + (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and + BicinRivas, about 1,840 stations. Station names read underscores as word + breaks, and station capacity counts docks rather than PBSC's geofenced area + (ulihhhh, #1190). + +- Keep missing launch-pad coordinates unknown so launches use the location + fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage + flight numbers no longer show "FLIGHT 0" + (Rudy Celekli, @rudycelekli, #1186). + +- Flights' ground floor works across the antimeridian. The viewer-distance + check behind the 150 km floor radius and its copy in the photoreal mesh-floor + sampler subtracted longitudes without wrapping, so two points 21 km apart + across 180° measured about 38,000 km and low or grounded aircraft just across + the line from the viewer were never floored. A contact's display corridor + walked a segment across 180° the long way round, spending its cell budget on + ground behind the contact, and a contact on the seam lost its cell hysteresis. + SEARCH NEARBY SITES on a view across 180° searched Google Places around + longitude 0, on the far side of the globe, with a radius sized from the view's + height alone; it now searches the view (Aditya, @Rohilalala, #1173). + +- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` + turns null, blank strings and booleans into 0 or 1, and the camera source + list sends an unknown number as JSON null, so a source-pack camera that left + out its pose reached the browser as zeros clamped to each field's floor — + facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — + instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the + city's ground); a null or blank position placed the camera at 0,0. The + server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, + blank and boolean values as missing. The same conversion also stops a + Tallinn row with a null heading reading as a confident north-facing camera, + a Caltrans camera with a blank elevation losing its 150 m ground prior, and + a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 + or on the zero meridian (omerbberk, #1172). + +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations (Aditya, @Rohilalala, #1171). + +- Fire-perimeter queries handle outlines and holes crossing the antimeridian: + areas enclosed near ±180° are included, areas at the opposite meridian are + excluded, and returned perimeter coordinates stay near the dateline. Incident + cards use the same circular longitude mean and compare multipart outline + areas along short longitude edges, keeping the card on the largest polygon + (Arianna Method, @ariannamethod, #1142). + +- Scenes keep the "Scene not saved" status when a capture, update, append or + import cannot be saved, and an unreadable saved project no longer overwrites + the recovery checkpoint. `startScene()` resolves to its documented result, + `afterShotId` works without an explicit scene, and `scenesRun` counts the + distinct scenes queued (djabhi31, #1135). + +- Label camera-tool Street View fallbacks as historical Google imagery rather + than the current camera view; keep unknown frame provenance explicit + (rudycelekli, #1118). + +- Release superseded Google Street View open waits and suppress metadata + requests whose viewer was unmounted while the service loaded (rudycelekli, + #1117). + +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). + +- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain + at once, but no longer lasts the whole session: while the fallback is on + screen, the real provider is retried in the background (30 seconds, doubling + to a 10-minute cap) and replaces it live once it loads. Map switches never + wait for a retry (Aditya, @Rohilalala, #1111). + +- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a + spliced catalog cannot pair one satellite's elements with another's; the next + valid set still parses (VG31OP, #1101). + +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). + +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). + +- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for + GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and + GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, + #1094). + +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged (cj-vana, #1071). + +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) + +- On Windows, `npm run doctor` and the Pinokio install no longer print Node's + DEP0190 warning ("Passing args to a child process with shell option true can + lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of + the shell command string instead of an argument array (quanticsoul4772, #1057). + +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which airlines, countries, aircraft types or ship types + dominate an area, or the average altitude or speed, over every match instead + of estimating from the returned rows. Airlines come from the ICAO designator + in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds + that report no operator; operator and type are counted only where the feed + reports them. Ship types count by family, so AIS codes 70 to 79 are all + cargo; type 0 ("not available") and the reserved codes 100 to 255 count as + unknown. Altitude and speed stats cover airborne aircraft only, so parked + aircraft do not pull the means down. Every vessel answer, `find_vessel` + included, now reads AIS ship-type codes as their family and a destination + with no letters as unknown (quanticsoul4772, #1056). + +- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV + source pack: the public DATA.GOV.HK English locations XML, frames pinned to + `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By + default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, + prioritized across Hong Kong Island, Kowloon and the New Territories. Headings + use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets + the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). + +- Cross-site pages can no longer fill the terrain-height cache or spend the + visitor's own `/api/terrain/heights` rate-limit quota through the visitor's + browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or + opaque `Origin` before rate limiting, cache access or upstream work. Unlike + the cost-bearing routes it still answers same-origin requests that arrive + through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or + Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, + #975). + +- Street Level is rebuilt around one small provider registry: Mapillary, + KartaView, Panoramax and Google Street View share one coverage drawer and + one photo viewer, one source lit at a time. Floating and docked panels, + FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are + gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be + deleted (gekh, #974). + +- Cross-site browser requests can no longer spend the TomTom traffic-tile + budget through `/api/tomtom/*`. The shared same-site gate runs before status, + cache, and upstream work; same-origin app requests and non-browser local + callers remain supported (Sunil56224972, #973). + +- Recent Imagery: opening DETAILS scrolls its card into view when it opens below + the fold. The panel used to restore the previous scroll position right after + the reveal, so the card stayed out of sight (Ian Blenke, #970). + +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). + +- Give each Statens vegvesen camera its own ground height from Kartverket's + keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so + mountain-pass cameras no longer sit hundreds of metres below the terrain + where neither the point-height prior nor the ground snap resolves. About 850 + of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 + points, the most the API answers from its 1 m terrain model, eight at a + time; a cold lookup takes about 6–13 s. Cameras over water use the water + surface rather than the sea-floor depth the API returns there; for bridge + cameras that is still below the deck. Heights are cached for good and misses + for 6 h, so a refresh only asks about new or moved cameras. After a + failing request (a 408, 429 or 5xx, a network error, a request over 10 s or + an unreadable body) the lookup stops and backs off (Retry-After, else + 30 min, longer than the refresh). The lane waits at most 12 s from its + start, and only on a cold cache; a slower first lookup leaves the cameras it + had not reached on the flat prior until the next 15-minute refresh, and + keeps filling the cache meanwhile. + `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). + +- Extract a reusable model-free World Packet from the existing voice + `get_current_view_state` owner without restoring disabled layers to the + model context. Keep the enabled-only payload, disabled-layer count, source + provenance, action result and view-state contract; read the layer roster once + per packet; voice retains the speech/card envelope at its own boundary. + Credits: original GEV voice/layer contributors and #880 implementation. + +- Add optional Gemini Live voice to the standalone app, with a provider chooser, + server-held API key, microphone streaming, shared map actions and captions. + Switching providers ends the old conversation and preserves the input mode + (Manjunath, #872). + +- Close the compact Radio disclosure when an adaptive tactical right-rail + restore directly expands Context, before Space Missions can be covered. + A repeated Context-open request also clears stale disclosure state. + Real PanelChrome constructor wiring and the rail reflow path are covered + by focused regressions. Credits: kvnloo (#816), raysung-2026's original + #804 report, asturksever's review, and quanticsoul4772's independent + Windows diagnosis. + +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). + +- Read satellite catalog numbers written in Alpha-5, the TLE spelling of + numbers above 99999: a letter for the first two digits, skipping I and O + (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a + field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all + but the first were dropped as duplicates; the satellite tools read the same + field as no number, so such a satellite could not be found by it. The layer + and the tools now decode the field in `src/sources/tle.js`, and reject a + malformed one (a sign, decimal point, hex, embedded space or stray letter) + instead of reading part of it as a number. This is Alpha-5 TLE + compatibility: CelesTrak's TLE feed carries no satellite above 99999, which + needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). + +- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write + cache files and logs there while the app runs, and none of it is source, so + the watcher now skips it; this guards against the DNS-lookup delays heavy + cache writes have caused, though no difference was measured on current main + (monwiss, #761). + +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate. The point cache now holds at most 50,000 entries + and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). + +- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: + the statewide ArcGIS catalog, read a page at a time under a byte cap with + redirects refused, and frames pinned to FL511's two image hosts over HTTPS. + FL511's terms limit its content to individual, personal use, and any other + use (a public deployment included) needs FDOT's express written consent, so + the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the + catalog's direction codes, and camera ids come from the image channel, since + the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` + sets the cap (Danielslay86, #605). + +- The client's great-circle distances now come from one shared + `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` + points). The Earth radius is standardized on 6371.0088 km, so distances shift + by about 1.4 ppm and region areas by about 2.8 ppm, and missing or + out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). + - The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the civil and military layers, so a consumer reading `getTrackedInfo()` no longer has to reach past it into the raw poll record for climb/descent. Reported, 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 @@ -282,8 +978,6 @@ point. A slow render gets one bounded second frame request, while hidden, stale, moved-camera, invalid and oversized captures still fail closed. -## [Unreleased] - - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without @@ -317,9 +1011,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 +1070,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 +1112,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 @@ -504,6 +1192,28 @@ needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim fallback order are unchanged. +- Voice can switch on every shipped data layer (the two scene-driven + Bhote Koshi layers stay scene-only), including transit, Recent + Imagery, wind, the weather layers, cyclones and mapped installations, and + understands their spoken names ("buses", "hurricanes", "military bases"). + One voice manifest (`src/voice/layerManifest.js`) generates the tool enums + and aliases, and a unit test fails when a new layer has no entry. +- `analyst_query` counts up to 250,000 loaded records per layer instead of the + first 2,000, and says when a layer went past that (`complete: false`). It + also answers over bikeshare stations, transit vehicles, rocket launches, + cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire + records carry their sensor (VIIRS or MODIS). +- Unknown fields, operators, units, value types, scope kinds and invalid + centres are refused with the allowed values instead of answering zero or widening to anywhere. A + layer that is off, still loading or unavailable is reported as such, not + as a count of zero; a partly answerable query is marked `partial`. + Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` + converts, and large rankings no longer sort the whole set. +- Results carry `lat`/`lon` for each item and a `display` object with the + scope, the feed window (earthquakes and fires cover the last 24 h) and any + caveat for the screen. The model is asked to answer in one short phrase. +- The weather and Recent Imagery panels can be opened by voice. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, @@ -786,32 +1496,6 @@ (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and `MapSourceController.subscribe()` reports every settled map switch. -## Unreleased — voice reaches every layer; honest analyst answers - -- Voice can switch on every shipped data layer (the two scene-driven - Bhote Koshi layers stay scene-only), including transit, Recent - Imagery, wind, the weather layers, cyclones and mapped installations, and - understands their spoken names ("buses", "hurricanes", "military bases"). - One voice manifest (`src/voice/layerManifest.js`) generates the tool enums - and aliases, and a unit test fails when a new layer has no entry. -- `analyst_query` counts up to 250,000 loaded records per layer instead of the - first 2,000, and says when a layer went past that (`complete: false`). It - also answers over bikeshare stations, transit vehicles, rocket launches, - cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire - records carry their sensor (VIIRS or MODIS). -- Unknown fields, operators, units, value types, scope kinds and invalid - centres are refused with the allowed values instead of answering zero or widening to anywhere. A - layer that is off, still loading or unavailable is reported as such, not - as a count of zero; a partly answerable query is marked `partial`. - Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` - converts, and large rankings no longer sort the whole set. -- Results carry `lat`/`lon` for each item and a `display` object with the - scope, the feed window (earthquakes and fires cover the last 24 h) and any - caveat for the screen. The model is asked to answer in one short phrase. -- The weather and Recent Imagery panels can be opened by voice. - -## Unreleased — local receiver feeds - ### Local receiver feeds - The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder @@ -954,7 +1638,6 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). - - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public @@ -1231,6 +1914,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Other changes + - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. - Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution. @@ -2039,3 +2723,4 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba0d38768..a7fe8ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,9 +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. Five have an `npm run` alias: `qa:map-source-tray`, -`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and -`qa:transit`. +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 @@ -66,11 +65,10 @@ gate for the feature you changed. > **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, and against the built preview -> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run -> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev -> server and a browser. Include the applicable local run in your PR's -> validation evidence. +> production build, plus a Windows onboarding job. It runs neither +> `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 @@ -110,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). @@ -118,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. @@ -155,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 563f28544..1524bd280 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,9 +64,12 @@ 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" | +| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) | | **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **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á" | @@ -110,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. @@ -152,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. @@ -162,17 +168,21 @@ 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`. +- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All 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 country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. -- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and 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.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **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. @@ -200,6 +210,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) | @@ -272,6 +284,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 c49c53ece..ac135360f 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. - **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in. -- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir, Snow. +- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. - **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave. @@ -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)).
@@ -280,14 +294,14 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea ![Tracking the ISS along its orbital path as it crosses over Ukraine](docs/media/14-iss-over-ukraine.gif) -6. **Switch the optics.** Tap `1`–`7` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor. +6. **Switch the optics.** Tap `1`–`6` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor. ![Cycling a dense live globe through CRT, FLIR, and NVG in one continuous view](docs/media/01-style-sweep.gif) 7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_ 8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_ -**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. +**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. --- @@ -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) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). 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) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). 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/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..de4215387 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; @@ -66,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({ * Local ADS-B layer: draws aircraft heard by the user's own receivers. * * Two inputs feed it. The receiver is any object with `getState()` returning - * `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`, + * `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`, * `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from - * `src/sources/adsbRecords.js`. The browser WebUSB session is one such - * receiver. The optional `feeds` session (`./feeds.js`) supplies records read + * `src/sources/adsbRecords.js`, and the optional `errorText` is the English + * text of an `error` status (`message`, the UI-language one, stands in for a + * receiver without it). The browser WebUSB session is one such receiver. + * The optional `feeds` session (`./feeds.js`) supplies records read * by the server from local 1090/978 MHz decoders; it polls only while this * layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`). * Aircraft heard only on 978 MHz UAT carry a thin ring around the marker. 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..afc61f7bc 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) && @@ -51,7 +51,7 @@ export function feedsConfigured(feedState) { function webUsbStatus(receiver, heard, feedState) { if (receiver.status === 'error') - return { status: 'error', error: receiver.message }; + return { status: 'error', error: receiver.errorText ?? receiver.message }; if (receiver.status === 'connecting' || receiver.status === 'tuning') return { loading: true, loadingLabel: msg('opening receiver') }; if ( diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index 8fe8245db..61eb763cf 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -2,7 +2,8 @@ // "USB error", feed problems, even as the whole error) read in the UI language. import assert from 'node:assert/strict'; import test from 'node:test'; -import { setLocale } from '../../i18n/index.js'; +import { setLocale, t } from '../../i18n/index.js'; +import { SdrController } from '../../sdr/controller.js'; import { LayerPanel } from '../../ui/layerPanel.js'; import { localAdsbStatus } from './status.js'; @@ -66,3 +67,45 @@ for (const locale of ['es', 'ru']) { }); } } + +// A receiver error is English data for the layer (stats, and what the voice +// tools and the analyst read); the Radio card and the row show it translated. +for (const locale of ['es', 'ru']) { + test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => { + const receiver = new SdrController({ + storage: null, + webUsb: {}, + providerFactory: () => ({ + get: async () => { + throw new Error('No device was selected.'); + }, + }), + }); + setLocale(locale); + try { + assert.equal(await receiver.connect('adsb'), false); + const state = receiver.getState(); + assert.equal(state.status, 'error'); + assert.equal(state.errorText, 'No RTL-SDR device selected'); + assert.equal(state.message, t('No RTL-SDR device selected')); + assert.notEqual(state.message, state.errorText); + + const status = localAdsbStatus({ + receiver: state, + feedState: null, + heard: 0, + }); + assert.equal(status.error, 'No RTL-SDR device selected'); + const row = rowFor(status, 0); + assert.ok(row.includes(state.message), row); + assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + + // The English text goes with the error: stop() clears it. + await receiver.stop(); + assert.equal(receiver.getState().errorText, null); + } finally { + setLocale('en'); + await receiver.destroy(); + } + }); +} 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/perimeters/index.js b/src/layers/perimeters/index.js index 0ef9a9bcc..83826a865 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -55,6 +55,8 @@ export function createFirePerimetersLayer({ let _dataSource = null; let _count = 0; let _lastUpdate = null; + // The proxy's last snapshot, served while WFIGS is unreachable. + let _stale = false; let _lastError = null; let _enabled = false; let _clickHandler = null; @@ -260,6 +262,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; _enabled = false; console.log('[Data:FirePerimeters] Initialized'); }, @@ -287,9 +290,21 @@ export function createFirePerimetersLayer({ _request = request; refreshInciwebIndex(request.signal); try { - const rows = await source.getSnapshot({ signal: request.signal }); + // The source contract is getSnapshot; a source that can also say how + // fresh its rows are (the WFIGS proxy) offers getSnapshotDetail. + const snapshot = + typeof source.getSnapshotDetail === 'function' + ? await source.getSnapshotDetail({ signal: request.signal }) + : { rows: await source.getSnapshot({ signal: request.signal }) }; if (request.signal.aborted || _request !== request || !_enabled) return false; + const { rows } = snapshot; + _stale = snapshot.stale === true; + // A stale snapshot is as old as its fetch, not this poll. + const updatedAt = + _stale && Number.isFinite(snapshot.fetchedAt) + ? snapshot.fetchedAt + : Date.now(); // Incident facts may change independently of the polygon's source clock. const signature = JSON.stringify( @@ -299,7 +314,7 @@ export function createFirePerimetersLayer({ ); if (signature === _snapshotSignature) { if (_selectedId) publishSelectedCard(); - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; return true; } @@ -355,7 +370,7 @@ export function createFirePerimetersLayer({ // Refresh (or drop) the selected incident's card against the new feed. if (_selectedId) publishSelectedCard(); _count = rows.length; - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; console.log( `[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`, @@ -366,6 +381,8 @@ export function createFirePerimetersLayer({ return false; console.warn('[Data:FirePerimeters] Fetch error:', e); _lastError = e?.message || msg('Perimeter source unavailable'); + // A failure now outranks the stale snapshot still drawn. + _stale = false; return false; } finally { if (_request === request) _request = null; @@ -391,6 +408,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; }, /** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */ @@ -443,6 +461,7 @@ export function createFirePerimetersLayer({ count: _count, lastUpdate: _lastUpdate, error: _lastError, + stale: _stale, }; }, }; diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index debd76aa8..bd5c3fc70 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) { if (action !== 'destroy') h.layer.destroy(); }); } + +test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => { + const fetchedAt = Date.now() - 3 * 60 * 60 * 1000; + let snapshot = { rows: [row], stale: true, fetchedAt }; + const h = harness({ + getSnapshot: async () => snapshot.rows, + getSnapshotDetail: async () => snapshot, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + assert.equal(h.layer.getStats().lastUpdate, fetchedAt); + snapshot = { rows: [row], stale: false, fetchedAt: Date.now() }; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.ok(h.layer.getStats().lastUpdate > fetchedAt); +}); + +test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => { + let fail = false; + const h = harness({ + getSnapshot: async () => [row], + getSnapshotDetail: async () => { + if (fail) throw new Error('WFIGS HTTP 502'); + return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 }; + }, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + fail = true; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502'); +}); diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js index 133beadb5..569191781 100644 --- a/src/layers/perimeters/source.js +++ b/src/layers/perimeters/source.js @@ -8,20 +8,35 @@ const msg = (text) => text; export function createWfigsPerimeterSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + /** + * The rows with how fresh they are: the proxy answers from its last + * snapshot, marked stale, while WFIGS is unreachable. + * @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>} + */ + async function getSnapshotDetail({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/fire-perimeters', { signal }); + if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error(msg('Malformed perimeter snapshot')); + return { + rows: payload.rows, + stale: payload.stale === true, + fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null, + }; + } + return { - async getSnapshot({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/fire-perimeters', { signal }); - if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); - const payload = await readResponseJsonCapped( - response, - 80 * 1024 * 1024, - signal, - ); - signal?.throwIfAborted(); - if (!Array.isArray(payload?.rows)) - throw new Error(msg('Malformed perimeter snapshot')); - return payload.rows; + /** The perimeter rows (see getSnapshotDetail for staleness). */ + async getSnapshot(options = {}) { + return (await getSnapshotDetail(options)).rows; }, + getSnapshotDetail, }; } diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs index a9f8fbd4c..bb6d6b305 100644 --- a/src/layers/perimeters/source.test.mjs +++ b/src/layers/perimeters/source.test.mjs @@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a name: 'AbortError', }); }); + +test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => { + let clock = 1_000_000_000; + let up = true; + const plugin = firePerimetersProxy({ + now: () => clock, + fetchImpl: async () => { + if (!up) throw new Error('WFIGS unreachable'); + return Response.json(validPayload); + }, + }); + let handler; + plugin.configureServer({ + middlewares: { use: (_path, callback) => (handler = callback) }, + }); + const ask = async () => { + const reply = {}; + await handler( + { url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } }, + { + writeHead(status, headers) { + reply.status = status; + reply.staleHeader = headers?.['X-Data-Stale'] === 'true'; + }, + end(body) { + reply.body = JSON.parse(body); + }, + }, + ); + return reply; + }; + const fresh = await ask(); + assert.equal(fresh.status, 200); + assert.equal(fresh.body.stale, undefined); + up = false; + clock += 23 * 60 * 60 * 1000; + const stale = await ask(); + assert.equal(stale.status, 200); + assert.equal(stale.staleHeader, true); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + clock += 2 * 60 * 60 * 1000; + assert.equal((await ask()).status, 502, 'a day-old snapshot is not served'); +}); + +test('the snapshot detail carries the proxy staleness and fetch time', async () => { + const fetchedAt = Date.UTC(2026, 9, 25, 12); + const source = createWfigsPerimeterSource({ + fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }), + }); + assert.deepEqual(await source.getSnapshotDetail(), { + rows: [], + stale: true, + fetchedAt, + }); + assert.deepEqual(await source.getSnapshot(), []); +}); + +test('getSnapshot works when taken off its source object', async () => { + const { getSnapshot } = createWfigsPerimeterSource({ + fetchImpl: async () => Response.json({ rows: [] }), + }); + assert.deepEqual(await getSnapshot(), []); +}); 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..33a0785b5 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,12 @@ 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().startsWith('zh-') + ? 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 fd136c82d..097d9e3be 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -139,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. @@ -226,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 f8ca1db56..01653611c 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -36,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; @@ -91,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; @@ -102,11 +108,13 @@ 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); @@ -123,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; @@ -157,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/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/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/controls.js b/src/layers/traffic/controls.js index d33e5a076..01af66633 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) { liveMode: layerState._liveMode, fetching: loading, flowError: layerState._flowError, + stale: layerState._flowStale, coveragePct: flowCoveragePct, statusUnavailable: layerState._flowStatusUnavailable, roadSource: layerState._roadSource, @@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading, mode: feed.mode, + // Cached proxy tiles (TomTom budget spent or upstream failing): the + // manager reads this as STALE. Any error (flow, roads, detail) reads + // DEGRADED instead. + stale: Boolean( + layerState._liveMode && + layerState._flowStale && + !( + layerState._roadError || + layerState._roadWarning || + layerState._detailError || + feed.error + ), + ), + oldestTileAt: layerState._flowOldestTileAt ?? null, error: layerState._roadError || layerState._roadWarning || diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 7135fd8c6..9b37178ba 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) { return layerState._warmFlow; layerState._warmFlowGeneration = generation; const signal = layerState._activeFetchAbort?.signal; - layerState._warmFlow = ensureFlowStatus(signal).then(() => { + layerState._warmFlow = ensureFlowStatus(signal).then(async () => { if (!layerState._liveMode || generation !== layerState._loadGeneration) return []; - return fetchFlowForBounds(clamped, { signal }); + if (typeof source.fetchFlowDetail !== 'function') + return fetchFlowForBounds(clamped, { signal }); + // The proxy serves cached tiles once the TomTom budget is spent or the + // upstream fails; carry that mark so getStats() reads STALE, not LIVE. + const detail = await source.fetchFlowDetail(clamped, { signal }); + if (generation === layerState._loadGeneration) { + layerState._flowStale = detail.stale === true; + layerState._flowOldestTileAt = detail.oldestTileAt ?? null; + } + return detail.segments; }); layerState._warmFlow.catch(() => {}); return layerState._warmFlow; 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..1c53e3ebe 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 = { @@ -43,6 +42,8 @@ export function createLifecycle({ layerState._lastViewCenter = null; layerState._flowRoads = null; layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; if (TRAFFIC_TIMING_ENABLED) { layerState._trafficTimingCurrentAnchor = null; layerState._trafficTimingSequence = 0; @@ -81,7 +82,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 +143,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; @@ -160,6 +161,8 @@ export function createLifecycle({ // A stale outage from the last session would misreport a fresh enable — // the next load re-derives feed health from real evidence. layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; layerState._surfaceFrameRemover?.(); layerState._surfaceFrameRemover = null; diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index 291c06c90..ff1c89ddb 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) { * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. * @param {boolean} [input.fetching] - A viewport load is in flight. * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. + * @param {boolean} [input.stale] - The flow came from cached proxy tiles. * @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100. * @param {boolean} [input.statusUnavailable] - The status probe itself failed. * @param {string} [input.roadSource] - Name of the geometry being drawn. @@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) { liveMode = false, fetching = false, flowError = null, + stale = false, coveragePct = 0, statusUnavailable = false, roadSource = 'OpenStreetMap', @@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) { }); return { mode, error: degraded, loadingLabel: degraded }; } + // Cached proxy tiles (TomTom budget spent or upstream failing) are not live. + if (liveMode && stale && !fetching) { + return { + mode, + error: null, + loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', { + source: roadSource, + }), + }; + } // TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy. if (liveMode && roadSource === 'TomTom') { return { 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/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index a7556e432..1d1cd4986 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated' assert.equal(state._flowPending, 0); }); +test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => { + const { createFlow } = await import('./flow.js'); + const { createModel } = await import('./model.js'); + const { createControls } = await import('./controls.js'); + const { createState } = await import('./state.js'); + const { layerFeedState } = await import('../../data/feedState.js'); + let detail = { + segments: [flow], + partial: false, + stale: true, + oldestTileAt: 1000, + }; + const source = { + fetchFlowDetail: async () => detail, + fetchFlowForBounds: async () => detail.segments, + getFlowSessionStats: () => ({ tilesFetched: 1 }), + }; + const state = createState({ services: {} }); + Object.assign(state, { + _enabled: true, + _liveMode: true, + _loadGeneration: 1, + _flowStatusPromise: Promise.resolve(), + _bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 }, + }); + const parts = {}; + const context = { state, services: { credits: {} }, parts, source }; + parts.model = createModel(context); + parts.flow = createFlow(context); + const controls = createControls(context); + + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1); + const stale = controls.methods.getStats(); + assert.equal(stale.mode, 'live'); + assert.equal(stale.stale, true); + assert.equal(stale.oldestTileAt, 1000); + assert.doesNotMatch(stale.loadingLabel, /LIVE/); + assert.equal(layerFeedState(stale), 'stale'); + + // A road failure outranks cached flow: the row reads the failure. + state._roadError = 'Road geometry unavailable'; + assert.equal(controls.methods.getStats().stale, false); + state._roadError = null; + + detail = { + segments: [flow], + partial: false, + stale: false, + oldestTileAt: 2000, + }; + state._loadGeneration = 2; + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2); + const live = controls.methods.getStats(); + assert.equal(live.stale, false); + assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/); + assert.equal(layerFeedState(live), 'nominal'); +}); + test('status names the drawn road source for each mode', async () => { const { createModel } = await import('./model.js'); const { trafficFeedPresentation: present } = createModel({ 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..ecd34c9ee 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 = {}; @@ -108,6 +108,16 @@ export function createState({ services }) { layerState._flowError = null; + /** + * True while the drawn flow came from cached proxy tiles (TomTom budget + * spent or upstream failing); `_flowOldestTileAt` is the oldest tile's + * fetch time in ms, or null. + * @type {boolean} + */ + + layerState._flowStale = false; + layerState._flowOldestTileAt = null; + /** * True when `/api/tomtom/status` itself could not be reached, so the layer is * simulating because it could not ask — not because the server said "no key". diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index f147f5288..077199855 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._fetching = false; layerState._flowPending = 0; layerState._roadError = null; + // Nothing is drawn up here, cached or not. + layerState._flowStale = false; + layerState._flowOldestTileAt = null; parts.animation.clearDots(); layerState._lastBounds = null; layerState._lastViewCenter = null; 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/lifecycle.js b/src/layers/vessels/lifecycle.js index bb566320b..1c1f611cc 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -11,7 +11,7 @@ export function createLifecycle({ }) { const { state } = vesselState; const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; - const { holdContinuousRender, releaseContinuousRender } = services.render; + const { releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -125,6 +125,7 @@ export function createLifecycle({ state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; + components.rendering.cancelTrailingVisibilityPass(); state.lastVisibilityUpdate = 0; state.lastFocusUpdate = 0; state.activeFocusCount = 0; @@ -164,7 +165,6 @@ export function createLifecycle({ const wasEnabled = state.feed.enabled; state.feed.enabled = true; if (!wasEnabled) beginAisSession(); - holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2) const activeViewer = viewer || state.viewer; components.rendering.ensureCollections(activeViewer); components.selection.installInteraction(activeViewer); @@ -208,6 +208,7 @@ export function createLifecycle({ state.feed.enabled = false; invalidateAisSession(); releaseContinuousRender('ais-vessels'); + components.rendering.cancelTrailingVisibilityPass(); unregisterPickOwner('ais-live-vessels'); components.rendering.setVisible(false); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); @@ -227,6 +228,7 @@ export function createLifecycle({ const activeViewer = viewer || state.viewer; invalidateAisSession(); releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) + components.rendering.cancelTrailingVisibilityPass(); if (state.feed.abort) state.feed.abort.abort(); unregisterPickOwner('ais-live-vessels'); components.selection.clearVesselInspection(); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 28455a293..4ef9b7525 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -29,7 +29,11 @@ function services() { forgetSpriteFocus: noop, }, worldFocus: {}, - render: { releaseContinuousRender: noop }, + render: { + holdContinuousRender: noop, + releaseContinuousRender: noop, + governorRequestRender: noop, + }, }; } function setup(source, options = {}) { 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/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs new file mode 100644 index 000000000..2fec27871 --- /dev/null +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { VISIBILITY_UPDATE_MS } from './policy.js'; + +// Vessels move only when a poll lands, so the layer must let the scene idle: +// it asks for frames for forced passes and one trailing frame after the camera +// stops, and never schedules a frame from a pass the camera did not cause. + +const noop = () => {}; + +function setup() { + let clock = 10_000; + const requests = []; + const holds = new Set(); + const timers = new Map(); + let nextTimer = 1; + const layer = createVesselLayer({ + source: { getSnapshot: async () => ({ records: [] }) }, + services: { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: () => clock, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { + holdContinuousRender: (id) => holds.add(id), + releaseContinuousRender: (id) => holds.delete(id), + governorRequestRender: (reason) => requests.push(reason), + }, + }, + }); + const camera = { + positionWC: { x: 0, y: 0, z: 7_000_000 }, + heading: 0, + pitch: -1.2, + roll: 0, + }; + layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } }); + layer.testing._setAisRuntimeForTest({ + now: () => clock, + setTimeout(fn, ms) { + const id = nextTimer++; + timers.set(id, { fn, at: clock + ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }); + const advance = (ms) => { + clock += ms; + for (const [id, timer] of [...timers]) { + if (timer.at > clock) continue; + timers.delete(id); + timer.fn(); + } + }; + const frame = () => layer.testing._runVisibilityPassForTest(); + return { layer, camera, requests, holds, timers, advance, frame }; +} + +test('a parked camera schedules no frames after the cadence pass', () => { + const { requests, holds, timers, advance, frame } = setup(); + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, []); + assert.equal(timers.size, 0); + assert.equal(holds.has('ais-vessels'), false); +}); + +test('a camera that stops inside the cadence gets exactly one trailing frame', () => { + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + advance(100); + frame(); + assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass'); + assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true); + + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, ['ais-vessels-visibility']); + // The trailing frame runs the regular pass on the pose the camera stopped + // on. Its own frames must not schedule another one. + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, ['ais-vessels-visibility']); + assert.equal(timers.size, 0); +}); + +test('a forced pass requests its own frame', () => { + const { layer, requests } = setup(); + layer.testing._runVisibilityPassForTest(true); + assert.deepEqual(requests, ['ais-vessels-update']); +}); + +test('destroy cancels a pending trailing frame', (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + assert.equal(timers.size, 1); + layer.destroy(); + assert.equal(timers.size, 0); + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, []); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index f29ced5aa..328dd4aee 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -75,7 +75,13 @@ export function createRendering({ focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite, + spriteFocusAtRest, } = services.focus; + const { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + } = services.render; function renderRowLimit() { const configured = Number(options.maxRows); @@ -196,6 +202,14 @@ export function createRendering({ return icon; } + // Vessels move only when a poll lands, so the layer does not hold the + // continuous render loop. Its passes run in preRender, which idle mode fires + // only for rendered frames: a forced pass (new data, selection) requests its + // frame, a frame that moved the camera but fell inside the 800 ms cadence + // leaves a trailing frame so the view the camera stopped on is always + // evaluated, and the continuous hold is kept only while focus emphasis is + // animating. The trailing frame is keyed to camera motion alone: the pass's + // own label publication requests a frame, and scheduling on that would loop. function installRuntime(viewer) { if (state.preRenderRemover || !viewer) return; state.preRenderRemover = viewer.scene.preRender.addEventListener(() => @@ -203,8 +217,27 @@ export function createRendering({ ); } + function scheduleTrailingVisibilityPass(now) { + if (state.trailingVisibilityTimer) return; + const waitMs = Math.max( + 0, + VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate), + ); + state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => { + state.trailingVisibilityTimer = null; + if (state.feed.enabled) governorRequestRender('ais-vessels-visibility'); + }, waitMs); + } + + function cancelTrailingVisibilityPass() { + if (!state.trailingVisibilityTimer) return; + vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer); + state.trailingVisibilityTimer = null; + } + function updateVisibility(force = false) { if (!state.feed.enabled) return; + if (force) governorRequestRender('ais-vessels-update'); const now = focusNowMs(performance.now()); const focusTarget = getFocusTarget(); const regularPass = @@ -212,13 +245,30 @@ export function createRendering({ const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount) && (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS); + if ( + !regularPass && + state.viewer?.camera && + cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig + ) + scheduleTrailingVisibilityPass(now); if (!regularPass && !focusPass) return; if (regularPass) state.lastVisibilityUpdate = now; if (focusPass) state.lastFocusUpdate = now; if (!state.records.all.length) { // No records — flush any lingering card entries (vanished-feed case). - if (regularPass) updateClusteredLabels([]); - if (focusPass) state.activeFocusCount = 0; + // Still record the pose this pass evaluated, or every later frame that + // falls inside the cadence would schedule another trailing frame. + if (regularPass) { + if (state.viewer?.camera) + vesselState._lastCamPoseSig = cameraPoseSignature( + state.viewer.camera, + ); + updateClusteredLabels([]); + } + if (focusPass) { + state.activeFocusCount = 0; + releaseContinuousRender('ais-vessels'); + } return; } @@ -272,6 +322,9 @@ export function createRendering({ Cesium.Cartesian3.distance(camera.positionWC, position), }); state.activeFocusCount = result.activeCount; + if (result.activeCount > 0 || result.transitioning) + holdContinuousRender('ais-vessels'); + else releaseContinuousRender('ais-vessels'); } } @@ -302,6 +355,16 @@ export function createRendering({ const bb = visual?.billboard; const position = bb?.position || visual?.position; if (!bb || !position) continue; + // Most of a worldwide feed sits behind the horizon. A hidden sprite with + // no pending focus state and full alpha would advance to exactly what it + // already is, so skip it instead of paying its distance and state work + // every 80 ms while anything is tracked. + if ( + bb.show === false && + spriteFocusAtRest(bb) && + !focusAlphaNeedsWrite(bb.color?.alpha, 1, params) + ) + continue; const focus = advanceSpriteFocus(bb, { // Hidden/far-side sprites still finish any pending release so the active // count remains truthful and they cannot reappear with stale dim alpha. @@ -510,6 +573,7 @@ export function createRendering({ shipIcon, installRuntime, updateVisibility, + cancelTrailingVisibilityPass, applyVesselFocusDeemphasis, makeOccluder, isVisible, 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/vessels/state.js b/src/layers/vessels/state.js index d0b8afb18..3ccaf08d9 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -64,6 +64,8 @@ export function createVesselState({ source, services }) { /** Test-only key target paired with interactionHandlerFactory. */ interactionKeyTarget: null, preRenderRemover: null, + /** Pending timer that renders one frame after a cadence-skipped pass. */ + trailingVisibilityTimer: null, lastVisibilityUpdate: 0, lastFocusUpdate: 0, /** Sprites whose animated emphasis remains outside the 1.0 deadband. */ diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index dc11f3003..6cab17086 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -151,6 +151,18 @@ export function createTesting({ }; } + /** Run one preRender visibility/focus pass. Test-only seam. */ + + function _runVisibilityPassForTest(force = false) { + components.rendering.updateVisibility(force); + } + + /** True while a trailing visibility frame is scheduled. Test-only seam. */ + + function _hasTrailingVisibilityPassForTest() { + return state.trailingVisibilityTimer !== null; + } + /** * Read lifecycle ownership state without exposing the mutable state object. * Test-only seam. @@ -176,5 +188,7 @@ export function createTesting({ _setAisRuntimeForTest, _getVesselFeedStateForTest, _getVesselStateForTest, + _runVisibilityPassForTest, + _hasTrailingVisibilityPassForTest, }; } 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.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..1fcfdd8dc 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -114,7 +114,6 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([ '.hud-top-right', '.hud-bottom-left', '.hud-bottom-right', - '.hud-top-bar', '.hud-bottom-bar', '#space-mission-panel', '#space-mission-panel-host', @@ -1155,11 +1154,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 +1172,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..b906ecf67 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(); @@ -169,11 +166,6 @@ test('Cockpit utility Escape leaves an expanded nested Parameters panel to the s /const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/, 'Cockpit utility disclosures clear their own focus when Escape closes them', ); - assert.match( - radioBindings, - /_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/, - 'compact Radio disclosure clears its own focus when Escape closes it', - ); }); 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..1774b1f43 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', @@ -205,10 +206,53 @@ test('no unchanged Realtime tool definition drifts silently', () => { .sort((a, b) => a.name.localeCompare(b.name)) .map((tool) => structuredClone(tool)) .filter((tool) => tool.name !== 'set_cyber_sonar'); - // Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field. + // Cyber adds one HUD choice and the sonar tool, and Operator was retired; + // retain the existing pin for every legacy field. 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'); + assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']); + hudLayout.enum = ['tactical', 'operator', 'minimal']; + // 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.'; + // Snow is retired from set_visual_style; restore it to keep the legacy pin. + const visualStyle = unchanged.find( + (tool) => tool.name === 'set_visual_style', + ).parameters.properties.style; + assert.deepEqual(visualStyle.enum, [ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + ]); + visualStyle.enum = [...visualStyle.enum, 'snow']; assert.equal(unchanged.length, 14); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) @@ -219,7 +263,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { - for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) { + for (const id of ['cockpit-radio-volume', 'radio-volume', 'sdr-volume']) { assert.match( html, new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`), @@ -239,22 +283,17 @@ test('Radio volume and mission speed share the Sharpen slider visual language', assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/); }); -test('Radio is nested inside Context with separate disclosure and power controls', () => { +test('Radio is nested inside Context with its own disclosure and power controls', () => { const contextStart = html.indexOf('id="global-context-panel"'); const radioStart = html.indexOf('id="radio-panel"'); const contextEnd = html.indexOf('\n ', contextStart); assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd); assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/); assert.match(html, /aria-label="Radio playback"/); - assert.match(html, /id="context-radio-toggle-btn"[^>]*aria-expanded="false"[^>]*aria-controls="context-radio-mini"/); - assert.doesNotMatch(html, /id="context-radio-toggle-btn"[^>]*aria-pressed=/); - assert.match(html, /id="context-radio-mini"[^>]*aria-label="Compact Radio controls"[^>]*hidden/); - assert.match(html, /id="context-radio-mini-enable-btn"[^>]*aria-pressed="false"/); - assert.match(html, /id="context-radio-details-btn"[^>]*aria-expanded="false"[^>]*aria-controls="radio-panel"/); - assert.match(html, /id="context-radio-details-btn"[\s\S]*?
diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html index 290726417..d7f55edd2 100644 --- a/src/ui/templates/context.html +++ b/src/ui/templates/context.html @@ -30,36 +30,6 @@
CONTEXT -
- - -
@@ -121,8 +91,8 @@
- +
@@ -180,7 +150,7 @@
- @@ -221,7 +191,7 @@
- +
diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index 220dc373b..458a2fcd8 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -13,9 +13,8 @@
Layout - - @@ -27,27 +26,27 @@
- + 8
- + 100%
- + 70%
- + 84%
- + 24°
@@ -58,24 +57,24 @@
- + 50%
Allocation -
- - +
+ +
- + 7%
- + 1%
@@ -99,26 +98,22 @@
Models -
- - +
+ +
- + +
Feather - - - 11% + + 100%
@@ -128,15 +123,15 @@
Shape -
- - - +
+ + +
- @@ -172,7 +167,7 @@ Bloom
- + 0%
@@ -182,7 +177,7 @@ Sharpen
- + 49%
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 363cfe0af..5f5d11b4d 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 @@
-
-