mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # DATA_SOURCES.md # docs/CURRENT-STATE.md # server/providers/cctv/constants.js # server/providers/cctv/sources.js
This commit is contained in:
commit
6f054408da
71 files changed
+5228
-181
No files matched your search
+5
-1
@@ -18,7 +18,8 @@
|
||||
# (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
||||
# them (see SECURITY.md). All other keys below stay server-side.
|
||||
|
||||
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
|
||||
# Optional: direct Google Photorealistic 3D Tiles, GEV place search and Street
|
||||
# View (Street View also needs the Maps JavaScript API enabled for the key).
|
||||
# Without it, a Cesium ion token can still load ion-hosted Google 3D; with
|
||||
# neither credential, the app starts on keyless Esri World Imagery with OSM
|
||||
# available in the map tray and as the automatic provider-failure fallback.
|
||||
@@ -216,6 +217,9 @@ AISSTREAM_API_KEY=
|
||||
# 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 ([email protected]) for the camera feed
|
||||
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
|
||||
# CCTV_NYC_CAMERAS_URL= # override the NYC TMC 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
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
- Add New York City traffic cameras from the NYC DOT Traffic Management Center
|
||||
as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the
|
||||
camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an
|
||||
operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's
|
||||
requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to
|
||||
`webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each
|
||||
camera UUID on that same host, so a catalog field cannot steer the proxy
|
||||
off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest
|
||||
the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is
|
||||
unchanged; extra cameras share it round-robin with the other packs.
|
||||
|
||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||
|
||||
@@ -95,6 +106,19 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Street Level: Google Street View is the second provider. On the Google 3D
|
||||
map (Google's terms keep it off other maps), light its chip (off by
|
||||
default: every panorama is billed to the Google Maps key, which needs the
|
||||
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
|
||||
(`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
|
||||
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
|
||||
is a Google-blue view cone.
|
||||
|
||||
- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
|
||||
Settings. API-key voice remains the default. Local sign-in can start from
|
||||
the auth button and reports completion, failure, timeout, or expired
|
||||
|
||||
@@ -21,6 +21,7 @@ How to read this:
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
@@ -57,6 +58,7 @@ How to read this:
|
||||
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
|
||||
| **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" |
|
||||
| **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) |
|
||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
|
||||
| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
|
||||
@@ -93,6 +95,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **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`.
|
||||
- **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/<cameraId>`, 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/<cameraId>/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 (~6 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) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first 429, 5xx or network failure stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 10 min; bounded to 1 min–1 h). The lane waits at most 12 s, counted from the start of the loader; a slower lookup keeps filling the cache for the next refresh. 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 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 `[email protected]` 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/<uuid>/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.
|
||||
- **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 seven 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.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
|
||||
@@ -300,9 +300,9 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **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** | ~3,900 public cameras 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). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~3,900 public cameras 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), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). 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 first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary and Google Street View; Panoramax and KartaView planned | Mapillary · Google | 🟡 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 | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
@@ -340,13 +340,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
## 📷 Street Level
|
||||
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token.
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token) and **Google Street View** (your Google Maps key).
|
||||
|
||||
**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**.
|
||||
**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.
|
||||
|
||||
**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). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND**, **FIT / FILL**, 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 "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on.
|
||||
**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 other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; 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. 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 **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").
|
||||
|
||||
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.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`.
|
||||
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.r.google-1` Street View on, `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
|
||||
|
||||
@@ -434,7 +434,7 @@ Seven keys. Five have a free tier, and the two 🔴 ones are metered:
|
||||
| | Key | Why | Get it |
|
||||
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
|
||||
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
||||
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D, Google place search and Street View ([Map Tiles API](https://developers.google.com/maps/documentation/tile); Street View also needs the Maps JavaScript API) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
||||
| 🔴 | **OpenAI** | 🎙️ Voice + AI HUD summary through an API key. Provider Settings also offers **USE CHATGPT OAUTH** for cloud voice without removing the API-key option | [platform.openai.com](https://platform.openai.com) — API-key usage is metered, see costs below |
|
||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||
|
||||
+4
-1
@@ -27,7 +27,7 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
|
||||
|
||||
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
|
||||
|
||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **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.
|
||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search and 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.
|
||||
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.
|
||||
|
||||
@@ -74,6 +74,9 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **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 log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
- **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, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
||||
|
||||
|
||||
+3
-2
@@ -20,8 +20,9 @@ import { panelBuildPlugin } from './panel.js';
|
||||
export const BROWSER_CSP = [
|
||||
"default-src 'self'",
|
||||
// The Bhote Koshi event's embedded media: the YouTube player API, the
|
||||
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js).
|
||||
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com",
|
||||
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js);
|
||||
// and the Maps JavaScript API that Street Level's Google Street View runs on.
|
||||
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com https://maps.googleapis.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob: https:",
|
||||
|
||||
+94
-7
@@ -914,6 +914,24 @@ owners. Tuner calculations have a pure entry, with existing layer exports
|
||||
preserved. Destruction removes listeners and state subscriptions before ending
|
||||
tuning; a delayed Enable result cannot reveal or refocus removed controls.
|
||||
|
||||
## NYC DOT TMC CCTV source pack
|
||||
|
||||
The CCTV catalog can add New York City traffic cameras from the NYC DOT
|
||||
Traffic Management Center. NYC DOT requires developers to sign a data-sharing
|
||||
agreement for the camera feed, so this is the one opt-in pack: it loads only
|
||||
when `CCTV_NYC_ENABLED=1`, and with the flag unset the server makes no NYC
|
||||
request. NYC DOT's requested disclaimer is in the Data attribution popover.
|
||||
When enabled, the server reads the keyless
|
||||
camera list at `https://webcams.nyctmc.org/api/cameras` and keeps cameras the
|
||||
feed marks online. Frame URLs are built from each camera UUID under
|
||||
`https://webcams.nyctmc.org/api/cameras/<uuid>/image`; they are never taken
|
||||
from the payload. There is no heading in the feed, so gizmos use the shared
|
||||
id-hash fallback at low confidence.
|
||||
|
||||
Once enabled, the 250 cameras nearest the five borough cores load.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
|
||||
round-robin, so adding this pack does not silently drop another region.
|
||||
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
@@ -1092,6 +1110,19 @@ normal fallback chain. The existing declared media size ceiling remains 64 MiB.
|
||||
|
||||
## GBFS upstream bounds
|
||||
|
||||
Station information is normalized in `src/sources/gbfsStations.js` for both the
|
||||
globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized
|
||||
String arrays regardless of the reported feed version. Nonempty English text
|
||||
(including regional `en-*` tags, case-insensitive) takes precedence, then the first
|
||||
usable translation. An unusable `name` falls back to `short_name` with the same
|
||||
rules; if neither supplies text, the station remains available with an empty name
|
||||
(the globe uses its station-ID label and the tool returns `null`). Malformed
|
||||
translation entries do not interrupt loading of the other stations. This covers
|
||||
station names, not complete GBFS v3 support.
|
||||
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
|
||||
station selection, keyboard dismissal, mobile cards and bike-share tool against
|
||||
either a dev or preview server (`--url` selects the target).
|
||||
|
||||
The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
|
||||
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
|
||||
streaming. The 12-second deadline includes reading the body, and rejected or
|
||||
@@ -1184,6 +1215,10 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
|
||||
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||
access. Callers retain validation, transport and response policy.
|
||||
|
||||
The shared TLE text parser skips incomplete named sets and resumes at the next
|
||||
complete set. A missing orbital line does not discard the later satellites in
|
||||
the catalog; satellite layers and next-pass tools use the same parser.
|
||||
|
||||
## Build configuration and local provider boundaries
|
||||
|
||||
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
|
||||
@@ -3054,7 +3089,7 @@ its criteria cannot be silently ignored.
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + NYC DOT TMC (opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
@@ -3078,8 +3113,9 @@ shared 360°/flat and captured-since filters (relative days, so a link keeps its
|
||||
meaning), one viewer host and one on-globe credit per active provider. Each
|
||||
provider draws in one colour (`PROVIDER_COLORS` in
|
||||
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
|
||||
panoramas), `s` (since, days). Only Mapillary is registered. Without
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary and Google
|
||||
Street View are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
|
||||
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
@@ -3103,8 +3139,15 @@ miss cooldown. Roads on elevated decks are still drawn at
|
||||
ground level. Framing a photo ignores mesh samples far below the bare earth
|
||||
(unloaded tiles).
|
||||
|
||||
The header pill is the layer switch. With one provider its chip is a layer
|
||||
switch too; with several, darkening the last lit chip turns the layer off. The
|
||||
The header pill is the layer switch. Lighting a chip swaps the source;
|
||||
clicking the lit chip turns the layer off. A chip without a usable key
|
||||
explains the key instead of switching, and the other chips keep switching.
|
||||
Lit off its map stack, a provider leaves the panel saying which map it
|
||||
needs. When the switched-on providers lack
|
||||
their keys but a switched-off one would draw with a key already set (no
|
||||
Mapillary token, Street View on Google 3D with a Google key), the layer
|
||||
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
||||
line and the layer row's tooltip say to light it. The
|
||||
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
||||
stops when the stack changes. Camera moves go through the application's
|
||||
@@ -3125,8 +3168,52 @@ manages each provider's credit and fans the filter out to every provider. To
|
||||
add a provider: implement the definition, register it in
|
||||
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
|
||||
group in `src/data/layerState.js` and its modules to
|
||||
`scripts/package-boundaries.json`. Once a keyless provider registers, the
|
||||
layer's `requiresKeyId` becomes null and the key gate moves to the chips.
|
||||
`scripts/package-boundaries.json`. With providers on different keys the
|
||||
layer's `requiresKeyId` is null and each chip reports its own key.
|
||||
|
||||
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
||||
the browser `GOOGLE_MAPS_API_KEY`, which needs that API enabled; it is loaded
|
||||
on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
|
||||
Its chip starts off (`defaultOn: false`; a link without its switch keeps it off) because every
|
||||
panorama load is billed to the key. Google's terms forbid Street View beside
|
||||
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
|
||||
off Google 3D it does not appear at all (no chip, legend swatch, globe
|
||||
credit, layer source or button, and Google's script is not loaded), lookups
|
||||
and opens skip it, and leaving Google 3D closes its panorama; the share
|
||||
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
|
||||
for other maps, so it draws none: with it on, a click on the map itself
|
||||
(`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, so a
|
||||
Mapillary line under the pointer wins; no tool holding the pointer; camera
|
||||
below 3 km) opens its nearest Google-collected outdoor panorama within 50 m
|
||||
and leaves the camera where it is (`groundClick.js`). Higher up the hint
|
||||
says to zoom in. **OPEN STREET VIEW** beside that hint (`#sl-nearest-btn`)
|
||||
does the same at the view centre, so Street View needs no pointer; it is
|
||||
disabled until the camera is at street zoom (and aimed at the sky, it opens
|
||||
nothing and says to aim at a street). One provider is lit at a time:
|
||||
`setProviderEnabled(id, true)` darkens the others, a chip click sends every
|
||||
switch in one update so the share link records the swap, and params that
|
||||
light several keep the one already on, else the first in chip order
|
||||
(`litByParams`; reapplying them never flips). Mapillary, free, is lit by
|
||||
default. The chip reads GOOGLE STREET VIEW, and an open panorama's globe
|
||||
marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
|
||||
of GEV's amber pin. Its
|
||||
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
|
||||
(a provider's `filters` default to both `pano` and `since`): lookups ignore
|
||||
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
|
||||
honours (`filterControls`), and the legend lists only providers that draw
|
||||
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
|
||||
element in the viewer host, without Google's fullscreen, close and address
|
||||
controls (the address goes in the caption); a pose goes out at once and
|
||||
again when the panorama's month, photographer and address arrive (one
|
||||
successful lookup per panorama per session, seeded by the nearest lookup;
|
||||
a details lookup that fails or takes over ten seconds waits five seconds
|
||||
before a view event can retry, and an unmount drops lookups in flight). A key
|
||||
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
|
||||
not allow, billing off) reads KEY REJECTED and fails a pending open at
|
||||
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
|
||||
(no Google key, no Google 3D) the gate checks that nothing of Street View
|
||||
shows off Google 3D, even switched on by a share link; the click and button
|
||||
step skips as `no Google key`.
|
||||
|
||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
||||
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
|
||||
"./tools": "./src/tools/index.js",
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
|
||||
@@ -532,6 +532,7 @@
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
@@ -897,6 +898,17 @@
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/layers/streetLevel/groundClick.js",
|
||||
"src/layers/streetLevel/providers/google/index.js",
|
||||
"src/layers/streetLevel/providers/google/policy.js",
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/groundClick.test.mjs",
|
||||
"src/app/layers/streetLevel.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/viewer.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
|
||||
"src/testSupport/googleStreetViewFakes.mjs",
|
||||
"server/providers/mapillary.js",
|
||||
"server/providers/mapillary/routes.js",
|
||||
"server/providers/mapillary/constants.js",
|
||||
@@ -968,5 +980,7 @@
|
||||
"src/tools/views.test.mjs",
|
||||
"src/app/embed.test.mjs",
|
||||
"src/tools/globePanel.test.mjs",
|
||||
"src/costEndpointThrottle.test.mjs"
|
||||
"src/costEndpointThrottle.test.mjs",
|
||||
"src/sources/gbfsStations.test.mjs",
|
||||
"scripts/qa-gbfs-names.mjs"
|
||||
]
|
||||
@@ -1194,7 +1194,8 @@
|
||||
"exports": [
|
||||
"./layers/street-level",
|
||||
"./layers/street-level/mapillary",
|
||||
"./layers/street-level/mapillary/source"
|
||||
"./layers/street-level/mapillary/source",
|
||||
"./layers/street-level/google"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/streetLevel/index.js",
|
||||
@@ -1223,6 +1224,11 @@
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/groundClick.js",
|
||||
"src/layers/streetLevel/providers/google/index.js",
|
||||
"src/layers/streetLevel/providers/google/policy.js",
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"scripts/qa-street-level.mjs"
|
||||
],
|
||||
@@ -2178,6 +2184,11 @@
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/groundClick.js",
|
||||
"src/layers/streetLevel/providers/google/index.js",
|
||||
"src/layers/streetLevel/providers/google/policy.js",
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
@@ -2704,6 +2715,11 @@
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/groundClick.js",
|
||||
"src/layers/streetLevel/providers/google/index.js",
|
||||
"src/layers/streetLevel/providers/google/policy.js",
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keyless station-name regression against the real globe and bike-share tool.
|
||||
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
|
||||
* GBFS documents are synthetic; no operator feed or provider key is required.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const urlIndex = process.argv.indexOf('--url');
|
||||
const appUrl =
|
||||
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
|
||||
const shots = path.resolve('qa-shots/gbfs-names');
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const information = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'english',
|
||||
name: [
|
||||
{ language: 'hu', text: 'Magyar tér' },
|
||||
{ language: 'en', text: 'QA English Square' },
|
||||
],
|
||||
},
|
||||
{
|
||||
station_id: 'short',
|
||||
name: [null, { language: 'en', text: ' ' }],
|
||||
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
|
||||
},
|
||||
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
|
||||
{ station_id: 'legacy', name: ' QA Legacy Station ' },
|
||||
{
|
||||
station_id: 'fallback',
|
||||
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
|
||||
},
|
||||
].map((station, index) => ({
|
||||
...station,
|
||||
lat: 30.2681 + index * 0.001,
|
||||
lon: -97.7469 + index * 0.001,
|
||||
capacity: 13,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const status = {
|
||||
data: {
|
||||
stations: information.data.stations.map(({ station_id }) => ({
|
||||
station_id,
|
||||
num_bikes_available: 4,
|
||||
num_docks_available: 9,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(process.env.PUPPETEER_EXECUTABLE_PATH
|
||||
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
|
||||
: {}),
|
||||
args:
|
||||
process.platform === 'darwin'
|
||||
? ['--use-angle=metal']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
let checks = 0;
|
||||
const check = (name, actual, expected) => {
|
||||
assert.deepEqual(actual, expected, name);
|
||||
checks++;
|
||||
console.log(`[PASS] ${name}`);
|
||||
};
|
||||
async function open(mode = 'normal') {
|
||||
const page = await browser.newPage();
|
||||
await page.bringToFront();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(
|
||||
({ information, status, mode }) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__qaPainted = [];
|
||||
const paint = CanvasRenderingContext2D.prototype.fillText;
|
||||
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
|
||||
if (
|
||||
String(text).startsWith('QA ') ||
|
||||
String(text) === 'Station unnamed'
|
||||
) {
|
||||
window.__qaPainted.push(String(text));
|
||||
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
|
||||
}
|
||||
return paint.call(this, text, ...args);
|
||||
};
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const url = String(
|
||||
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||
);
|
||||
if (url.includes('/api/gbfs/')) {
|
||||
if (mode === 'failure') return new Response('{}', { status: 503 });
|
||||
const payload =
|
||||
mode === 'empty'
|
||||
? { data: { stations: [] } }
|
||||
: decodeURIComponent(url).includes('station_information')
|
||||
? information
|
||||
: status;
|
||||
return new Response(JSON.stringify(payload), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/flights')) return new Response('{"states":[]}');
|
||||
if (url.includes('/api/military')) return new Response('{"ac":[]}');
|
||||
if (url.includes('/api/vessels'))
|
||||
return new Response('{"connected":false,"vessels":[]}');
|
||||
return original(input, init);
|
||||
};
|
||||
},
|
||||
{ information, status, mode },
|
||||
);
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.getElementById('loading-screen') ||
|
||||
(document.getElementById('loading-screen').classList.contains('hidden') &&
|
||||
getComputedStyle(document.getElementById('loading-screen')).opacity ===
|
||||
'0'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, viewer } = window.__godsEyeView;
|
||||
for (const [id, layer] of dataManager.layers) {
|
||||
if (layer.enabled)
|
||||
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
}
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||
window.__godsEyeView.requestRender('gbfs-name-qa');
|
||||
});
|
||||
return { page, errors };
|
||||
}
|
||||
try {
|
||||
const { page, errors } = await open();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
|
||||
.count === 5,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const records = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getAnalystRecords(),
|
||||
);
|
||||
check(
|
||||
'the layer retains all five stations and normalized names',
|
||||
records.map((r) => r.name),
|
||||
[
|
||||
'QA English Square',
|
||||
'QA Short Name',
|
||||
null,
|
||||
'QA Legacy Station',
|
||||
'QA Magyar Station',
|
||||
],
|
||||
);
|
||||
const tool = await page.evaluate(() =>
|
||||
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
|
||||
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
|
||||
}),
|
||||
);
|
||||
check('the real bike-share tool succeeds', tool.ok, true);
|
||||
check(
|
||||
'the tool preserves localized, short, unnamed, legacy and non-English names',
|
||||
tool.data.rows.map((r) => r.name).sort(),
|
||||
[
|
||||
null,
|
||||
'QA English Square',
|
||||
'QA Short Name',
|
||||
'QA Legacy Station',
|
||||
'QA Magyar Station',
|
||||
].sort(),
|
||||
);
|
||||
check(
|
||||
'availability remains attached to every station',
|
||||
tool.data.rows.every(
|
||||
(r) => r.bikes_available === 4 && r.docks_available === 9,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const clickPoint = await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const point = viewer.scene.cartesianToCanvasCoordinates(
|
||||
Cartesian3.fromDegrees(-97.7469, 30.2681),
|
||||
);
|
||||
const rect = viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: rect.left + point.x, y: rect.top + point.y };
|
||||
});
|
||||
await page.mouse.click(clickPoint.x, clickPoint.y);
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
check(
|
||||
'a real pointer click selects the station with its localized name',
|
||||
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
for (const [id, title] of [
|
||||
['english', 'QA English Square'],
|
||||
['short', 'QA Short Name'],
|
||||
['unnamed', 'Station unnamed'],
|
||||
]) {
|
||||
await page.evaluate((id) => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
|
||||
app.requestRender('gbfs-name-selection-qa');
|
||||
}, id);
|
||||
await page.waitForFunction(
|
||||
(title) => window.__qaPainted.includes(title),
|
||||
{ timeout: 10000 },
|
||||
title,
|
||||
);
|
||||
check(
|
||||
`the selected ${id} card paints the expected title`,
|
||||
await page.evaluate((title) => window.__qaPainted.includes(title), title),
|
||||
true,
|
||||
);
|
||||
await page.screenshot({ path: path.join(shots, `${id}.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.every((record) => !record.skipLabel),
|
||||
);
|
||||
check(
|
||||
`Escape clears the ${id} card`,
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.filter((record) => record.skipLabel).length,
|
||||
),
|
||||
0,
|
||||
);
|
||||
}
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||
app.styleManager.toggleCleanView(true);
|
||||
const Cartesian3 = app.viewer.camera.position.constructor;
|
||||
app.viewer.camera.cancelFlight();
|
||||
app.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module._selectBikeshareStationForTest('austin-capmetro:english');
|
||||
app.requestRender('gbfs-name-mobile-qa');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
check(
|
||||
'the mobile selected card paints its normalized name',
|
||||
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
|
||||
true,
|
||||
);
|
||||
await page.screenshot({ path: path.join(shots, 'mobile.png') });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'disabling clears selected cards and analyst records',
|
||||
await page.evaluate(() => ({
|
||||
count: window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getAnalystRecords().length,
|
||||
selected: window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.filter((record) => record.skipLabel).length,
|
||||
})),
|
||||
{ count: 0, selected: 0 },
|
||||
);
|
||||
check('normal fixtures produce no page exceptions', errors, []);
|
||||
await page.close();
|
||||
for (const mode of ['empty', 'failure']) {
|
||||
const { page, errors } = await open(mode);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().loading,
|
||||
);
|
||||
check(
|
||||
`${mode} leaves no rendered stations`,
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count,
|
||||
),
|
||||
0,
|
||||
);
|
||||
check(`${mode} produces no page exceptions`, errors, []);
|
||||
await page.close();
|
||||
}
|
||||
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -381,11 +381,36 @@ async function main() {
|
||||
);
|
||||
await nextFrames(page);
|
||||
};
|
||||
await drag(handlePoint(await readPanel(), 'se'), 0, 200);
|
||||
const chosen = await readPanel();
|
||||
// Grow it from the corner once the restored window holds still, with the
|
||||
// pointer really on the handle (a press beside it selects page text).
|
||||
let chosen = await readPanel();
|
||||
const tried = [];
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
|
||||
attempt++
|
||||
) {
|
||||
await settle();
|
||||
const box = await readPanel();
|
||||
const corner = handlePoint(box, 'se');
|
||||
const hit = await page.evaluate(({ x, y }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
return {
|
||||
dir: node?.dataset?.dir || null,
|
||||
target: node
|
||||
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
|
||||
: 'nothing',
|
||||
};
|
||||
}, corner);
|
||||
tried.push(
|
||||
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
|
||||
);
|
||||
if (hit.dir === 'se') await drag(corner, 0, 200);
|
||||
chosen = await readPanel();
|
||||
}
|
||||
assert.ok(
|
||||
chosen.height > MIN_SIZE.height + 150,
|
||||
`the window grew to ${chosen.height}px`,
|
||||
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
|
||||
);
|
||||
await setCollapsed(true);
|
||||
const strip = await readPanel();
|
||||
|
||||
+317
-12
@@ -49,18 +49,27 @@ export const VIEWPORTS = Object.freeze([
|
||||
]);
|
||||
|
||||
/** Provider chips the panel must show, in order. */
|
||||
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
|
||||
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']);
|
||||
|
||||
/**
|
||||
* Skips no setup can avoid: CI forks have no Google key, and live runs cannot
|
||||
* stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy
|
||||
* token runs the keyed flow, so a keyless strict run means that wiring broke.
|
||||
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
|
||||
* nor Street View), and live runs cannot stage fixture-only cases. `no
|
||||
* Mapillary key` is not accepted: CI's dummy token runs the keyed flow, so a
|
||||
* keyless strict run means that wiring broke. Nor is `Google key refused`: a
|
||||
* key that is set must work.
|
||||
*/
|
||||
export const STRICT_ALLOWED_SKIPS = Object.freeze([
|
||||
'no Google 3D',
|
||||
'no Google key',
|
||||
'fixtures only',
|
||||
]);
|
||||
|
||||
/** A Sacramento street with Google Street View (10th St, by the Capitol). */
|
||||
export const STREET_VIEW_STREET = Object.freeze({
|
||||
lon: -121.49372,
|
||||
lat: 38.57986,
|
||||
});
|
||||
|
||||
export function strictViolations(skips) {
|
||||
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
|
||||
}
|
||||
@@ -619,7 +628,8 @@ async function main() {
|
||||
}
|
||||
await page.setViewport(VIEWPORTS[0]);
|
||||
const expandStrip = async (target = page) => {
|
||||
await target.click(
|
||||
await clickPanelControl(
|
||||
target,
|
||||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||||
);
|
||||
assert.ok(
|
||||
@@ -649,14 +659,30 @@ async function main() {
|
||||
),
|
||||
].map((chip) => chip.dataset.chipId),
|
||||
);
|
||||
assert.deepEqual(chips, EXPECTED_PROVIDERS);
|
||||
// One swatch per source plus "Selected"; each chip wears its colour.
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.querySelectorAll('#sl-legend li').length,
|
||||
),
|
||||
EXPECTED_PROVIDERS.length + 1,
|
||||
// Every registered provider usable on this map, in order (Street View
|
||||
// only on Google 3D).
|
||||
const listed = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState()
|
||||
.providers.map((provider) => provider.id),
|
||||
);
|
||||
assert.deepEqual(chips, listed);
|
||||
assert.deepEqual(
|
||||
EXPECTED_PROVIDERS.filter((id) => listed.includes(id)),
|
||||
listed,
|
||||
);
|
||||
assert.ok(listed.includes('mapillary'));
|
||||
// One swatch per switched-on source plus "Selected" (Street View
|
||||
// starts off); each chip wears its colour.
|
||||
const legend = await page.evaluate(() => ({
|
||||
swatches: document.querySelectorAll('#sl-legend li').length,
|
||||
on: window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState()
|
||||
.providers.filter((provider) => provider.on).length,
|
||||
}));
|
||||
assert.equal(legend.swatches, legend.on + 1);
|
||||
assert.equal(
|
||||
await page.$eval('[data-chip-id="mapillary"]', (chip) =>
|
||||
chip.style.getPropertyValue('--chip-color'),
|
||||
@@ -1721,6 +1747,285 @@ async function main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
const googleChip = '#sl-provider-chips [data-chip-id="google"]';
|
||||
const googleKey = await page.evaluate(() =>
|
||||
Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||
);
|
||||
await step(
|
||||
'Street View starts off, appears only on the Google 3D map, and travels in the share link',
|
||||
async () => {
|
||||
const module = () =>
|
||||
window.__godsEyeView.dataManager.layers.get('street-level').module;
|
||||
assert.equal(
|
||||
await page.evaluate(`(${module})().getParams().google`),
|
||||
false,
|
||||
'off until lit: panoramas are billed',
|
||||
);
|
||||
const setStreetView = (on) =>
|
||||
page.evaluate(
|
||||
(value) =>
|
||||
window.__godsEyeView.dataManager.setLayerParams(
|
||||
'street-level',
|
||||
{ google: value },
|
||||
{ origin: 'user' },
|
||||
),
|
||||
on,
|
||||
);
|
||||
const shown = () =>
|
||||
page.evaluate(() => ({
|
||||
chip: Boolean(
|
||||
document.querySelector(
|
||||
'#sl-provider-chips [data-chip-id="google"]',
|
||||
),
|
||||
),
|
||||
legend: [...document.querySelectorAll('#sl-legend li')].some(
|
||||
(item) => /Street View/.test(item.textContent),
|
||||
),
|
||||
credit: document.body.innerHTML.includes('Street View imagery'),
|
||||
button: !document.getElementById('sl-nearest-btn').hidden,
|
||||
}));
|
||||
const original = await stacks.active();
|
||||
// Off Google 3D (CI's only map) nothing of Street View shows, even
|
||||
// switched on: Google's terms keep it off other maps.
|
||||
if (original === 'photoreal') await stacks.set('esri-imagery');
|
||||
await setStreetView(true);
|
||||
await uiUntil((u) => u.providers.every((p) => p.id !== 'google'));
|
||||
assert.ok(
|
||||
await settle(
|
||||
page,
|
||||
() => !document.body.innerHTML.includes('Street View imagery'),
|
||||
),
|
||||
'no Google credit on another map',
|
||||
);
|
||||
assert.deepEqual(await shown(), {
|
||||
chip: false,
|
||||
legend: false,
|
||||
credit: false,
|
||||
button: false,
|
||||
});
|
||||
// Street View rides the shared provider-switch field (r).
|
||||
const option = `${STREET_LEVEL_TOKEN}.r.google-1`;
|
||||
assert.ok(
|
||||
await settle(
|
||||
page,
|
||||
(token) =>
|
||||
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
||||
.split('_')
|
||||
.includes(token),
|
||||
option,
|
||||
// The link is written once the view rests for 500 ms; under a
|
||||
// software renderer with a photo open that can take a while.
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
`the share link keeps the choice: ${option}`,
|
||||
);
|
||||
if (await stacks.photoreal()) {
|
||||
await stacks.set('photoreal');
|
||||
await uiUntil((u) => u.providers.some((p) => p.id === 'google'));
|
||||
const chipNow = await page.$eval(googleChip, (node) => ({
|
||||
className: node.className,
|
||||
title: node.title,
|
||||
color: node.style.getPropertyValue('--chip-color'),
|
||||
}));
|
||||
assert.equal(chipNow.color, '#4285f4');
|
||||
if (googleKey) assert.match(chipNow.className, /chip-active/);
|
||||
else {
|
||||
assert.match(chipNow.className, /chip-error/);
|
||||
assert.match(chipNow.title, /GOOGLE_MAPS_API_KEY/);
|
||||
}
|
||||
assert.ok(
|
||||
await settle(page, () =>
|
||||
document.body.innerHTML.includes('Street View imagery'),
|
||||
),
|
||||
'the Google credit shows on Google 3D',
|
||||
);
|
||||
}
|
||||
// Back to Mapillary (one source at a time), as the next steps expect.
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setLayerParams(
|
||||
'street-level',
|
||||
{ mapillary: true, google: false },
|
||||
{ origin: 'user' },
|
||||
),
|
||||
);
|
||||
await stacks.set(original);
|
||||
await uiUntil(
|
||||
(u) => u.providers.find((p) => p.id === 'google')?.on !== true,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'on Google 3D a click on a street opens Google Street View where it is, captioned and linked, and its view drives the pose',
|
||||
async ({ skip }) => {
|
||||
if (!googleKey) return skip('no Google key');
|
||||
// Google's terms: Street View only beside Google's own map.
|
||||
if (!(await stacks.photoreal())) return skip('no Google 3D');
|
||||
const original = await stacks.active();
|
||||
const closePhoto = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.closeViewer(),
|
||||
);
|
||||
const setOn = async (selector, id, on) => {
|
||||
const now = (await ui()).providers.find((p) => p.id === id)?.on;
|
||||
if (now === on) return;
|
||||
await clickPanelControl(page, selector);
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, want) =>
|
||||
u.providers.find((p) => p.id === want.id)?.on === want.on,
|
||||
{ id, on },
|
||||
),
|
||||
`the ${id} chip switched ${on ? 'on' : 'off'}`,
|
||||
);
|
||||
};
|
||||
await closePhoto();
|
||||
await stacks.set('photoreal');
|
||||
// A Mapillary line under the pointer wins the click: aim at bare
|
||||
// ground with only Street View on (lit first: the last lit chip
|
||||
// would switch the layer off).
|
||||
await setOn(googleChip, 'google', true);
|
||||
await setOn(chip, 'mapillary', false);
|
||||
try {
|
||||
const street = STREET_VIEW_STREET;
|
||||
await page.evaluate((at) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(at.lon, at.lat, 300),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, street);
|
||||
// At street zoom the hint invites the click.
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u) =>
|
||||
u.providers.find((p) => p.id === 'google')?.hint ===
|
||||
'click a street to open Street View',
|
||||
),
|
||||
'the click hint shows at street zoom',
|
||||
);
|
||||
const camera = () =>
|
||||
page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera.positionWC;
|
||||
return [c.x, c.y, c.z];
|
||||
});
|
||||
const at = await page.evaluate((point) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const ground = v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(point.lon, point.lat, 0),
|
||||
);
|
||||
const canvas = v.scene.cartesianToCanvasCoordinates(ground);
|
||||
const box = v.scene.canvas.getBoundingClientRect();
|
||||
return (
|
||||
canvas && { x: box.left + canvas.x, y: box.top + canvas.y }
|
||||
);
|
||||
}, street);
|
||||
assert.ok(at, 'the street is on screen');
|
||||
const before = await camera();
|
||||
await page.mouse.click(at.x, at.y);
|
||||
await uiUntil(
|
||||
(u) =>
|
||||
Boolean(u.street.error) ||
|
||||
(u.street.providerId === 'google' &&
|
||||
Boolean(u.street.imageId) &&
|
||||
!u.street.loading),
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const opened = (await ui()).street;
|
||||
if (/refused GOOGLE_MAPS_API_KEY/.test(opened.error || ''))
|
||||
return skip('Google key refused');
|
||||
if (/did not answer/.test(opened.error || ''))
|
||||
return skip('Google did not answer');
|
||||
assert.equal(opened.error, null);
|
||||
assert.equal(opened.providerId, 'google');
|
||||
const after = await camera();
|
||||
assert.ok(
|
||||
Math.hypot(...after.map((v, i) => v - before[i])) < 1,
|
||||
'the camera stays where the user clicked',
|
||||
);
|
||||
const shown = await page.evaluate(() => ({
|
||||
by: document.getElementById('sl-image-by').textContent,
|
||||
when: document.getElementById('sl-image-when').textContent,
|
||||
label: document.getElementById('sl-image-link').textContent,
|
||||
href: document.getElementById('sl-image-link').href,
|
||||
fitFillHidden:
|
||||
document.querySelector('[data-sl-render]').parentElement.hidden,
|
||||
panorama: Boolean(
|
||||
document.querySelector('#sl-viewer .sl-google-panorama'),
|
||||
),
|
||||
mapillaryLeft: Boolean(
|
||||
document.querySelector('#sl-viewer .mapillary-dom'),
|
||||
),
|
||||
}));
|
||||
assert.match(shown.by, /Image by /);
|
||||
assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month');
|
||||
assert.equal(shown.label, 'GOOGLE STREET VIEW ↗');
|
||||
assert.match(shown.href, /map_action=pano&pano=/);
|
||||
assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google');
|
||||
assert.equal(shown.panorama, true);
|
||||
assert.equal(shown.mapillaryLeft, false, 'no viewer left under it');
|
||||
// A real drag turns Google's view, and the pose follows it.
|
||||
const box = await page.$eval(
|
||||
'#sl-viewer .sl-google-panorama',
|
||||
(node) => {
|
||||
const r = node.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
},
|
||||
);
|
||||
await page.mouse.move(box.x, box.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 120, box.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, start) =>
|
||||
Number.isFinite(u.street.bearing) &&
|
||||
Math.abs(u.street.bearing - start) > 5,
|
||||
opened.bearing ?? 0,
|
||||
{ timeout: 10_000 },
|
||||
),
|
||||
'the pose bearing follows the drag',
|
||||
);
|
||||
// The keyboard path: OPEN STREET VIEW at the view centre.
|
||||
await closePhoto();
|
||||
await uiUntil((u) => u.street.open === false);
|
||||
await clickPanelControl(page, '#sl-nearest-btn');
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u) =>
|
||||
u.street.providerId === 'google' &&
|
||||
Boolean(u.street.imageId) &&
|
||||
!u.street.loading,
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
'OPEN STREET VIEW opens the panorama at the view centre',
|
||||
);
|
||||
// Leaving Google 3D closes the panorama (Google's terms).
|
||||
await stacks.set('esri-imagery');
|
||||
assert.ok(
|
||||
await uiUntil((u) => u.street.open === false),
|
||||
'the panorama closes off Google 3D',
|
||||
);
|
||||
} finally {
|
||||
await closePhoto();
|
||||
// Off Google 3D the Street View chip is hidden: return to it
|
||||
// before switching the chip, or the cleanup throws and hides
|
||||
// the step's own result.
|
||||
await stacks.set('photoreal');
|
||||
await setOn(chip, 'mapillary', true);
|
||||
await setOn(googleChip, 'google', false);
|
||||
await stacks.set(original);
|
||||
}
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
||||
async () => {
|
||||
|
||||
@@ -19,17 +19,21 @@ import {
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadNycSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
|
||||
/** Env opt-in: only "1" enables. For packs whose feed access needs an
|
||||
* operator agreement, so an unset flag never reaches the upstream. */
|
||||
const envOptIn = (name) => String(process.env[name] || '').trim() === '1';
|
||||
|
||||
/**
|
||||
* Live open-data packs, in merge order. Adding a region is one entry here
|
||||
* plus its loader in sources.js; the catalog cap is shared across entries
|
||||
* round-robin (cap.js), so a new pack never silently evicts an older one.
|
||||
* Each pack fails independently (allSettled) and is gated by its own env
|
||||
* kill switch.
|
||||
* kill switch, or by an opt-in flag where access needs an agreement.
|
||||
*/
|
||||
const LIVE_PACKS = [
|
||||
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
|
||||
@@ -98,6 +102,13 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
|
||||
load: loadVegvesenSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so the pack is
|
||||
// off unless the operator opts in (see DATA_SOURCES.md).
|
||||
name: 'nyc',
|
||||
enabled: () => envOptIn('CCTV_NYC_ENABLED'),
|
||||
load: loadNycSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -300,6 +300,36 @@ export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
|
||||
/** Same identity the other CCTV upstream requests send. */
|
||||
export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0';
|
||||
|
||||
/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the
|
||||
* five boroughs (~980 cameras). Frames are stills synthesized from each
|
||||
* camera UUID on the city's webcam host — never taken from the payload. */
|
||||
export const NYC_CAMERAS_URL = 'https://webcams.nyctmc.org/api/cameras';
|
||||
/** The only origin NYC camera frames may come from. The catalog's `imageUrl`
|
||||
* is ignored; the registered path is always this origin plus a validated
|
||||
* UUID plus `/image`. */
|
||||
export const NYC_IMAGE_ORIGIN = 'https://webcams.nyctmc.org/api/cameras/';
|
||||
export const NYC_IMAGE_URL = (id) => `${NYC_IMAGE_ORIGIN}${id}/image`;
|
||||
export const NYC_CAMERA_ID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
export const DEFAULT_NYC_MAX_SOURCES = 250;
|
||||
/** Hard ceiling on the NYC catalog body. The live list is ~980 rows / ~250 KB;
|
||||
* this only exists so an upstream that streams an unbounded body cannot be
|
||||
* buffered without limit. */
|
||||
export const NYC_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
/** Ground-elevation prior in metres. Matches the New York CITY_POIS prior;
|
||||
* the client's one-shot ground snap corrects it wherever 3D tiles load. */
|
||||
export const NYC_GROUND_ELEVATION_M = 10;
|
||||
/** Prioritization anchors: one per borough so a cap keeps citywide coverage
|
||||
* rather than Midtown alone. */
|
||||
export const NYC_ANCHORS = [
|
||||
{ lat: 40.758, lon: -73.9855 }, // Manhattan (Times Square)
|
||||
{ lat: 40.7128, lon: -74.006 }, // Lower Manhattan
|
||||
{ lat: 40.6782, lon: -73.9442 }, // Brooklyn
|
||||
{ lat: 40.7282, lon: -73.7949 }, // Queens
|
||||
{ lat: 40.8448, lon: -73.8648 }, // Bronx
|
||||
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
|
||||
];
|
||||
|
||||
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
|
||||
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
|
||||
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
|
||||
|
||||
@@ -367,6 +367,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** New York City's five boroughs, with slack for the harbor and city line. */
|
||||
export function isLikelyNycCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 40.45 &&
|
||||
lat <= 40.95 &&
|
||||
lon >= -74.3 &&
|
||||
lon <= -73.65
|
||||
);
|
||||
}
|
||||
|
||||
/** Mainland Norway, with slack for the coast and the Finnmark border. */
|
||||
export function isLikelyNorwayCoordinate(lat, lon) {
|
||||
return (
|
||||
|
||||
@@ -76,6 +76,13 @@ import {
|
||||
KARTVERKET_USER_AGENT,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
KARTVERKET_HEIGHT_RANGE_M,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_CAMERA_ID_RE,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -94,6 +101,7 @@ import {
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
isLikelyNycCoordinate,
|
||||
cameraDisplayCode,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
@@ -2143,3 +2151,148 @@ export async function applyVegvesenGroundHeights(
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
const NYC_BOROUGHS = new Set([
|
||||
'Manhattan',
|
||||
'Brooklyn',
|
||||
'Queens',
|
||||
'Bronx',
|
||||
'Staten Island',
|
||||
]);
|
||||
|
||||
/** NYC TMC publishes `isOnline` as the strings "true"/"false". */
|
||||
export function nycCameraIsOnline(value) {
|
||||
return (
|
||||
value === true ||
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase() === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for one NYC camera: the intersection an operator recognises
|
||||
* ("Central Park West @ 86 St"). The catalog's `area` is the borough, not
|
||||
* a facing, and is not part of the name.
|
||||
*
|
||||
* @param {object} record - Raw TMC camera row.
|
||||
* @param {string} cameraId - Derived stable id.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function nycCameraName(record, cameraId) {
|
||||
const name = String(record?.name ?? '').trim();
|
||||
if (name) return name;
|
||||
const short = String(cameraId || '')
|
||||
.replace(/^nyc-/, '')
|
||||
.slice(0, 8);
|
||||
return short ? `NYC Camera ${short}` : 'NYC Camera';
|
||||
}
|
||||
|
||||
/**
|
||||
* One NYC DOT TMC camera row -> one catalog source, or null.
|
||||
*
|
||||
* Frame URLs are synthesized from a validated UUID on the pinned TMC image
|
||||
* origin. The payload's `imageUrl` is ignored so a catalog edit cannot steer
|
||||
* the frame proxy off-host. There is no heading in the feed; headings use
|
||||
* the shared id-hash fallback at low confidence, like Calgary and Fintraffic.
|
||||
*
|
||||
* @param {object} record - Raw TMC camera row.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function nycCameraToSource(record) {
|
||||
if (!record || typeof record !== 'object') return null;
|
||||
if (!nycCameraIsOnline(record.isOnline)) return null;
|
||||
|
||||
const lat = toFiniteNumber(record.latitude);
|
||||
const lon = toFiniteNumber(record.longitude);
|
||||
if (!isLikelyNycCoordinate(lat, lon)) return null;
|
||||
|
||||
const uuid = String(record.id ?? '').trim();
|
||||
if (!NYC_CAMERA_ID_RE.test(uuid)) return null;
|
||||
const cameraId = `nyc-${uuid.toLowerCase()}`;
|
||||
const imageUrl = NYC_IMAGE_URL(uuid);
|
||||
const name = nycCameraName(record, cameraId);
|
||||
const area = String(record.area ?? '').trim();
|
||||
const city = NYC_BOROUGHS.has(area) ? area : 'New York';
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name,
|
||||
city,
|
||||
cityId: 'nyc',
|
||||
provider: 'NYC DOT Traffic Management Center',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: NYC_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'nyc-dot-tmc',
|
||||
license: 'Public NYC DOT Traffic Management Center traffic camera',
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch NYC DOT Traffic Management Center cameras from the keyless
|
||||
* `webcams.nyctmc.org` catalog. Frames are stills on that same host,
|
||||
* registered from a validated UUID rather than from the payload URL.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadNycSourcesFromOpenData() {
|
||||
try {
|
||||
const endpoint = process.env.CCTV_NYC_CAMERAS_URL || NYC_CAMERAS_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn('[CCTV] NYC catalog redirected; redirects are not followed');
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] NYC camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const rows = await readResponseJsonCapped(resp, NYC_MAX_CATALOG_BYTES);
|
||||
if (!Array.isArray(rows)) return [];
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const record of rows) {
|
||||
const camera = nycCameraToSource(record);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_NYC_MAX_SOURCES || DEFAULT_NYC_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
|
||||
: DEFAULT_NYC_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, NYC_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded NYC camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] NYC camera download error:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,33 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as scenePick from '../../data/scenePick.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** The browser Maps key the scene was started with (src/app/scene.js). */
|
||||
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
|
||||
|
||||
/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */
|
||||
function defaultProviders(sources) {
|
||||
if (sources.mapillary == null) return [];
|
||||
return [
|
||||
createMapillaryProvider({ source: sources.mapillary }),
|
||||
createGoogleProvider({ getApiKey: googleApiKey }),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject providers, or use Mapillary only when its source is supplied.
|
||||
* An explicit provider list replaces the default; unused sources.mapillary is
|
||||
* neither constructed nor validated, including when the list is empty.
|
||||
* Inject providers, or use the defaults. An explicit provider list replaces
|
||||
* them; an unused sources.mapillary is neither constructed nor validated,
|
||||
* including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = sources.mapillary == null
|
||||
? []
|
||||
: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
providers = defaultProviders(sources),
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
@@ -23,6 +35,7 @@ export function createApplicationStreetLevel({
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
|
||||
@@ -27,3 +27,25 @@ test('explicit providers replace the default source and an empty list removes it
|
||||
/array/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => {
|
||||
const { createDefaultLayerState } = await import('../../data/layerState.js');
|
||||
const mapillary = {
|
||||
getStatus() {},
|
||||
getTile() {},
|
||||
getSequenceImages() {},
|
||||
nearestImages() {},
|
||||
};
|
||||
const layer = createApplicationStreetLevel({ sources: { mapillary } });
|
||||
assert.deepEqual(layer.providerIds, ['mapillary', 'google']);
|
||||
// A fresh page and a restored default agree: Street View is billed, so off.
|
||||
layer.setParams(createDefaultLayerState().options['street-level']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, true);
|
||||
// A link that lit Street View carries it in the shared switch field.
|
||||
layer.setParams({ providerSwitches: 'google-1' });
|
||||
assert.equal(layer.getParams().google, true);
|
||||
assert.equal(layer.getParams().providerSwitches, 'google-1');
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadNycSourcesFromOpenData,
|
||||
nycCameraIsOnline,
|
||||
nycCameraName,
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_IMAGE_ORIGIN,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyNycCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
|
||||
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
id: MIDTOWN_ID,
|
||||
name: 'Central Park West @ 86 St',
|
||||
latitude: 40.785302,
|
||||
longitude: -73.969353,
|
||||
area: 'Manhattan',
|
||||
isOnline: 'true',
|
||||
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = nycCameraToSource(row());
|
||||
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(source.name, 'Central Park West @ 86 St');
|
||||
assert.equal(source.city, 'Manhattan');
|
||||
assert.equal(source.cityId, 'nyc');
|
||||
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
|
||||
assert.equal(source.lat, 40.785302);
|
||||
assert.equal(source.lon, -73.969353);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'nyc-dot-tmc');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Public NYC DOT Traffic Management Center traffic camera',
|
||||
);
|
||||
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
|
||||
const source = nycCameraToSource(
|
||||
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
|
||||
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: true })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
// Austin: a plausible lat/lon, but not a New York camera.
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
|
||||
assert.equal(nycCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
|
||||
const mixed = nycCameraToSource(
|
||||
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
|
||||
);
|
||||
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(mixed.city, 'New York');
|
||||
assert.equal(
|
||||
mixed.url,
|
||||
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
|
||||
'the registered frame path keeps the catalog UUID spelling',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
|
||||
'FDR Drive @ 23 St',
|
||||
);
|
||||
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
|
||||
assert.equal(nycCameraIsOnline('true'), true);
|
||||
assert.equal(nycCameraIsOnline('false'), false);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
|
||||
const long = nycCameraToSource(
|
||||
row({
|
||||
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BROOKLYN'));
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
name: 'Central Park West @ 86 St (duplicate)',
|
||||
latitude: 40.758,
|
||||
longitude: -73.9855,
|
||||
}),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
|
||||
}),
|
||||
row({ isOnline: 'false', name: 'Offline camera' }),
|
||||
row({ id: 'https://evil.example/x' }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
|
||||
assert.equal(cameras[1].city, 'Brooklyn');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = JSON.stringify([row()]);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_NYC_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
|
||||
else process.env.CCTV_NYC_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_NYC_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
// Far southern Staten Island, listed first so only distance can keep them.
|
||||
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
|
||||
row({
|
||||
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -74.24,
|
||||
area: 'Staten Island',
|
||||
name: `SI ${i}`,
|
||||
}),
|
||||
),
|
||||
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
|
||||
row({
|
||||
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -73.9855,
|
||||
area: 'Manhattan',
|
||||
name: `Midtown ${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.city),
|
||||
[
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Staten Island',
|
||||
'Staten Island',
|
||||
],
|
||||
'Midtown fills first; the Staten Island anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === NYC_CAMERAS_URL) {
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
|
||||
* leaves it unset), restoring the environment afterwards. */
|
||||
const runCatalogWithNycFlag = async (t, flag) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
|
||||
else process.env.CCTV_NYC_ENABLED = flag;
|
||||
return await runCatalogWithMockedUpstreams(t);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
|
||||
assert.ok(
|
||||
requested.includes(NYC_CAMERAS_URL),
|
||||
'the catalog refresh invokes the NYC loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
'NYC cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
});
|
||||
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so an install that
|
||||
// has not opted in must never contact it.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_NYC_ENABLED', undefined],
|
||||
['an empty CCTV_NYC_ENABLED', ''],
|
||||
['CCTV_NYC_ENABLED=0', '0'],
|
||||
['CCTV_NYC_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no NYC request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('nyctmc.org')),
|
||||
false,
|
||||
'the lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
|
||||
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
|
||||
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
|
||||
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
|
||||
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
|
||||
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyNycCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
|
||||
});
|
||||
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NYC DOT asks every application that uses its data to carry this
|
||||
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
|
||||
export const NYC_DOT_DISCLAIMER =
|
||||
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
|
||||
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
|
||||
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -249,6 +256,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>)',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New York City, when enabled): ' +
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
|
||||
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
|
||||
assert.ok(credit, 'NYC DOT camera frames must be credited');
|
||||
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
@@ -2703,13 +2703,19 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
|
||||
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
|
||||
});
|
||||
|
||||
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||
test('Street Level: a link without the provider switches keeps Mapillary on and Street View off', () => {
|
||||
assert.deepEqual(streetLevelOptions(''), {
|
||||
mapillary: true,
|
||||
providerSwitches: '',
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
// Street View rides the shared provider-switch field, off unless lit.
|
||||
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
|
||||
assert.equal(
|
||||
createDefaultLayerState().options['street-level'].providerSwitches,
|
||||
'',
|
||||
);
|
||||
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
|
||||
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
|
||||
assert.equal(
|
||||
|
||||
@@ -47,3 +47,52 @@ export function isPickedWorldPosition(position) {
|
||||
const magnitude = Math.hypot(x, y, z);
|
||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* The world position under a screen point: the rendered surface where the
|
||||
* depth buffer has it, else the ellipsoid; null over the sky.
|
||||
*
|
||||
* @param {object} viewer A Cesium viewer.
|
||||
* @param {{x: number, y: number}} screenPosition
|
||||
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
|
||||
*/
|
||||
export function pickGroundPosition(viewer, screenPosition) {
|
||||
const scene = viewer?.scene;
|
||||
let position = null;
|
||||
if (
|
||||
scene?.pickPositionSupported &&
|
||||
typeof scene.pickPosition === 'function'
|
||||
) {
|
||||
try {
|
||||
position = scene.pickPosition(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(position) &&
|
||||
typeof viewer?.camera?.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
position = viewer.camera.pickEllipsoid(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
return isPickedWorldPosition(position) ? position : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `scene.pick()` result is the map itself: nothing, or 3D tile
|
||||
* content (which has no id).
|
||||
*
|
||||
* @param {object|null|undefined} picked
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSurfacePick(picked) {
|
||||
if (!picked) return true;
|
||||
if (picked.id !== undefined) return false;
|
||||
return (
|
||||
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
|
||||
);
|
||||
}
|
||||
+68
-13
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPickedWorldPosition } from './scenePick.js';
|
||||
|
||||
const describe = (position) => (position
|
||||
? `(${position.x}, ${position.y}, ${position.z})`
|
||||
: String(position));
|
||||
const describe = (position) =>
|
||||
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
|
||||
|
||||
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
// These are the shapes `scene.pickPosition()` can produce when the depth read
|
||||
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
new Cesium.Cartesian3(0.2, -0.3, 0.1),
|
||||
];
|
||||
for (const position of degenerate) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(0, 0, 5_999_999),
|
||||
];
|
||||
for (const position of belowTheFloor) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
|
||||
const carto = Cesium.Cartographic.fromCartesian(position);
|
||||
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
|
||||
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
|
||||
assert.ok(
|
||||
carto.height < -300_000,
|
||||
'and puts the "target" hundreds of km underground',
|
||||
);
|
||||
}
|
||||
|
||||
const aboveTheCeiling = [
|
||||
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(1e155, 0, 0),
|
||||
];
|
||||
for (const position of aboveTheCeiling) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
|
||||
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
);
|
||||
|
||||
// Both edges of the band are inclusive, and both are real magnitudes.
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a real picked position is accepted', () => {
|
||||
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
|
||||
];
|
||||
for (const position of real) {
|
||||
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
true,
|
||||
`must accept ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The floor has real margin under the smallest legitimate magnitude, so no
|
||||
// plausible pick sits anywhere near the boundary.
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
|
||||
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(
|
||||
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
|
||||
);
|
||||
assert.ok(
|
||||
polarSurface > 6_300_000,
|
||||
`smallest real magnitude was ${polarSurface}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
// Earth's core. That is the quietest failure of the three and the reason
|
||||
// the guard needs a magnitude band, not just a finiteness check.
|
||||
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
|
||||
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
|
||||
assert.ok(
|
||||
Cesium.Cartographic.fromCartesian(nearCenter),
|
||||
'Cesium converts it without complaint',
|
||||
);
|
||||
assert.equal(isPickedWorldPosition(nearCenter), false);
|
||||
|
||||
// …and every position the guard accepts must convert to the right place.
|
||||
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
|
||||
});
|
||||
|
||||
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
|
||||
const { pickGroundPosition } = await import('./scenePick.js');
|
||||
const ground = { x: 6378137, y: 0, z: 0 };
|
||||
const sky = { x: 500, y: 0, z: 0 };
|
||||
const viewer = (picked, ellipsoid) => ({
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: { pickEllipsoid: () => ellipsoid },
|
||||
});
|
||||
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
|
||||
assert.equal(pickGroundPosition(null, {}), null);
|
||||
});
|
||||
|
||||
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
|
||||
const { isSurfacePick } = await import('./scenePick.js');
|
||||
assert.equal(isSurfacePick(undefined), true);
|
||||
assert.equal(isSurfacePick({ content: {} }), true);
|
||||
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
|
||||
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
|
||||
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
|
||||
});
|
||||
+23
-1
@@ -36,7 +36,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
unlocks: 'The photorealistic 3D planet + place search',
|
||||
unlocks: 'The photorealistic 3D planet, place search + Street View',
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
tier: 'metered',
|
||||
@@ -352,6 +352,28 @@ export function keySetupRequirement(id) {
|
||||
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key guidance that also names switched-off sources whose key is already set.
|
||||
* @param {string} requirement What `keySetupRequirement` said ('' allowed).
|
||||
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
|
||||
*/
|
||||
export function keySetupWithAlternatives(requirement, alternatives = []) {
|
||||
const options = alternatives.map(({ label, requiresKeyId }) => {
|
||||
const entry = KEY_SETUP_KEYS.find(
|
||||
(candidate) => candidate.id === requiresKeyId,
|
||||
);
|
||||
const name = entry
|
||||
? entry.title
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
: null;
|
||||
return name ? `light ${label} to use your ${name} key` : `light ${label}`;
|
||||
});
|
||||
if (!options.length) return requirement;
|
||||
const offer = `or ${options.join(', or ')}`;
|
||||
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a live provider value belongs to a source outside the store
|
||||
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isKeySetupExternallyManaged,
|
||||
keySetupStatus,
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
knownKeySetupEnvVars,
|
||||
parseWindowsUserSid,
|
||||
upsertDotenvValues,
|
||||
@@ -362,3 +363,16 @@ test('server Google key remains supported without appearing in setup or its miss
|
||||
assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY'));
|
||||
assert.ok(!JSON.stringify(status).includes(secret));
|
||||
});
|
||||
|
||||
test('key guidance can offer another source that works with a key already set', () => {
|
||||
const streetView = { label: 'STREET VIEW', requiresKeyId: 'google-maps' };
|
||||
assert.equal(
|
||||
keySetupWithAlternatives(keySetupRequirement('mapillary'), [streetView]),
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(
|
||||
keySetupWithAlternatives('', [streetView]),
|
||||
'Light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X');
|
||||
});
|
||||
@@ -617,32 +617,11 @@ export function createDirectionsLayer({ services, source }) {
|
||||
}
|
||||
|
||||
function pickGround(screenPosition) {
|
||||
const scene = _viewer?.scene;
|
||||
let cartesian = null;
|
||||
if (
|
||||
scene?.pickPositionSupported &&
|
||||
typeof scene.pickPosition === 'function'
|
||||
) {
|
||||
try {
|
||||
cartesian = scene.pickPosition(screenPosition);
|
||||
} catch {
|
||||
cartesian = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!services.scenePick.isPickedWorldPosition(cartesian) &&
|
||||
typeof _viewer?.camera?.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
cartesian = _viewer.camera.pickEllipsoid(
|
||||
screenPosition,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
);
|
||||
} catch {
|
||||
cartesian = null;
|
||||
}
|
||||
}
|
||||
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
|
||||
const cartesian = services.scenePick.pickGroundPosition(
|
||||
_viewer,
|
||||
screenPosition,
|
||||
);
|
||||
if (!cartesian) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(cartesian);
|
||||
if (!carto) return null;
|
||||
return {
|
||||
|
||||
@@ -65,6 +65,15 @@ export function imageConeGlyph({
|
||||
});
|
||||
}
|
||||
|
||||
/** 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));
|
||||
});
|
||||
}
|
||||
|
||||
/** Marker for the image the viewer currently shows. */
|
||||
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
||||
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as Cesium from 'cesium';
|
||||
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) {
|
||||
if (!viewer?.camera?.positionCartographic) return false;
|
||||
return (
|
||||
cameraHeightAboveGround(viewer, { groundAt }) <= 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
|
||||
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
|
||||
*/
|
||||
export function createGroundClick({
|
||||
state,
|
||||
openNearest,
|
||||
isAvailable,
|
||||
groundAt = null,
|
||||
}) {
|
||||
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)
|
||||
)
|
||||
return false;
|
||||
const providerIds = [...state.providers.values()]
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.on &&
|
||||
entry.def.groundClick &&
|
||||
entry.status?.configured &&
|
||||
isAvailable(entry),
|
||||
)
|
||||
.map((entry) => entry.def.id);
|
||||
if (!providerIds.length) return false;
|
||||
const position = scenePick.pickGroundPosition(state.viewer, screenPosition);
|
||||
const carto = position && Cesium.Cartographic.fromCartesian(position);
|
||||
if (!carto) return false;
|
||||
return openNearest(
|
||||
{
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
},
|
||||
{ providerIds, frame: false },
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import * as scenePick from '../../data/scenePick.js';
|
||||
|
||||
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10);
|
||||
const TILESET = { isCesium3DTileset: true };
|
||||
|
||||
/**
|
||||
* A layer state with providers `{id, on, groundClick, configured, available}`;
|
||||
* `picked` is where the depth buffer lands, `ellipsoid` the fallback hit.
|
||||
*/
|
||||
function harness({
|
||||
providers = [
|
||||
{ id: 'mapillary', groundClick: false },
|
||||
{ id: 'google', groundClick: true },
|
||||
],
|
||||
height = 400,
|
||||
enabled = true,
|
||||
picked = GROUND,
|
||||
ellipsoid = undefined,
|
||||
} = {}) {
|
||||
const opened = [];
|
||||
const state = {
|
||||
enabled,
|
||||
services: { scenePick },
|
||||
providers: new Map(
|
||||
providers.map(
|
||||
({
|
||||
id,
|
||||
on = true,
|
||||
groundClick,
|
||||
configured = true,
|
||||
available = true,
|
||||
}) => [
|
||||
id,
|
||||
{ on, def: { id, groundClick }, status: { configured }, available },
|
||||
],
|
||||
),
|
||||
),
|
||||
viewer: {
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: {
|
||||
pickEllipsoid: () => ellipsoid,
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(
|
||||
-121.4944,
|
||||
38.5816,
|
||||
height,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
const openAtGround = createGroundClick({
|
||||
state,
|
||||
openNearest: (point, options) => {
|
||||
opened.push({ point, options });
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
isAvailable: (entry) => entry.available,
|
||||
});
|
||||
return { opened, openAtGround };
|
||||
}
|
||||
|
||||
const near = (point) =>
|
||||
Math.abs(point.lon - -121.4944) < 1e-9 &&
|
||||
Math.abs(point.lat - 38.5816) < 1e-9;
|
||||
|
||||
test('a click on the map opens the click-to-open providers there, without moving the camera', () => {
|
||||
for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) {
|
||||
const { opened, openAtGround } = harness();
|
||||
openAtGround({ x: 10, y: 20 }, picked);
|
||||
assert.equal(opened.length, 1, JSON.stringify(picked));
|
||||
assert.ok(near(opened[0].point));
|
||||
assert.deepEqual(opened[0].options, {
|
||||
providerIds: ['google'],
|
||||
frame: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('where the depth buffer names no place, the ellipsoid answers', () => {
|
||||
const { opened, openAtGround } = harness({
|
||||
picked: new Cesium.Cartesian3(500, 0, 0),
|
||||
ellipsoid: GROUND,
|
||||
});
|
||||
openAtGround({ x: 1, y: 1 });
|
||||
assert.ok(near(opened[0].point));
|
||||
});
|
||||
|
||||
test('nothing opens over another layer’s object, the sky, from high up, off its map stack, or with the provider off', () => {
|
||||
const cases = [
|
||||
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
|
||||
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
|
||||
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
|
||||
{ options: { height: 5000 }, label: 'high up' },
|
||||
{
|
||||
options: {
|
||||
providers: [{ id: 'google', groundClick: true, available: false }],
|
||||
},
|
||||
label: 'not Google 3D',
|
||||
},
|
||||
{
|
||||
options: { providers: [{ id: 'google', groundClick: true, on: false }] },
|
||||
label: 'off',
|
||||
},
|
||||
{
|
||||
options: {
|
||||
providers: [{ id: 'google', groundClick: true, configured: false }],
|
||||
},
|
||||
label: 'no key',
|
||||
},
|
||||
{ options: { enabled: false }, label: 'layer off' },
|
||||
];
|
||||
for (const { picked, options = {}, label } of cases) {
|
||||
const { opened, openAtGround } = harness(options);
|
||||
assert.equal(openAtGround({ x: 1, y: 1 }, picked), false, label);
|
||||
assert.equal(opened.length, 0, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('street zoom is measured above the ground under the camera', () => {
|
||||
const viewer = (height) => ({
|
||||
camera: {
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height),
|
||||
},
|
||||
scene: { globe: { getHeight: () => 1500 } },
|
||||
});
|
||||
// 1,500 m up a plateau, 2,000 m above it: still a street.
|
||||
assert.equal(groundClickHeightOk(viewer(3500)), true);
|
||||
assert.equal(groundClickHeightOk(viewer(5000)), false);
|
||||
assert.equal(groundClickHeightOk(null), false);
|
||||
});
|
||||
+211
-31
@@ -1,3 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createState } from './state.js';
|
||||
import { createMarker } from './marker.js';
|
||||
import { createCameraFollow } from './cameraFollow.js';
|
||||
@@ -5,17 +6,23 @@ import { createCredits } from './credits.js';
|
||||
import { createPickRouter } from './pickRouter.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createViewerHost } from './viewerHost.js';
|
||||
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||
import {
|
||||
composeUIState,
|
||||
keyAlternatives,
|
||||
summarizeCoverage,
|
||||
} from './uiState.js';
|
||||
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
import { createMeshSampler } from './meshSampler.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,
|
||||
NEAREST_RADIUS_M,
|
||||
POSITION_PICK_ID,
|
||||
@@ -66,6 +73,12 @@ export function createStreetLevelLayer({
|
||||
entry.instance.clearSequence?.();
|
||||
};
|
||||
parts.selection = createSelection(context);
|
||||
parts.openAtGround = createGroundClick({
|
||||
state,
|
||||
openNearest: (point, options) => layer.openNearest(point, options),
|
||||
isAvailable: (entry) => providerAvailable(entry),
|
||||
groundAt: parts.groundCaster?.groundAt ?? null,
|
||||
});
|
||||
|
||||
let notifyQueued = false;
|
||||
function notify() {
|
||||
@@ -115,11 +128,13 @@ 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 entry = {
|
||||
def,
|
||||
instance: null,
|
||||
on: true,
|
||||
on: def === startsOn,
|
||||
status: null,
|
||||
/** The error this provider last reported, until it withdraws it. */
|
||||
reportedError: null,
|
||||
@@ -140,6 +155,39 @@ export function createStreetLevelLayer({
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
|
||||
function providerAvailable(entry) {
|
||||
const required = entry.def.requiresMapStack;
|
||||
return !required || mapStack?.getActiveId?.() === required;
|
||||
}
|
||||
|
||||
/** Leaving a provider's map stack closes its image, viewer and credit. */
|
||||
function syncProviderAvailability() {
|
||||
const owner = state.providers.get(state.street.providerId);
|
||||
if (state.street.open && owner && !providerAvailable(owner))
|
||||
layer.closeViewer();
|
||||
for (const entry of state.providers.values()) {
|
||||
if (!entry.def.requiresMapStack) continue;
|
||||
if (state.enabled && entry.on && providerAvailable(entry))
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
else {
|
||||
parts.credits.hide(state.viewer, entry.def);
|
||||
parts.viewerHost.unmount(entry.def.id);
|
||||
}
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Whether a ground click can open an image: the camera is at street zoom. */
|
||||
function syncGroundClickReady() {
|
||||
const ready =
|
||||
state.enabled &&
|
||||
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
|
||||
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.
|
||||
@@ -162,6 +210,7 @@ export function createStreetLevelLayer({
|
||||
|
||||
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
|
||||
function syncSurface() {
|
||||
syncGroundClickReady();
|
||||
const available = Boolean(parts.groundCaster) && state.enabled;
|
||||
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
||||
const mode = nextSurfaceMode(state.surface, {
|
||||
@@ -222,9 +271,13 @@ export function createStreetLevelLayer({
|
||||
function activate(entry) {
|
||||
if (!state.viewer) return;
|
||||
entry.instance.activate(state.viewer);
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
if (!entry.status) refreshStatus(entry);
|
||||
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
|
||||
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);
|
||||
}
|
||||
|
||||
function deactivate(entry) {
|
||||
@@ -241,25 +294,80 @@ export function createStreetLevelLayer({
|
||||
nearestLookup = null;
|
||||
}
|
||||
|
||||
function openImage(providerId, imageId) {
|
||||
abortNearest();
|
||||
return parts.viewerHost.open(providerId, imageId);
|
||||
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
|
||||
function screenCentreGround() {
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const pick = state.services.scenePick?.pickGroundPosition;
|
||||
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight)
|
||||
return undefined;
|
||||
const position = pick(state.viewer, {
|
||||
x: canvas.clientWidth / 2,
|
||||
y: canvas.clientHeight / 2,
|
||||
});
|
||||
const carto = position && Cesium.Cartographic.fromCartesian(position);
|
||||
return carto
|
||||
? {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `frame: false` keeps the camera where it is (the user picked the spot). */
|
||||
function openImage(providerId, imageId, { frame = true } = {}) {
|
||||
abortNearest();
|
||||
const entry = state.providers.get(providerId);
|
||||
if (entry && !providerAvailable(entry)) {
|
||||
state.street.loading = false;
|
||||
state.street.error = unavailableReason(entry);
|
||||
notify();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return parts.viewerHost.open(providerId, imageId, { frame });
|
||||
}
|
||||
|
||||
function unavailableReason(entry) {
|
||||
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
|
||||
}
|
||||
|
||||
/** One imagery source at a time: lighting a provider darkens the others. */
|
||||
function setProviderEnabled(providerId, on) {
|
||||
const entry = state.providers.get(providerId);
|
||||
if (!entry) return false;
|
||||
const next = on !== false;
|
||||
if (entry.on === next) return true;
|
||||
entry.on = next;
|
||||
if (state.enabled) {
|
||||
if (next) activate(entry);
|
||||
else deactivate(entry);
|
||||
}
|
||||
if (next)
|
||||
for (const other of state.providers.values())
|
||||
if (other !== entry && other.on) switchProvider(other, false);
|
||||
switchProvider(entry, next);
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
function switchProvider(entry, on) {
|
||||
if (entry.on === on) return;
|
||||
entry.on = on;
|
||||
if (!state.enabled) return;
|
||||
if (on) activate(entry);
|
||||
else deactivate(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one provider params leave lit: of those they switch on, the one
|
||||
* already on (reapplying never flips), else the first; with none, the lit
|
||||
* one unless they switch it off.
|
||||
*/
|
||||
function litByParams(switches) {
|
||||
const asked = [...state.providers.keys()].filter(
|
||||
(id) => switches.get(id) === true,
|
||||
);
|
||||
const current = [...state.providers]
|
||||
.filter(([, entry]) => entry.on)
|
||||
.map(([id]) => id);
|
||||
if (asked.length)
|
||||
return asked.find((id) => current.includes(id)) ?? asked[0];
|
||||
return current.find((id) => switches.get(id) !== false) ?? null;
|
||||
}
|
||||
|
||||
function setCoverageFilter(next) {
|
||||
const filter = normalizeFilter(next, state.filter);
|
||||
if (sameFilter(filter, state.filter)) return;
|
||||
@@ -270,8 +378,19 @@ export function createStreetLevelLayer({
|
||||
notify();
|
||||
}
|
||||
|
||||
/** The hint a ground-click provider shows, or the provider's own. */
|
||||
function hintFor(entry, stats) {
|
||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
||||
return `zoom in to a street to open ${entry.def.name}`;
|
||||
return stats.hint || '';
|
||||
}
|
||||
|
||||
/** Providers usable on this map stack; the others are not shown at all. */
|
||||
const listedEntries = () =>
|
||||
[...state.providers.values()].filter((entry) => providerAvailable(entry));
|
||||
|
||||
function providerSnapshots() {
|
||||
return [...state.providers.values()].map((entry) => {
|
||||
return listedEntries().map((entry) => {
|
||||
const stats = entry.instance.coverageStats();
|
||||
return {
|
||||
id: entry.def.id,
|
||||
@@ -285,9 +404,11 @@ export function createStreetLevelLayer({
|
||||
requiresKeyId: entry.def.requiresKeyId || null,
|
||||
loading: stats.loading === true,
|
||||
count: stats.count || 0,
|
||||
hint: stats.hint || '',
|
||||
hint: hintFor(entry, stats),
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -313,24 +434,43 @@ export function createStreetLevelLayer({
|
||||
|
||||
function getUIState() {
|
||||
const { host, ...street } = state.street;
|
||||
return composeUIState({
|
||||
const ui = composeUIState({
|
||||
enabled: state.enabled,
|
||||
groundClickReady: state.groundClickReady,
|
||||
filter: state.filter,
|
||||
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(
|
||||
(entry) => entry.on && !providerAvailable(entry),
|
||||
);
|
||||
if (stranded && !ui.providers.some((p) => p.on))
|
||||
ui.coverage.hint = unavailableReason(stranded);
|
||||
return ui;
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: STREET_LEVEL_LAYER_ID,
|
||||
name: 'Street Level',
|
||||
icon: '📷',
|
||||
source: definitionsFrozen.map((def) => def.name).join(' · '),
|
||||
/** The sources usable on this map (Street View only on Google 3D). */
|
||||
get source() {
|
||||
return listedEntries()
|
||||
.map((entry) => entry.def.name)
|
||||
.join(' · ');
|
||||
},
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
||||
/** 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,
|
||||
);
|
||||
},
|
||||
/** Registered providers, in chip order. */
|
||||
providerIds: definitionsFrozen.map((def) => def.id),
|
||||
getSourceAvailability() {
|
||||
@@ -404,16 +544,19 @@ export function createStreetLevelLayer({
|
||||
|
||||
getStats() {
|
||||
// The lifecycle polls this every second: summarise, don't snapshot.
|
||||
const coverage = summarizeCoverage(providerSnapshots());
|
||||
const snapshots = providerSnapshots();
|
||||
const coverage = summarizeCoverage(snapshots);
|
||||
let loadingLabel = '';
|
||||
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
|
||||
if (coverage.keyRequired) loadingLabel = keyLabel;
|
||||
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
||||
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||
return {
|
||||
source: layer.source,
|
||||
count: coverage.count,
|
||||
loading: coverage.loading,
|
||||
keyRequired: coverage.keyRequired,
|
||||
keyAlternatives: keyAlternatives(snapshots),
|
||||
error: coverage.keyRequired
|
||||
? coverage.keyRejected
|
||||
? coverage.error || keyLabel
|
||||
@@ -441,9 +584,11 @@ export function createStreetLevelLayer({
|
||||
unsubscribeMapStack =
|
||||
mapStack?.subscribe?.(() => {
|
||||
syncFollowAvailability();
|
||||
syncProviderAvailability();
|
||||
syncSurface();
|
||||
}) || null;
|
||||
syncFollowAvailability();
|
||||
syncProviderAvailability();
|
||||
syncSurface();
|
||||
},
|
||||
|
||||
@@ -460,7 +605,11 @@ export function createStreetLevelLayer({
|
||||
providerIds: state.providers.keys(),
|
||||
filter: state.filter,
|
||||
});
|
||||
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
|
||||
const lit = litByParams(decoded.providers);
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
if (lit) switchProvider(state.providers.get(lit), true);
|
||||
notify();
|
||||
setCoverageFilter(decoded.filter);
|
||||
return true;
|
||||
},
|
||||
@@ -476,15 +625,39 @@ export function createStreetLevelLayer({
|
||||
attachViewerHost(element) {
|
||||
parts.viewerHost.attach(element);
|
||||
if (element && state.enabled)
|
||||
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
|
||||
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 any active provider has around a point. */
|
||||
async openNearest(point) {
|
||||
const view = point || viewCentre(state.viewer);
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
{ providerIds = null, frame = true, aim = 'view' } = {},
|
||||
) {
|
||||
let view = point;
|
||||
if (!view && aim === 'screen-centre') {
|
||||
view = screenCentreGround();
|
||||
if (view === null) {
|
||||
abortNearest();
|
||||
state.street.loading = false;
|
||||
state.street.error = AIM_AT_GROUND_MESSAGE;
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
view ||= viewCentre(state.viewer);
|
||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||
return false;
|
||||
abortNearest();
|
||||
@@ -495,7 +668,11 @@ export function createStreetLevelLayer({
|
||||
state.street.error = null;
|
||||
notify();
|
||||
let lastError = null;
|
||||
for (const entry of activeEntries()) {
|
||||
const asked = (entry) =>
|
||||
(!providerIds || providerIds.includes(entry.def.id)) &&
|
||||
providerAvailable(entry);
|
||||
const entries = activeEntries().filter(asked);
|
||||
for (const entry of entries) {
|
||||
let imageId = null;
|
||||
try {
|
||||
imageId = await entry.instance.nearestImage(
|
||||
@@ -511,17 +688,20 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
if (signal.aborted) return false;
|
||||
if (!state.enabled) break;
|
||||
// This provider was switched off while it looked: try the next.
|
||||
if (!imageId || !entry.on) continue;
|
||||
// Switched off, or its map left, while it looked: try the next.
|
||||
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
|
||||
nearestLookup = null;
|
||||
return openImage(entry.def.id, imageId);
|
||||
return openImage(entry.def.id, imageId, { frame });
|
||||
}
|
||||
if (nearestLookup === lookup) nearestLookup = null;
|
||||
// Switched off while it looked: nothing to open, nothing to report.
|
||||
if (state.enabled && activeEntries().length)
|
||||
const stillOn = entries.some(
|
||||
(entry) => entry.on && providerAvailable(entry),
|
||||
);
|
||||
if (state.enabled && stillOn)
|
||||
state.street.error =
|
||||
lastError?.message ||
|
||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
|
||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : 'the view centre'}`;
|
||||
state.street.loading = false;
|
||||
notify();
|
||||
return false;
|
||||
|
||||
@@ -483,6 +483,328 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
|
||||
assert.equal(viewer.credits.length, 1);
|
||||
});
|
||||
|
||||
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
|
||||
async function withStreetView(
|
||||
t,
|
||||
{ mapStack = 'photoreal', nearest, mapillaryKeyless = false } = {},
|
||||
) {
|
||||
const { createGoogleProvider } = await import('./providers/google/index.js');
|
||||
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
|
||||
await import('../../testSupport/googleStreetViewFakes.mjs');
|
||||
const fake = fakeStreetViewLibrary({
|
||||
panoramas: {
|
||||
'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' },
|
||||
},
|
||||
nearest: nearest ?? (() => 'pano-a'),
|
||||
});
|
||||
const mapillaryLookups = [];
|
||||
const mapillary = fakeProvider({
|
||||
nearestImage: async (point) => {
|
||||
mapillaryLookups.push(point);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
mapillary.requiresKeyId = 'mapillary';
|
||||
if (mapillaryKeyless) {
|
||||
mapillary.stats.keyRequired = true;
|
||||
const create = mapillary.create;
|
||||
mapillary.create = (context) => ({
|
||||
...create(context),
|
||||
status: async () => ({ configured: false }),
|
||||
});
|
||||
}
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
const google = createGoogleProvider({
|
||||
getApiKey: () => 'browser-key',
|
||||
loader,
|
||||
});
|
||||
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
|
||||
const stack = fakeMapStack(mapStack);
|
||||
layer.attachMapStackController(stack);
|
||||
layer.attachViewerHost(fakeHost());
|
||||
return { layer, viewer, stack, fake, loader, mapillaryLookups };
|
||||
}
|
||||
|
||||
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
|
||||
const { layer } = await withStreetView(t);
|
||||
const google = () =>
|
||||
layer.getUIState().providers.find((p) => p.id === 'google');
|
||||
assert.equal(google().on, false);
|
||||
assert.equal(layer.requiresKeyId, 'mapillary');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.equal(layer.requiresKeyId, 'google-maps', 'Street View alone');
|
||||
});
|
||||
|
||||
test('the layer starts with one source lit: the first that defaults on', async (t) => {
|
||||
const { layer } = await enabledLayer(t, [
|
||||
fakeProvider(),
|
||||
fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((p) => p.on)
|
||||
.map((p) => p.id),
|
||||
['mapillary'],
|
||||
);
|
||||
});
|
||||
|
||||
test('leaving Google 3D during a Street View lookup ends it without an error or a stuck spinner', async (t) => {
|
||||
const { layer, stack, fake } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
let answer;
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
const opening = layer.openNearest({ lat: 38.5816, lon: -121.4944 });
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.loading, true);
|
||||
stack.switchTo('bing');
|
||||
answer({ data: { location: { pano: 'pano-a' }, imageDate: '2024-05' } });
|
||||
assert.equal(await opening, false);
|
||||
const { street } = layer.getUIState();
|
||||
assert.equal(street.loading, false, 'OPEN STREET VIEW is usable again');
|
||||
assert.equal(street.error, null);
|
||||
assert.equal(street.open, false);
|
||||
});
|
||||
|
||||
test('a source swap during a lookup reports no false "no imagery"', async (t) => {
|
||||
let answer;
|
||||
const mapillary = fakeProvider({
|
||||
nearestImage: () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
});
|
||||
const other = fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' });
|
||||
const { layer } = await enabledLayer(t, [mapillary, other]);
|
||||
const opening = layer.openNearest({ lat: 1, lon: 2 });
|
||||
layer.setProviderEnabled('panoramax', true);
|
||||
answer('img-1');
|
||||
assert.equal(await opening, false);
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
assert.equal(layer.getUIState().street.loading, false);
|
||||
});
|
||||
|
||||
test('Street View lit off Google 3D says why the panel is empty', async (t) => {
|
||||
const { layer, stack } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
stack.switchTo('esri-imagery');
|
||||
assert.match(
|
||||
layer.getUIState().coverage.hint,
|
||||
/Google Street View needs the Google 3D map/,
|
||||
);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
assert.doesNotMatch(layer.getUIState().coverage.hint, /Google 3D/);
|
||||
});
|
||||
|
||||
test('one imagery source at a time: lighting one darkens the other, either way', async (t) => {
|
||||
const { layer } = await withStreetView(t);
|
||||
const lit = () =>
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((p) => p.on)
|
||||
.map((p) => p.id);
|
||||
assert.deepEqual(lit(), ['mapillary'], 'Mapillary by default: it is free');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
// Params that light one switch to it; reapplying them changes nothing.
|
||||
layer.setParams({ google: true });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
layer.setParams({ google: true });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
// Params lighting both keep the one already on, however often applied.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'google-1' });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
}
|
||||
layer.setParams({ mapillary: true, google: false });
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
// Filter-only params leave the source alone.
|
||||
layer.setParams({ pano: 'pano' });
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
// Darkening the only lit one leaves none: the panel turns the layer off.
|
||||
layer.setProviderEnabled('mapillary', false);
|
||||
assert.deepEqual(lit(), []);
|
||||
});
|
||||
|
||||
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
|
||||
const { layer, viewer, stack } = await withStreetView(t, {
|
||||
mapStack: 'esri-imagery',
|
||||
});
|
||||
layer.setProviderEnabled('google', true);
|
||||
await settle();
|
||||
const ui = () => layer.getUIState();
|
||||
const listed = () => ui().providers.map((p) => p.id);
|
||||
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');
|
||||
assert.equal(await layer.openImage('google', 'pano-a'), false);
|
||||
assert.match(ui().street.error, /needs the Google 3D map/);
|
||||
assert.equal(layer.getParams().google, true, 'the choice is kept');
|
||||
|
||||
stack.switchTo('photoreal');
|
||||
assert.deepEqual(listed(), ['mapillary', 'google']);
|
||||
assert.equal(credited(), true);
|
||||
assert.equal(layer.source, 'Mapillary · Google Street View');
|
||||
assert.equal(await layer.openImage('google', 'pano-a'), true);
|
||||
assert.equal(ui().street.providerId, 'google');
|
||||
|
||||
stack.switchTo('bing');
|
||||
await settle();
|
||||
assert.equal(ui().street.open, false, 'closed with the map');
|
||||
assert.equal(credited(), false);
|
||||
assert.deepEqual(listed(), ['mapillary']);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
|
||||
const { layer, mapillaryLookups } = await withStreetView(t, {
|
||||
nearest: () => null,
|
||||
});
|
||||
layer.setProviderEnabled('google', true);
|
||||
await settle();
|
||||
const opened = await layer.openNearest(
|
||||
{ lat: 38.58, lon: -121.49 },
|
||||
{ providerIds: ['google'], frame: false },
|
||||
);
|
||||
assert.equal(opened, false);
|
||||
assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked');
|
||||
assert.equal(
|
||||
layer.getUIState().street.error,
|
||||
'No street-level imagery within 50 m of that point',
|
||||
);
|
||||
});
|
||||
|
||||
test('an image opened from a ground click leaves the camera where it is', async (t) => {
|
||||
const { layer, viewer } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
await settle();
|
||||
await layer.openNearest(
|
||||
{ lat: 38.58, lon: -121.49 },
|
||||
{ providerIds: ['google'], frame: false },
|
||||
);
|
||||
assert.equal(layer.getUIState().street.imageId, 'pano-a');
|
||||
assert.equal(viewer.flights.started, 0, 'no framing flight');
|
||||
});
|
||||
|
||||
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
|
||||
const { layer, loader } = await withStreetView(t, {
|
||||
mapStack: 'esri-imagery',
|
||||
});
|
||||
layer.setProviderEnabled('google', true);
|
||||
layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.deepEqual(loader.imports, [], 'no prewarm, no lookup');
|
||||
});
|
||||
|
||||
test('the button’s “centre of the view” is the ground under the middle of the screen', async (t) => {
|
||||
const { createStreetLevelLayer: create } = await import('./index.js');
|
||||
const scenePick = await import('../../data/scenePick.js');
|
||||
const lookups = [];
|
||||
const provider = fakeProvider({
|
||||
nearestImage: async (point) => {
|
||||
lookups.push(point);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const saved = globalThis.document;
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
|
||||
Object.assign(viewer.scene, {
|
||||
pickPositionSupported: true,
|
||||
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
|
||||
});
|
||||
viewer.scene.canvas.clientWidth = 1000;
|
||||
viewer.scene.canvas.clientHeight = 600;
|
||||
const layer = create({ providers: [provider], services: { scenePick } });
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
await layer.openNearest(undefined, { aim: 'screen-centre' });
|
||||
assert.equal(lookups.length, 1);
|
||||
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
|
||||
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
|
||||
});
|
||||
|
||||
test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => {
|
||||
const { createStreetLevelLayer: create } = await import('./index.js');
|
||||
const scenePick = await import('../../data/scenePick.js');
|
||||
const lookups = [];
|
||||
const provider = fakeProvider({
|
||||
nearestImage: async (point) => {
|
||||
lookups.push(point);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const saved = globalThis.document;
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
// Every ground pick misses: the middle of the screen is sky.
|
||||
Object.assign(viewer.scene, {
|
||||
pickPositionSupported: true,
|
||||
pickPosition: () => undefined,
|
||||
});
|
||||
viewer.camera.pickEllipsoid = () => undefined;
|
||||
viewer.scene.canvas.clientWidth = 1000;
|
||||
viewer.scene.canvas.clientHeight = 600;
|
||||
const layer = create({ providers: [provider], services: { scenePick } });
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
assert.equal(
|
||||
await layer.openNearest(undefined, { aim: 'screen-centre' }),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(lookups, [], 'no lookup at the view centre instead');
|
||||
const { street } = layer.getUIState();
|
||||
assert.match(street.error, /Aim the middle of the view at a street/);
|
||||
assert.equal(street.loading, false);
|
||||
});
|
||||
|
||||
test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => {
|
||||
const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true });
|
||||
await settle();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.keyRequired, true);
|
||||
assert.deepEqual(stats.keyAlternatives, [
|
||||
{ id: 'google', label: 'GOOGLE STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
]);
|
||||
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
|
||||
stack.switchTo('esri-imagery');
|
||||
assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D');
|
||||
// Lit, Street View draws: the layer is no longer key-gated.
|
||||
stack.switchTo('photoreal');
|
||||
layer.setProviderEnabled('google', true);
|
||||
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, []);
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { positionMarkerGlyph } from './glyphs.js';
|
||||
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';
|
||||
|
||||
function markerLook(style) {
|
||||
if (style?.kind === 'view-cone') {
|
||||
const color = style.color || COLORS.position;
|
||||
return {
|
||||
key: `view-cone:${color}`,
|
||||
image: viewConeMarkerGlyph({ color }),
|
||||
size: 48,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'pin',
|
||||
image: positionMarkerGlyph({ color: COLORS.position }),
|
||||
size: 40,
|
||||
};
|
||||
}
|
||||
|
||||
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
|
||||
export function createMarker({ state, parts }) {
|
||||
const { render, sprites } = state.services;
|
||||
@@ -42,7 +58,8 @@ export function createMarker({ state, parts }) {
|
||||
const dem = ground.groundAt(lon, lat);
|
||||
if (dem === null) {
|
||||
ground.prepare([[lon, lat]]).then((ready) => {
|
||||
if (ready && last?.position === position) set(position, last.bearing);
|
||||
if (ready && last?.position === position)
|
||||
set(position, last.bearing, last.style);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -53,14 +70,15 @@ export function createMarker({ state, parts }) {
|
||||
|
||||
// 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);
|
||||
if (last && state.surface === 'terrain')
|
||||
set(last.position, last.bearing, last.style);
|
||||
});
|
||||
|
||||
/** Move (or create) the marker; a null position removes it. */
|
||||
function set(position, bearing) {
|
||||
/** 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 } : null;
|
||||
last = position ? { position, bearing, style } : null;
|
||||
if (!position) {
|
||||
collection.removeAll();
|
||||
state.marker.billboard = null;
|
||||
@@ -78,14 +96,20 @@ export function createMarker({ state, parts }) {
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE;
|
||||
const look = markerLook(style);
|
||||
if (state.marker.billboard && state.marker.look !== look.key) {
|
||||
collection.remove?.(state.marker.billboard);
|
||||
state.marker.billboard = null;
|
||||
}
|
||||
if (!state.marker.billboard) {
|
||||
state.marker.look = look.key;
|
||||
state.marker.billboard = collection.add({
|
||||
id: POSITION_PICK_ID,
|
||||
position: cartesian,
|
||||
image: positionMarkerGlyph({ color: COLORS.position }),
|
||||
imageId: 'sl-position',
|
||||
width: 40,
|
||||
height: 40,
|
||||
image: look.image,
|
||||
imageId: `sl-position:${look.key}`,
|
||||
width: look.size,
|
||||
height: look.size,
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
heightReference,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
@@ -117,7 +141,7 @@ export function createMarker({ state, parts }) {
|
||||
|
||||
/** Re-place the marker after the surface mode changes. */
|
||||
function setSurface() {
|
||||
if (last) set(last.position, last.bearing);
|
||||
if (last) set(last.position, last.bearing, last.style);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -40,6 +40,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||
collection: {
|
||||
show: true,
|
||||
add: (options) => ({ ...options }),
|
||||
remove() {},
|
||||
removeAll() {},
|
||||
},
|
||||
billboard: null,
|
||||
@@ -187,3 +188,28 @@ test('draped mode clamps the marker to the globe whatever the ground says', () =
|
||||
);
|
||||
assert.equal(ground.prepares.length, 0);
|
||||
});
|
||||
|
||||
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
|
||||
const { state, marker } = setup();
|
||||
const removed = [];
|
||||
state.marker.collection.remove = (billboard) => removed.push(billboard);
|
||||
const streetView = { kind: 'view-cone', color: '#4285f4' };
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView);
|
||||
const cone = state.marker.billboard;
|
||||
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4');
|
||||
assert.equal(
|
||||
cone.rotation,
|
||||
-Cesium.Math.toRadians(45),
|
||||
'it turns with the view',
|
||||
);
|
||||
// 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');
|
||||
assert.deepEqual(removed, [cone]);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export const POSITION_PICK_ID = 'sl:pos';
|
||||
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
|
||||
export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
google: '#4285f4',
|
||||
});
|
||||
|
||||
/** Colours every provider shares: the selection highlight and the marker. */
|
||||
@@ -35,3 +36,9 @@ 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;
|
||||
|
||||
/** Above this, a ground click is a misclick, not a street. */
|
||||
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||
|
||||
export const AIM_AT_GROUND_MESSAGE =
|
||||
'Aim the middle of the view at a street to look for imagery there';
|
||||
@@ -0,0 +1,184 @@
|
||||
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
GOOGLE_CREDIT_HTML,
|
||||
GOOGLE_KEY_ID,
|
||||
GOOGLE_LABEL,
|
||||
GOOGLE_NAME,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
GROUND_CLICK_HINT,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
NEAREST_RADIUS_M,
|
||||
NEAREST_TIMEOUT_MS,
|
||||
NO_ANSWER_MESSAGE,
|
||||
PICK_PREFIX,
|
||||
} from './policy.js';
|
||||
|
||||
/** `promise`, unless it times out, `signal` aborts or Google refuses the key. */
|
||||
function settleWithin(promise, { ms, signal, loader }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
|
||||
ms,
|
||||
);
|
||||
const onAbort = () => finish(reject, signal.reason);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const stopAuthWatch = loader.onAuthFailure(() =>
|
||||
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
|
||||
);
|
||||
promise.then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
function finish(settle, value) {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
stopAuthWatch();
|
||||
settle(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Street View provider: Google's panorama viewer and nearest-panorama
|
||||
* lookups. It draws no coverage; a click on a street opens the panorama.
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createGoogleProvider({
|
||||
getApiKey,
|
||||
loader = createMapsLoader({ getApiKey }),
|
||||
}) {
|
||||
if (typeof getApiKey !== 'function')
|
||||
throw new TypeError('Google Street View needs getApiKey');
|
||||
|
||||
return Object.freeze({
|
||||
id: GOOGLE_PROVIDER_ID,
|
||||
name: GOOGLE_NAME,
|
||||
label: GOOGLE_LABEL,
|
||||
requiresKeyId: GOOGLE_KEY_ID,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
|
||||
groundClick: true,
|
||||
// Google's terms forbid Street View beside non-Google maps.
|
||||
requiresMapStack: FOLLOW_MAP_STACK_ID,
|
||||
// Every panorama is billed to the Maps key.
|
||||
defaultOn: false,
|
||||
// All 360°, and no coverage for a date filter to thin.
|
||||
filters: [],
|
||||
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
|
||||
|
||||
create(context) {
|
||||
let keyRequired = !getApiKey();
|
||||
let active = false;
|
||||
let service = null;
|
||||
async function getService() {
|
||||
const lib = await loader.importLibrary('streetView');
|
||||
service ||= new lib.StreetViewService();
|
||||
return service;
|
||||
}
|
||||
const viewer = createGoogleViewer({
|
||||
loader,
|
||||
getService,
|
||||
render: context.services?.render,
|
||||
});
|
||||
const stopAuthWatch = loader.onAuthFailure(() => {
|
||||
// Switched off: its chip says so.
|
||||
if (context.isActive?.())
|
||||
context.actions.reportError(KEY_REJECTED_MESSAGE);
|
||||
context.notify();
|
||||
});
|
||||
|
||||
return {
|
||||
async status() {
|
||||
keyRequired = !getApiKey();
|
||||
context.notify();
|
||||
return { configured: !keyRequired };
|
||||
},
|
||||
|
||||
init() {},
|
||||
|
||||
activate() {
|
||||
active = true;
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
active = false;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
active = false;
|
||||
stopAuthWatch();
|
||||
},
|
||||
|
||||
refreshCoverage() {},
|
||||
|
||||
/** No filter applies to Street View (see `filters`). */
|
||||
setFilter() {},
|
||||
|
||||
coverageStats() {
|
||||
const keyRejected = loader.authFailed();
|
||||
return {
|
||||
count: 0,
|
||||
zoom: null,
|
||||
kind: null,
|
||||
loading: false,
|
||||
hint:
|
||||
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
|
||||
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
|
||||
keyRequired,
|
||||
keyRejected,
|
||||
};
|
||||
},
|
||||
|
||||
handlePick: () => false,
|
||||
|
||||
/**
|
||||
* Nearest Google-collected outdoor panorama, or null. The imagery
|
||||
* filters do not apply (see `filters`).
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
|
||||
signal?.throwIfAborted();
|
||||
// Set once the caller has its answer (aborted, timed out, refused):
|
||||
// a lookup still waiting on Google's script then asks nothing.
|
||||
let settled = false;
|
||||
const lookup = (async () => {
|
||||
const lib = await loader.importLibrary('streetView');
|
||||
await getService();
|
||||
if (settled) return null;
|
||||
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
|
||||
try {
|
||||
const { data } = await service.getPanorama({
|
||||
location: { lat, lng: lon },
|
||||
radius: NEAREST_RADIUS_M,
|
||||
// Both: Google's own imagery, outdoors (sources intersect).
|
||||
sources: [GOOGLE, OUTDOOR],
|
||||
preference: lib.StreetViewPreference.NEAREST,
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ZERO_RESULTS') return null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
const data = await settleWithin(lookup, {
|
||||
ms: NEAREST_TIMEOUT_MS,
|
||||
signal,
|
||||
loader,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const panoId = data?.location?.pano;
|
||||
if (!panoId) return null;
|
||||
viewer.remember(panoId, data);
|
||||
return panoId;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Loads the Google Maps JavaScript API once per page, on first use. Google's
|
||||
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
|
||||
*/
|
||||
|
||||
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
|
||||
const READY_CALLBACK = '__gevGoogleMapsReady';
|
||||
/** The page's one script load, shared by every loader on it. */
|
||||
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
|
||||
|
||||
/**
|
||||
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
|
||||
*/
|
||||
export function createMapsLoader({
|
||||
getApiKey,
|
||||
document = globalThis.document,
|
||||
global = globalThis,
|
||||
}) {
|
||||
let authFailed = false;
|
||||
let authHandlerInstalled = false;
|
||||
const authListeners = new Set();
|
||||
|
||||
/** Once per loader: a retry must not wrap its own handler. */
|
||||
function installAuthHandler() {
|
||||
if (authHandlerInstalled) return;
|
||||
authHandlerInstalled = true;
|
||||
const previous = global.gm_authFailure;
|
||||
global.gm_authFailure = () => {
|
||||
authFailed = true;
|
||||
for (const listener of [...authListeners]) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* a listener's error is its own */
|
||||
}
|
||||
}
|
||||
if (typeof previous === 'function') previous();
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
const key = getApiKey?.();
|
||||
if (!key)
|
||||
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
|
||||
installAuthHandler();
|
||||
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
|
||||
if (global.google?.maps?.importLibrary)
|
||||
return Promise.resolve(global.google.maps);
|
||||
const loading = new Promise((resolve, reject) => {
|
||||
global[READY_CALLBACK] = () => {
|
||||
delete global[READY_CALLBACK];
|
||||
resolve(global.google.maps);
|
||||
};
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
|
||||
key,
|
||||
v: 'weekly',
|
||||
loading: 'async',
|
||||
callback: READY_CALLBACK,
|
||||
})}`;
|
||||
script.onerror = () => {
|
||||
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
||||
script.remove();
|
||||
delete global[READY_CALLBACK];
|
||||
reject(new Error('The Google Maps JavaScript API could not load'));
|
||||
};
|
||||
document.head.append(script);
|
||||
});
|
||||
global[PAGE_LOAD] = loading;
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
async importLibrary(name) {
|
||||
const maps = await load();
|
||||
return maps.importLibrary(name);
|
||||
},
|
||||
authFailed: () => authFailed,
|
||||
onAuthFailure(listener) {
|
||||
authListeners.add(listener);
|
||||
return () => authListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
|
||||
/** A document whose appended scripts the test loads or fails. */
|
||||
function fakePage() {
|
||||
const scripts = [];
|
||||
const document = {
|
||||
createElement: () => {
|
||||
const script = {
|
||||
remove() {
|
||||
scripts.splice(scripts.indexOf(script), 1);
|
||||
},
|
||||
};
|
||||
return script;
|
||||
},
|
||||
head: { append: (script) => scripts.push(script) },
|
||||
};
|
||||
const global = {};
|
||||
const library = { name: 'streetView' };
|
||||
/** Google's script arrived: it defines google.maps and calls back. */
|
||||
const loadScript = () => {
|
||||
global.google = {
|
||||
maps: { importLibrary: async (name) => ({ ...library, name }) },
|
||||
};
|
||||
const callback = new URL(scripts.at(-1).src).searchParams.get('callback');
|
||||
global[callback]();
|
||||
};
|
||||
return { document, global, scripts, loadScript };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('the API loads once, with the key, asynchronously, and hands out libraries', async () => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k-1', ...page });
|
||||
const first = loader.importLibrary('streetView');
|
||||
const second = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1, 'one script for concurrent imports');
|
||||
const url = new URL(page.scripts[0].src);
|
||||
assert.equal(
|
||||
url.origin + url.pathname,
|
||||
'https://maps.googleapis.com/maps/api/js',
|
||||
);
|
||||
assert.equal(url.searchParams.get('key'), 'k-1');
|
||||
assert.equal(url.searchParams.get('loading'), 'async');
|
||||
assert.equal(page.scripts[0].async, true);
|
||||
page.loadScript();
|
||||
assert.equal((await first).name, 'streetView');
|
||||
assert.equal((await second).name, 'streetView');
|
||||
const callback = url.searchParams.get('callback');
|
||||
assert.equal(page.global[callback], undefined, 'the callback is cleaned up');
|
||||
});
|
||||
|
||||
test('two loaders on one page share one script and both get the API', async () => {
|
||||
const page = fakePage();
|
||||
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const one = first.importLibrary('streetView');
|
||||
const two = second.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1, 'one script for the page');
|
||||
page.loadScript();
|
||||
assert.equal((await one).name, 'streetView');
|
||||
assert.equal((await two).name, 'streetView');
|
||||
assert.equal((await first.importLibrary('streetView')).name, 'streetView');
|
||||
});
|
||||
|
||||
test('after a failed script, either loader on the page can retry it', async () => {
|
||||
const page = fakePage();
|
||||
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const failed = first.importLibrary('streetView');
|
||||
await settle();
|
||||
page.scripts[0].onerror();
|
||||
await assert.rejects(failed, /could not load/);
|
||||
const retry = second.importLibrary('streetView');
|
||||
const again = first.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1);
|
||||
page.loadScript();
|
||||
assert.equal((await retry).name, 'streetView');
|
||||
assert.equal((await again).name, 'streetView');
|
||||
});
|
||||
|
||||
test('without a key nothing loads', async () => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({ getApiKey: () => null, ...page });
|
||||
await assert.rejects(loader.importLibrary('streetView'), /not set/);
|
||||
assert.equal(page.scripts.length, 0);
|
||||
});
|
||||
|
||||
test('a script that fails to load can be retried', async () => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const failed = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
page.scripts[0].onerror();
|
||||
await assert.rejects(failed, /could not load/);
|
||||
assert.equal(page.scripts.length, 0, 'the failed script is removed');
|
||||
const retry = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1);
|
||||
page.loadScript();
|
||||
assert.equal((await retry).name, 'streetView');
|
||||
});
|
||||
|
||||
test("Google's refusal of the key reaches every listener and the page's own handler", async () => {
|
||||
const page = fakePage();
|
||||
let before = 0;
|
||||
page.global.gm_authFailure = () => before++;
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const heard = [];
|
||||
const stop = loader.onAuthFailure(() => heard.push('a'));
|
||||
loader.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(loader.authFailed(), false);
|
||||
page.global.gm_authFailure();
|
||||
assert.equal(loader.authFailed(), true);
|
||||
assert.deepEqual(heard, ['a']);
|
||||
assert.equal(before, 1, 'a handler installed earlier still runs');
|
||||
stop();
|
||||
page.global.gm_authFailure();
|
||||
assert.deepEqual(heard, ['a']);
|
||||
});
|
||||
|
||||
test('a retried load still reports a refusal once, and the page handler once', async () => {
|
||||
const page = fakePage();
|
||||
let before = 0;
|
||||
page.global.gm_authFailure = () => before++;
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const heard = [];
|
||||
loader.onAuthFailure(() => heard.push('a'));
|
||||
const failed = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
page.scripts[0].onerror();
|
||||
await assert.rejects(failed);
|
||||
loader.importLibrary('streetView');
|
||||
await settle();
|
||||
page.global.gm_authFailure();
|
||||
assert.deepEqual(heard, ['a']);
|
||||
assert.equal(before, 1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
export const GOOGLE_PROVIDER_ID = 'google';
|
||||
export const GOOGLE_NAME = 'Google Street View';
|
||||
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
|
||||
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
|
||||
export const GOOGLE_KEY_ID = 'google-maps';
|
||||
|
||||
export const PICK_PREFIX = Object.freeze({ root: 'gsv:' });
|
||||
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: PROVIDER_COLORS.google,
|
||||
selected: SHARED_COLORS.selected,
|
||||
});
|
||||
|
||||
export const GOOGLE_CREDIT_HTML =
|
||||
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
|
||||
|
||||
/** Shown when Google refuses the key (gm_authFailure). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
|
||||
|
||||
/** Past these, a nearest lookup, an open or a details lookup has failed. */
|
||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
||||
export const OPEN_TIMEOUT_MS = 20_000;
|
||||
export const DETAILS_TIMEOUT_MS = 10_000;
|
||||
/** Pause after a failed details lookup before a view event can retry. */
|
||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||
|
||||
export const NO_ANSWER_MESSAGE =
|
||||
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
|
||||
|
||||
export const GROUND_CLICK_HINT = 'click a street to open Street View';
|
||||
|
||||
/** Deep link to a panorama on Google Maps (Maps URLs). */
|
||||
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
api: '1',
|
||||
map_action: 'pano',
|
||||
pano: String(panoId),
|
||||
heading: String(Math.round(heading)),
|
||||
pitch: String(Math.round(pitch)),
|
||||
});
|
||||
return `https://www.google.com/maps/@?${params}`;
|
||||
}
|
||||
|
||||
/** Epoch ms for a Street View image date ('YYYY-MM'), or null. */
|
||||
export function imageDateMs(imageDate) {
|
||||
const match = /^(\d{4})-(\d{2})/.exec(String(imageDate ?? ''));
|
||||
if (!match) return null;
|
||||
const month = Number(match[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
return Date.UTC(Number(match[1]), month - 1, 1);
|
||||
}
|
||||
|
||||
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
|
||||
export function creatorFrom(copyright) {
|
||||
const text = String(copyright ?? '')
|
||||
.replace(/^©\s*(\d{4}\s+)?/, '')
|
||||
.replace(/^From the Owner,\s*Photo by:\s*/i, '')
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGoogleProvider } from './index.js';
|
||||
import {
|
||||
GROUND_CLICK_HINT,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
creatorFrom,
|
||||
imageDateMs,
|
||||
streetViewUrl,
|
||||
} from './policy.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
import { requiresKeyIdFor, validateProviders } from '../../registry.js';
|
||||
import { createMapillaryProvider } from '../mapillary/index.js';
|
||||
import {
|
||||
fakeHost,
|
||||
fakeMapsLoader,
|
||||
fakeStreetViewLibrary,
|
||||
} from '../../../../testSupport/googleStreetViewFakes.mjs';
|
||||
|
||||
const PANORAMAS = {
|
||||
fresh: { lat: 38.5816, lng: -121.4944, imageDate: '2025-06' },
|
||||
old: { lat: 38.6, lng: -121.5, imageDate: '2009-03' },
|
||||
};
|
||||
|
||||
function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
|
||||
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS, nearest });
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
let apiKey = key;
|
||||
const def = createGoogleProvider({ getApiKey: () => apiKey, loader });
|
||||
const reported = [];
|
||||
let filter = { pano: 'all', sinceMs: null };
|
||||
let active = false;
|
||||
const context = {
|
||||
services: {},
|
||||
getFilter: () => filter,
|
||||
isActive: () => active,
|
||||
notify() {},
|
||||
actions: { reportError: (message) => reported.push(message) },
|
||||
};
|
||||
const created = def.create(context);
|
||||
// The core switches it on and off; mirror that for isActive().
|
||||
const instance = {
|
||||
...created,
|
||||
activate() {
|
||||
active = true;
|
||||
created.activate();
|
||||
},
|
||||
deactivate() {
|
||||
active = false;
|
||||
created.deactivate();
|
||||
},
|
||||
};
|
||||
return {
|
||||
def,
|
||||
instance,
|
||||
fake,
|
||||
loader,
|
||||
reported,
|
||||
setFilter: (next) => (filter = next),
|
||||
setKey: (next) => (apiKey = next),
|
||||
};
|
||||
}
|
||||
|
||||
test('the provider registers beside Mapillary, off by default, gated on the browser Maps key', () => {
|
||||
const { def } = setup();
|
||||
assert.equal(def.id, 'google');
|
||||
assert.equal(def.label, 'GOOGLE STREET VIEW');
|
||||
assert.deepEqual(def.filters, [], 'no imagery filter applies');
|
||||
assert.deepEqual(def.marker, { kind: 'view-cone', color: '#4285f4' });
|
||||
assert.equal(def.requiresKeyId, 'google-maps');
|
||||
assert.equal(def.defaultOn, false, 'every panorama is billed');
|
||||
assert.equal(def.groundClick, true);
|
||||
assert.equal(def.colors.coverage, PROVIDER_COLORS.google);
|
||||
assert.match(def.credit.html, /Street View imagery © .*Google/);
|
||||
const mapillary = createMapillaryProvider({
|
||||
source: {
|
||||
getStatus() {},
|
||||
getTile() {},
|
||||
getSequenceImages() {},
|
||||
nearestImages() {},
|
||||
},
|
||||
});
|
||||
const both = validateProviders([mapillary, def]);
|
||||
assert.equal(requiresKeyIdFor(both), null, 'each chip gates on its own key');
|
||||
assert.throws(() => createGoogleProvider({}), /getApiKey/);
|
||||
});
|
||||
|
||||
test('status follows the browser Maps key', async () => {
|
||||
const { instance, setKey } = setup({ key: '' });
|
||||
assert.deepEqual(await instance.status(), { configured: false });
|
||||
assert.equal(instance.coverageStats().keyRequired, true);
|
||||
setKey('browser-key');
|
||||
assert.deepEqual(await instance.status(), { configured: true });
|
||||
assert.equal(instance.coverageStats().keyRequired, false);
|
||||
});
|
||||
|
||||
test('the nearest lookup asks Google for its own outdoor imagery within 50 m', async () => {
|
||||
const { instance, fake } = setup();
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 38.58, lon: -121.49 }),
|
||||
'fresh',
|
||||
);
|
||||
const [request] = fake.lookups;
|
||||
assert.deepEqual(request.location, { lat: 38.58, lng: -121.49 });
|
||||
assert.equal(request.radius, 50);
|
||||
assert.deepEqual(request.sources, ['google', 'outdoor']);
|
||||
assert.equal(request.preference, 'nearest');
|
||||
});
|
||||
|
||||
test('no panorama nearby is null; the imagery filters (Mapillary\u2019s) do not apply', async () => {
|
||||
const none = setup({ nearest: () => null });
|
||||
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
|
||||
|
||||
const { instance, setFilter } = setup({ nearest: () => 'old' }); // 2009
|
||||
for (const filter of [
|
||||
{ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) },
|
||||
{ pano: 'flat', sinceMs: null },
|
||||
{ pano: 'pano', sinceMs: Date.UTC(2025, 0, 1) },
|
||||
]) {
|
||||
setFilter(filter);
|
||||
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
|
||||
}
|
||||
assert.equal(instance.coverageStats().hint, '', 'off: no hint');
|
||||
instance.activate();
|
||||
setFilter({ pano: 'flat', sinceMs: null });
|
||||
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
|
||||
});
|
||||
|
||||
test('an aborted lookup gives up before asking Google', async () => {
|
||||
const { instance } = setup();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
instance.nearestImage({ lat: 0, lon: 0 }, { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a key Google refuses reads KEY REJECTED, shows why, and stops lookups', async () => {
|
||||
const { instance, loader, reported } = setup();
|
||||
instance.activate();
|
||||
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
|
||||
loader.refuseKey();
|
||||
const stats = instance.coverageStats();
|
||||
assert.equal(stats.keyRejected, true);
|
||||
assert.equal(stats.error, KEY_REJECTED_MESSAGE);
|
||||
assert.equal(stats.hint, '', 'no "click a street" hint over a dead key');
|
||||
assert.deepEqual(reported, [KEY_REJECTED_MESSAGE]);
|
||||
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
|
||||
message: KEY_REJECTED_MESSAGE,
|
||||
});
|
||||
instance.destroy();
|
||||
assert.equal(loader.listenerCount(), 0, 'destroy stops listening');
|
||||
});
|
||||
|
||||
test('the hint shows only while the provider is on', () => {
|
||||
const { instance } = setup();
|
||||
assert.equal(instance.coverageStats().hint, '');
|
||||
instance.activate();
|
||||
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
|
||||
instance.deactivate();
|
||||
assert.equal(instance.coverageStats().hint, '');
|
||||
});
|
||||
|
||||
test('image dates, photographers and deep links read the way Google writes them', () => {
|
||||
assert.equal(imageDateMs('2024-05'), Date.UTC(2024, 4, 1));
|
||||
assert.equal(imageDateMs('2024-13'), null);
|
||||
assert.equal(imageDateMs(undefined), null);
|
||||
assert.equal(creatorFrom('© 2024 Google'), 'Google');
|
||||
assert.equal(creatorFrom('From the Owner, Photo by: Ada'), 'Ada');
|
||||
assert.equal(creatorFrom(''), null);
|
||||
assert.equal(
|
||||
streetViewUrl('a b', { heading: 90.4, pitch: -9.6 }),
|
||||
'https://www.google.com/maps/@?api=1&map_action=pano&pano=a+b&heading=90&pitch=-10',
|
||||
);
|
||||
});
|
||||
|
||||
test('a lookup Google never answers fails after the timeout, and a refused key fails it at once', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
|
||||
const { instance, fake, loader } = setup();
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise(() => {});
|
||||
const hung = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
|
||||
await assert.rejects(hung, { message: NO_ANSWER_MESSAGE });
|
||||
|
||||
const refused = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
await Promise.resolve();
|
||||
loader.refuseKey();
|
||||
await assert.rejects(refused, { message: KEY_REJECTED_MESSAGE });
|
||||
assert.equal(
|
||||
loader.listenerCount(),
|
||||
1,
|
||||
'only the provider’s own watch is left',
|
||||
);
|
||||
});
|
||||
|
||||
test('aborting a lookup in flight gives up at once', async () => {
|
||||
const { instance, fake } = setup();
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise(() => {});
|
||||
const controller = new AbortController();
|
||||
const lookup = instance.nearestImage(
|
||||
{ lat: 0, lon: 0 },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
await assert.rejects(lookup, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('a lookup cancelled while Google\u2019s script loads asks Google nothing', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
|
||||
const fake = fakeStreetViewLibrary({
|
||||
panoramas: PANORAMAS,
|
||||
nearest: () => 'fresh',
|
||||
});
|
||||
const loader = fakeMapsLoader(fake.library, { deferred: true });
|
||||
const instance = createGoogleProvider({
|
||||
getApiKey: () => 'browser-key',
|
||||
loader,
|
||||
}).create({
|
||||
services: {},
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
actions: { reportError() {} },
|
||||
});
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
// Aborted, then timed out, while the script still loads.
|
||||
const controller = new AbortController();
|
||||
const aborted = instance.nearestImage(
|
||||
{ lat: 0, lon: 0 },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
await assert.rejects(aborted, { name: 'AbortError' });
|
||||
const expired = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
|
||||
await assert.rejects(expired, /did not answer/);
|
||||
// The script arrives: a current lookup still gets its answer.
|
||||
const current = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
loader.release();
|
||||
await flush();
|
||||
assert.equal(await current, 'fresh');
|
||||
assert.equal(fake.lookups.length, 1, 'only the current lookup asked Google');
|
||||
});
|
||||
|
||||
test('a Google outage is an error, not "no imagery here"', async () => {
|
||||
const { instance, fake } = setup();
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error('UNKNOWN_ERROR'), {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
endpoint: 'STREETVIEW_GET_PANORAMA',
|
||||
}),
|
||||
);
|
||||
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
test('a refusal while the provider is off reports no error over another photo', () => {
|
||||
const { instance, loader, reported } = setup();
|
||||
loader.refuseKey();
|
||||
assert.deepEqual(reported, []);
|
||||
assert.equal(
|
||||
instance.coverageStats().keyRejected,
|
||||
true,
|
||||
'its chip still says so',
|
||||
);
|
||||
});
|
||||
|
||||
test('the panorama a lookup found is dated without a second lookup', async () => {
|
||||
const { instance, fake } = setup();
|
||||
await instance.nearestImage({ lat: 38.58, lon: -121.49 });
|
||||
const host = fakeHost();
|
||||
const poses = [];
|
||||
instance.viewer.onPose((pose) => poses.push(pose));
|
||||
await instance.viewer.mount(host);
|
||||
await instance.viewer.open('fresh');
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2025, 5, 1));
|
||||
assert.deepEqual(
|
||||
fake.lookups.filter((request) => request.pano),
|
||||
[],
|
||||
'no getPanorama({pano}) for a panorama the lookup already described',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
DETAILS_RETRY_COOLDOWN_MS,
|
||||
DETAILS_TIMEOUT_MS,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
NO_ANSWER_MESSAGE,
|
||||
OPEN_TIMEOUT_MS,
|
||||
creatorFrom,
|
||||
imageDateMs,
|
||||
streetViewUrl,
|
||||
} from './policy.js';
|
||||
|
||||
const PANORAMA_OPTIONS = Object.freeze({
|
||||
visible: false,
|
||||
// The caption has the address; Google's card covers a docked panorama.
|
||||
addressControl: false,
|
||||
linksControl: true,
|
||||
panControl: true,
|
||||
zoomControl: true,
|
||||
clickToGo: true,
|
||||
showRoadLabels: true,
|
||||
// The panel has EXPAND and ×.
|
||||
fullscreenControl: false,
|
||||
enableCloseButton: false,
|
||||
motionTracking: false,
|
||||
motionTrackingControl: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Viewer adapter over Google's StreetViewPanorama: emits a pose for every
|
||||
* panorama and view change, including steps along Google's arrows.
|
||||
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, getService?: () => Promise<object>, render?: object}} options
|
||||
* @returns {import('../../registry.js').ViewerAdapter & {remember: Function}}
|
||||
*/
|
||||
export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
let library = null;
|
||||
let panorama = null;
|
||||
let element = null;
|
||||
let host = null;
|
||||
let service = null;
|
||||
let pendingOpen = null;
|
||||
let cancelShown = null;
|
||||
/** Bumped by `unmount`: a construction it overtook builds nothing. */
|
||||
let generation = 0;
|
||||
let subscriptions = [];
|
||||
const described = new Map();
|
||||
/** Panorama id → its lookup in flight; a late answer to a dropped one is ignored. */
|
||||
const lookingUp = new Map();
|
||||
const retryAfter = new Map();
|
||||
const listeners = new Set();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('google-street-view');
|
||||
}
|
||||
|
||||
async function ensureLibrary() {
|
||||
library ||= await loader.importLibrary('streetView');
|
||||
return library;
|
||||
}
|
||||
|
||||
function emit(pose) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function metaFrom(data) {
|
||||
return {
|
||||
capturedAt: imageDateMs(data?.imageDate),
|
||||
creator: creatorFrom(data?.copyright),
|
||||
title: data?.location?.description || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a panorama's date, photographer and address once, then re-emit
|
||||
* the pose. A failed or timed-out lookup can retry after the cooldown.
|
||||
*/
|
||||
async function describe(panoId) {
|
||||
if (described.has(panoId) || lookingUp.has(panoId)) return;
|
||||
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
|
||||
const lookup = { timer: null };
|
||||
lookingUp.set(panoId, lookup);
|
||||
const current = () => lookingUp.get(panoId) === lookup;
|
||||
try {
|
||||
const { data } = await new Promise((resolve, reject) => {
|
||||
lookup.timer = setTimeout(
|
||||
() => reject(new Error(NO_ANSWER_MESSAGE)),
|
||||
DETAILS_TIMEOUT_MS,
|
||||
);
|
||||
(async () => {
|
||||
service ||= getService
|
||||
? await getService()
|
||||
: new (await ensureLibrary()).StreetViewService();
|
||||
return service.getPanorama({ pano: panoId });
|
||||
})().then(resolve, reject);
|
||||
});
|
||||
if (!current()) return;
|
||||
described.set(panoId, metaFrom(data));
|
||||
retryAfter.delete(panoId);
|
||||
} catch {
|
||||
if (current())
|
||||
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(lookup.timer);
|
||||
if (current()) lookingUp.delete(panoId);
|
||||
}
|
||||
publishPose();
|
||||
}
|
||||
|
||||
function releaseLookups() {
|
||||
for (const lookup of lookingUp.values()) clearTimeout(lookup.timer);
|
||||
lookingUp.clear();
|
||||
}
|
||||
|
||||
function publishPose() {
|
||||
const panoId = panorama?.getPano?.();
|
||||
const latLng = panorama?.getPosition?.();
|
||||
if (!panoId || !latLng || pendingOpen === null) return;
|
||||
const meta = described.get(panoId);
|
||||
if (!meta) describe(panoId);
|
||||
const pov = panorama.getPov() || {};
|
||||
emit({
|
||||
providerId: GOOGLE_PROVIDER_ID,
|
||||
imageId: panoId,
|
||||
position: { lon: latLng.lng(), lat: latLng.lat() },
|
||||
bearing: Number.isFinite(pov.heading) ? pov.heading : null,
|
||||
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: true,
|
||||
capturedAt: meta?.capturedAt ?? null,
|
||||
capturedAtPrecision: 'month',
|
||||
creator: meta?.creator ?? null,
|
||||
title: meta?.title ?? null,
|
||||
sequenceId: null,
|
||||
externalUrl: streetViewUrl(panoId, {
|
||||
heading: pov.heading || 0,
|
||||
pitch: pov.pitch || 0,
|
||||
}),
|
||||
});
|
||||
requestRender();
|
||||
}
|
||||
|
||||
async function ensurePanorama(target) {
|
||||
if (panorama && host === target) return panorama;
|
||||
const built = generation;
|
||||
const { StreetViewPanorama } = await ensureLibrary();
|
||||
if (built !== generation) throw new Error('Street View was unmounted');
|
||||
if (panorama && host === target) return panorama;
|
||||
destroyPanorama();
|
||||
element = target.ownerDocument.createElement('div');
|
||||
element.className = 'sl-google-panorama';
|
||||
element.style.cssText = 'position:absolute;inset:0;';
|
||||
target.append(element);
|
||||
host = target;
|
||||
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
|
||||
// Not pano_changed: it can fire with the old panorama's position.
|
||||
subscriptions = ['position_changed', 'pov_changed'].map((name) =>
|
||||
panorama.addListener(name, () => publishPose()),
|
||||
);
|
||||
return panorama;
|
||||
}
|
||||
|
||||
function destroyPanorama() {
|
||||
for (const subscription of subscriptions) subscription.remove();
|
||||
subscriptions = [];
|
||||
try {
|
||||
panorama?.setVisible(false);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
element?.remove();
|
||||
panorama = null;
|
||||
element = null;
|
||||
host = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once `panoId` shows (OK status, or a new position when the
|
||||
* status stays OK); rejects on no imagery, a refused key or no answer.
|
||||
*/
|
||||
function shown(instance, panoId) {
|
||||
if (instance.getPano() === panoId && instance.getStatus() === 'OK')
|
||||
return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
|
||||
OPEN_TIMEOUT_MS,
|
||||
);
|
||||
const stopAuthWatch = loader.onAuthFailure(() =>
|
||||
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
|
||||
);
|
||||
cancelShown = () => finish(resolve);
|
||||
// At a position event the status may still be the last panorama's failure.
|
||||
const onStatus = () => {
|
||||
if (pendingOpen !== panoId) return finish(resolve);
|
||||
const status = instance.getStatus();
|
||||
if (status === 'OK') finish(resolve);
|
||||
else if (status)
|
||||
finish(
|
||||
reject,
|
||||
new Error('Street View has no imagery for this place'),
|
||||
);
|
||||
};
|
||||
const onPosition = () => {
|
||||
if (pendingOpen !== panoId || instance.getStatus() === 'OK')
|
||||
finish(resolve);
|
||||
};
|
||||
const subscriptions = [
|
||||
instance.addListener('status_changed', onStatus),
|
||||
instance.addListener('position_changed', onPosition),
|
||||
];
|
||||
function finish(settle, value) {
|
||||
cancelShown = null;
|
||||
clearTimeout(timer);
|
||||
for (const subscription of subscriptions) subscription.remove();
|
||||
stopAuthWatch();
|
||||
settle(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async mount(target) {
|
||||
if (!target) throw new Error('Street Level viewer has no host element');
|
||||
await ensurePanorama(target);
|
||||
},
|
||||
|
||||
async open(imageId) {
|
||||
const panoId = String(imageId);
|
||||
if (!host) throw new Error('Street View is not mounted');
|
||||
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
|
||||
pendingOpen = panoId;
|
||||
const instance = await ensurePanorama(host);
|
||||
if (pendingOpen !== panoId) return;
|
||||
const ready = shown(instance, panoId);
|
||||
instance.setPano(panoId);
|
||||
instance.setVisible(true);
|
||||
await ready;
|
||||
if (pendingOpen !== panoId) return;
|
||||
publishPose();
|
||||
},
|
||||
|
||||
close() {
|
||||
pendingOpen = null;
|
||||
cancelShown?.();
|
||||
try {
|
||||
panorama?.setVisible(false);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
},
|
||||
|
||||
unmount() {
|
||||
pendingOpen = null;
|
||||
cancelShown?.();
|
||||
releaseLookups();
|
||||
generation++;
|
||||
destroyPanorama();
|
||||
},
|
||||
|
||||
resize() {
|
||||
if (!panorama) return;
|
||||
try {
|
||||
globalThis.google?.maps?.event?.trigger(panorama, 'resize');
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
/** Seed the details cache from a nearest lookup's answer. */
|
||||
remember(panoId, data) {
|
||||
if (panoId && data) described.set(String(panoId), metaFrom(data));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import { DETAILS_RETRY_COOLDOWN_MS, DETAILS_TIMEOUT_MS } from './policy.js';
|
||||
import {
|
||||
fakeHost,
|
||||
fakeMapsLoader,
|
||||
fakeStreetViewLibrary,
|
||||
} from '../../../../testSupport/googleStreetViewFakes.mjs';
|
||||
|
||||
const PANORAMAS = {
|
||||
'pano-a': {
|
||||
lat: 38.5816,
|
||||
lng: -121.4944,
|
||||
imageDate: '2024-05',
|
||||
copyright: '© 2024 Google',
|
||||
description: '10th St',
|
||||
},
|
||||
'pano-b': {
|
||||
lat: 38.5817,
|
||||
lng: -121.4944,
|
||||
imageDate: '2019-08',
|
||||
copyright: 'From the Owner, Photo by: Ada',
|
||||
},
|
||||
};
|
||||
|
||||
function setup() {
|
||||
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
const viewer = createGoogleViewer({ loader });
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
const host = fakeHost();
|
||||
return { fake, loader, viewer, poses, host };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('opening a panorama shows it and emits a provider-neutral pose', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
assert.equal(host.children.length, 1, 'renders in its own element');
|
||||
const [panorama] = fake.built;
|
||||
assert.equal(panorama.options.fullscreenControl, false);
|
||||
assert.equal(panorama.options.addressControl, false, 'the caption has it');
|
||||
assert.equal(panorama.options.enableCloseButton, false);
|
||||
assert.equal(panorama.visible, false, 'nothing shows before an open');
|
||||
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(panorama.visible, true);
|
||||
await settle(); // the pose is dated once its lookup answers
|
||||
const pose = poses.at(-1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
providerId: pose.providerId,
|
||||
imageId: pose.imageId,
|
||||
position: pose.position,
|
||||
isPano: pose.isPano,
|
||||
capturedAt: pose.capturedAt,
|
||||
capturedAtPrecision: pose.capturedAtPrecision,
|
||||
creator: pose.creator,
|
||||
title: pose.title,
|
||||
},
|
||||
{
|
||||
providerId: 'google',
|
||||
imageId: 'pano-a',
|
||||
position: { lon: -121.4944, lat: 38.5816 },
|
||||
isPano: true,
|
||||
capturedAt: Date.UTC(2024, 4, 1),
|
||||
capturedAtPrecision: 'month',
|
||||
creator: 'Google',
|
||||
title: '10th St',
|
||||
},
|
||||
);
|
||||
assert.match(pose.externalUrl, /map_action=pano&pano=pano-a/);
|
||||
});
|
||||
|
||||
test('dragging the view and stepping along an arrow both emit poses; the date is looked up once per panorama', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
const [panorama] = fake.built;
|
||||
panorama.setPov({ heading: 90, pitch: -10 });
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
[poses.at(-1).bearing, poses.at(-1).tilt],
|
||||
[90, -10],
|
||||
'the view follows the drag',
|
||||
);
|
||||
// Google's own arrow: the panorama changes without an open.
|
||||
panorama.setPano('pano-b');
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'pano-b');
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
const dateLookups = fake.lookups.filter((request) => request.pano);
|
||||
assert.deepEqual(
|
||||
dateLookups.map((request) => request.pano),
|
||||
['pano-a', 'pano-b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a panorama Google has no imagery for fails the open', async () => {
|
||||
const { viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await assert.rejects(viewer.open('gone'), /no imagery/);
|
||||
});
|
||||
|
||||
test('a closed panorama is hidden and its late view changes emit nothing', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
const [panorama] = fake.built;
|
||||
viewer.close();
|
||||
assert.equal(panorama.visible, false);
|
||||
const count = poses.length;
|
||||
panorama.setPov({ heading: 180, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(poses.length, count);
|
||||
});
|
||||
|
||||
test('an open overtaken by a newer one only shows the newer panorama', async () => {
|
||||
const { viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const first = viewer.open('pano-a');
|
||||
const second = viewer.open('pano-b');
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
[...new Set(poses.map((pose) => pose.imageId))].at(-1),
|
||||
'pano-b',
|
||||
);
|
||||
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
|
||||
});
|
||||
|
||||
test('unmounting removes the element and every listener', async () => {
|
||||
const { fake, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
assert.ok(panorama.listenerCount() > 0);
|
||||
viewer.unmount();
|
||||
assert.equal(host.children.length, 0);
|
||||
assert.equal(panorama.listenerCount(), 0);
|
||||
await assert.rejects(viewer.open('pano-a'), /not mounted/);
|
||||
});
|
||||
|
||||
test('there is no prewarm: Google\u2019s script loads on first use only, never on another map', () => {
|
||||
const { viewer, loader } = setup();
|
||||
assert.equal(viewer.prewarm, undefined);
|
||||
assert.deepEqual(loader.imports, []);
|
||||
});
|
||||
|
||||
test('a key Google refuses fails the open at once, not after the timeout', async () => {
|
||||
const { fake, loader, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
// Google refuses the key while the panorama loads; it never reports OK.
|
||||
panorama.setPano = (id) => {
|
||||
panorama.pano = id;
|
||||
queueMicrotask(() => loader.refuseKey());
|
||||
};
|
||||
await assert.rejects(viewer.open('pano-a'), /Maps JavaScript API is enabled/);
|
||||
await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/);
|
||||
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind');
|
||||
});
|
||||
|
||||
test('a viewer unmounted while the library loads builds no panorama (none is billed)', async () => {
|
||||
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
|
||||
const loader = fakeMapsLoader(fake.library, { deferred: true });
|
||||
const viewer = createGoogleViewer({ loader });
|
||||
const host = fakeHost();
|
||||
const mounting = viewer.mount(host);
|
||||
viewer.unmount();
|
||||
loader.release();
|
||||
await assert.rejects(mounting, /unmounted/);
|
||||
assert.equal(fake.built.length, 0);
|
||||
assert.equal(host.children.length, 0);
|
||||
});
|
||||
|
||||
test('a date that arrives after the photo was closed emits nothing', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
const [panorama] = fake.built;
|
||||
let answer;
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
panorama.setPano('pano-b'); // Google's arrow; its date is still unknown
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'pano-b', 'the step shows at once');
|
||||
assert.equal(
|
||||
poses.at(-1).capturedAt,
|
||||
null,
|
||||
'undated until the lookup answers',
|
||||
);
|
||||
viewer.close();
|
||||
const count = poses.length;
|
||||
answer({ data: { imageDate: '2019-08' } });
|
||||
await settle();
|
||||
assert.equal(poses.length, count);
|
||||
});
|
||||
|
||||
test('an open never waits on the date lookup', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise(() => {}); // the metadata never answers
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
viewer.unmount();
|
||||
});
|
||||
|
||||
test('closing while a panorama loads settles the open and drops its listeners', async () => {
|
||||
const { fake, loader, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
const before = panorama.listenerCount();
|
||||
panorama.setPano = (id) => {
|
||||
panorama.pano = id; // still loading
|
||||
};
|
||||
const opening = viewer.open('pano-a');
|
||||
await settle();
|
||||
viewer.close();
|
||||
await opening;
|
||||
assert.equal(panorama.listenerCount(), before);
|
||||
assert.equal(loader.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('a second open whose status stays OK still resolves, on the new position', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(fake.built[0].getStatus(), 'OK');
|
||||
await viewer.open('pano-b'); // OK → OK: no status_changed
|
||||
assert.equal(poses.at(-1).imageId, 'pano-b');
|
||||
});
|
||||
|
||||
test('Google answering with a newer id for the place shows that id', async () => {
|
||||
const fake = fakeStreetViewLibrary({
|
||||
panoramas: PANORAMAS,
|
||||
substitutes: { 'old-id': 'pano-a' },
|
||||
});
|
||||
const viewer = createGoogleViewer({ loader: fakeMapsLoader(fake.library) });
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
await viewer.mount(fakeHost());
|
||||
await viewer.open('old-id');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
});
|
||||
|
||||
test('a panorama that never shows fails after the timeout and leaves no listener', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { OPEN_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
|
||||
const { fake, loader, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
const before = panorama.listenerCount();
|
||||
panorama.setPano = (id) => {
|
||||
panorama.pano = id; // Google never answers
|
||||
};
|
||||
const opening = viewer.open('pano-a');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(OPEN_TIMEOUT_MS);
|
||||
await assert.rejects(opening, { message: NO_ANSWER_MESSAGE });
|
||||
assert.equal(panorama.listenerCount(), before);
|
||||
assert.equal(loader.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('failed details lookups cool down through repeated drags and retry after the pause', async (t) => {
|
||||
let now = 1_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
let failures = 2;
|
||||
let calls = 0;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
calls++;
|
||||
if (failures-- > 0) return Promise.reject(new Error('offline'));
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
|
||||
assert.equal(calls, 1);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
for (let heading = 0; heading < 20; heading++) {
|
||||
fake.built[0].setPov({ heading, pitch: 0 });
|
||||
await settle();
|
||||
}
|
||||
assert.equal(calls, attempt + 1, 'drags do not retry during the pause');
|
||||
assert.equal(poses.at(-1).bearing, 19, 'pose updates still follow drags');
|
||||
now += DETAILS_RETRY_COOLDOWN_MS - 1;
|
||||
fake.built[0].setPov({ heading: 20, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, attempt + 1, 'still paused just before the deadline');
|
||||
now++;
|
||||
fake.built[0].setPov({ heading: 21, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, attempt + 2, 'one retry at the deadline');
|
||||
}
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
|
||||
now += DETAILS_RETRY_COOLDOWN_MS;
|
||||
fake.built[0].setPov({ heading: 10, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, 3, 'successful details stay cached');
|
||||
});
|
||||
|
||||
test('a failed panorama does not delay details for another panorama', async (t) => {
|
||||
t.mock.method(Date, 'now', () => 1_000);
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
if (request.pano === 'pano-a') return Promise.reject(new Error('offline'));
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-a');
|
||||
fake.built[0].setPano('pano-b');
|
||||
await settle();
|
||||
assert.deepEqual(requests, ['pano-a', 'pano-b']);
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
fake.built[0].setPano('pano-a');
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
requests,
|
||||
['pano-a', 'pano-b'],
|
||||
'returning respects the pause',
|
||||
);
|
||||
});
|
||||
|
||||
test('a retry after a failed open is not failed by the old panorama\u2019s status', async () => {
|
||||
const { viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await assert.rejects(viewer.open('gone'), /no imagery/);
|
||||
// The new panorama moves first; its OK status follows.
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
});
|
||||
|
||||
test('a details lookup that never answers times out, cools down and retries', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000 });
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
let hang = true;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
return hang ? new Promise(() => {}) : getPanorama.call(this, request);
|
||||
};
|
||||
const opening = viewer.open('pano-a');
|
||||
await flush();
|
||||
await opening;
|
||||
assert.deepEqual(requests, ['pano-a']);
|
||||
fake.built[0].setPov({ heading: 5, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a'], 'one lookup in flight at a time');
|
||||
t.mock.timers.tick(DETAILS_TIMEOUT_MS);
|
||||
await flush();
|
||||
hang = false;
|
||||
fake.built[0].setPov({ heading: 6, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a'], 'the cooldown follows a timeout');
|
||||
t.mock.timers.tick(DETAILS_RETRY_COOLDOWN_MS);
|
||||
fake.built[0].setPov({ heading: 7, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a', 'pano-a']);
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
|
||||
assert.equal(poses.at(-1).bearing, 7);
|
||||
});
|
||||
|
||||
test('a remount looks up again a panorama whose lookup hung', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
let answerHung;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
if (requests.length === 1)
|
||||
return new Promise((resolve) => {
|
||||
answerHung = () => resolve(getPanorama.call(this, request));
|
||||
});
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-b');
|
||||
viewer.unmount();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-b');
|
||||
await settle();
|
||||
assert.deepEqual(requests, ['pano-b', 'pano-b']);
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
// The hung answer arriving now is for a viewer that is gone: dropped.
|
||||
const count = poses.length;
|
||||
answerHung();
|
||||
await settle();
|
||||
assert.equal(poses.length, count);
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||
|
||||
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
|
||||
|
||||
export function filtersOf(provider) {
|
||||
return provider?.filters ?? IMAGERY_FILTERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract an imagery provider registers with the Street Level layer.
|
||||
*
|
||||
@@ -11,10 +17,15 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
|
||||
* @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 {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @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.
|
||||
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain, 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.
|
||||
@@ -65,6 +76,8 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||
* @property {number|null} altitude
|
||||
* @property {boolean} isPano
|
||||
* @property {number|null} capturedAt Epoch milliseconds.
|
||||
* @property {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day').
|
||||
* @property {string|null} [title] A place name for the caption, e.g. the address.
|
||||
* @property {string|null} creator
|
||||
* @property {string|null} sequenceId
|
||||
* @property {string} externalUrl Deep link to the image on the provider's site.
|
||||
@@ -124,6 +137,26 @@ export function validateProviders(providers) {
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label} needs a coverage colour`,
|
||||
);
|
||||
if (
|
||||
provider.marker !== undefined &&
|
||||
!(
|
||||
provider.marker?.kind === 'view-cone' &&
|
||||
typeof provider.marker.color === 'string'
|
||||
)
|
||||
)
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: marker must be {kind: 'view-cone', color}`,
|
||||
);
|
||||
if (
|
||||
provider.filters !== undefined &&
|
||||
!(
|
||||
Array.isArray(provider.filters) &&
|
||||
provider.filters.every((key) => IMAGERY_FILTERS.includes(key))
|
||||
)
|
||||
)
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`,
|
||||
);
|
||||
ids.add(provider.id);
|
||||
prefixes.push(provider.pickPrefix);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
|
||||
const def = (overrides = {}) => ({
|
||||
id: 'mapillary',
|
||||
@@ -101,3 +101,20 @@ test('requiresKeyIdFor is the shared key, or null once providers differ', () =>
|
||||
);
|
||||
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
|
||||
});
|
||||
|
||||
test('a provider may narrow the filters it honours and pick a view-cone marker', () => {
|
||||
assert.deepEqual(filtersOf(def()), ['pano', 'since'], 'both by default');
|
||||
const narrowed = def({
|
||||
filters: [],
|
||||
marker: { kind: 'view-cone', color: '#4285f4' },
|
||||
});
|
||||
assert.deepEqual(filtersOf(validateProviders([narrowed])[0]), []);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ filters: ['colour'] })]),
|
||||
/filters must list pano or since/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
|
||||
/marker must be/,
|
||||
);
|
||||
});
|
||||
@@ -21,7 +21,11 @@ export function createSelection({ state, parts }) {
|
||||
? picking.resolvePickId(picked)
|
||||
: picked?.id;
|
||||
const route = parts.router.resolve(id);
|
||||
if (!route?.instance) return;
|
||||
// Not ours: the ground handler decides whether it is the map itself.
|
||||
if (!route?.instance) {
|
||||
parts.openAtGround?.(click.position, picked);
|
||||
return;
|
||||
}
|
||||
const entry = state.providers.get(route.providerId);
|
||||
if (!entry?.on) return;
|
||||
route.instance.handlePick(route.id);
|
||||
|
||||
@@ -34,6 +34,7 @@ function harness({ selected = false } = {}) {
|
||||
removeListener(type, listener, options);
|
||||
};
|
||||
const sequences = { selected, cleared: 0 };
|
||||
const groundClicks = [];
|
||||
const picks = [];
|
||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
||||
const scene = { under: null };
|
||||
@@ -66,6 +67,8 @@ function harness({ selected = false } = {}) {
|
||||
parts: {
|
||||
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;
|
||||
@@ -76,6 +79,9 @@ function harness({ selected = false } = {}) {
|
||||
const onMove = state.clickHandler.getInputAction(
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
const onClick = state.clickHandler.getInputAction(
|
||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
return {
|
||||
state,
|
||||
sequences,
|
||||
@@ -97,6 +103,8 @@ function harness({ selected = false } = {}) {
|
||||
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();
|
||||
},
|
||||
@@ -256,3 +264,21 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.click(5);
|
||||
h.scene.under = 'flight:abc';
|
||||
h.click(6);
|
||||
assert.deepEqual(
|
||||
h.groundClicks.map(({ position, picked }) => [position.x, picked?.id]),
|
||||
[
|
||||
[5, undefined],
|
||||
[6, 'flight:abc'],
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,12 @@ export function freshStreet() {
|
||||
altitude: null,
|
||||
isPano: false,
|
||||
capturedAt: null,
|
||||
/** 'day', or 'month' for providers that date images by month (Google). */
|
||||
capturedAtPrecision: 'day',
|
||||
/** A place name for the caption (Google's address), or null. */
|
||||
title: null,
|
||||
/** Whether the open viewer has FIT/FILL render modes. */
|
||||
renderModes: true,
|
||||
sequenceId: null,
|
||||
creator: null,
|
||||
externalUrl: null,
|
||||
@@ -50,10 +56,13 @@ export function createState({ services }) {
|
||||
...freshStreet(),
|
||||
},
|
||||
|
||||
/** 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 },
|
||||
marker: { collection: null, billboard: null, look: null },
|
||||
clickHandler: null,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { COLORS } from './policy.js';
|
||||
* @property {string} hint
|
||||
* @property {string|null} error
|
||||
* @property {string} color The source's one colour.
|
||||
* @property {boolean} [groundClick]
|
||||
* @property {Array<'pano'|'since'>} [filters]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,7 @@ export function summarizeCoverage(providers) {
|
||||
hint:
|
||||
providers.length === 0
|
||||
? 'No street-level imagery providers configured'
|
||||
: active.find((p) => p.hint)?.hint || '',
|
||||
: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
|
||||
error: active.find((p) => p.error)?.error || null,
|
||||
keyRequired:
|
||||
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||
@@ -41,13 +43,39 @@ export function summarizeCoverage(providers) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switched-off providers that would draw with a key already set, offered
|
||||
* while the switched-on ones lack theirs.
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
* @returns {Array<{id: string, label: string, requiresKeyId: string|null}>}
|
||||
*/
|
||||
export function keyAlternatives(providers) {
|
||||
if (!summarizeCoverage(providers).keyRequired) return [];
|
||||
return providers
|
||||
.filter((p) => !p.on && p.configured === true && p.keyRequired !== true)
|
||||
.map(({ id, label, requiresKeyId }) => ({ id, label, requiresKeyId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter controls a switched-on provider honours (both when none is on).
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
*/
|
||||
export function filterControls(providers) {
|
||||
const active = providers.filter((p) => p.on);
|
||||
const honoured = (key) =>
|
||||
active.length === 0 ||
|
||||
active.some((p) => (p.filters ?? ['pano', 'since']).includes(key));
|
||||
return { pano: honoured('pano'), since: honoured('since') };
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel snapshot from core state and provider snapshots. The legend has
|
||||
* one swatch per active source, then the shared selection colour.
|
||||
* one swatch per active source that draws coverage lines, then Selected.
|
||||
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||
*/
|
||||
export function composeUIState({
|
||||
enabled,
|
||||
groundClickReady = false,
|
||||
filter,
|
||||
providers,
|
||||
street,
|
||||
@@ -57,18 +85,23 @@ export function composeUIState({
|
||||
const active = providers.filter((p) => p.on);
|
||||
const { keyRequired, keyRejected, ...coverage } =
|
||||
summarizeCoverage(providers);
|
||||
const legend = active.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
color: provider.color,
|
||||
}));
|
||||
if (active.length)
|
||||
const legend = active
|
||||
.filter((provider) => provider.groundClick !== true)
|
||||
.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
color: provider.color,
|
||||
}));
|
||||
if (legend.length)
|
||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||
return {
|
||||
enabled,
|
||||
groundClickReady: groundClickReady === true,
|
||||
keyRequired,
|
||||
keyAlternatives: keyAlternatives(providers),
|
||||
keyRejected,
|
||||
filter: { ...filter },
|
||||
filterControls: filterControls(providers),
|
||||
providers: providers.map((p) => ({ ...p })),
|
||||
coverage,
|
||||
legend,
|
||||
|
||||
@@ -85,6 +85,49 @@ test('the legend is one swatch per active source, in its colour, then Selected',
|
||||
);
|
||||
});
|
||||
|
||||
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) =>
|
||||
composeUIState({ ...base, providers }).filterControls;
|
||||
assert.deepEqual(controls([streetView]), { pano: false, since: false });
|
||||
assert.deepEqual(controls([provider(), streetView]), {
|
||||
pano: true,
|
||||
since: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
controls([provider({ on: false }), streetView]),
|
||||
{ pano: false, since: false },
|
||||
'Mapillary switched off',
|
||||
);
|
||||
assert.deepEqual(
|
||||
controls([provider({ on: false }), { ...streetView, on: false }]),
|
||||
{ pano: true, since: true },
|
||||
'nothing lit: both, ready for the first chip',
|
||||
);
|
||||
});
|
||||
|
||||
test('hint and error come from the first active provider that has one', () => {
|
||||
const ui = composeUIState({
|
||||
...base,
|
||||
@@ -149,3 +192,46 @@ test('the surface mode defaults to draped and passes through', () => {
|
||||
'terrain',
|
||||
);
|
||||
});
|
||||
|
||||
test('every switched-on provider’s hint is kept, once each', async () => {
|
||||
const { summarizeCoverage } = await import('./uiState.js');
|
||||
const { providerSnapshot } =
|
||||
await import('../../testSupport/streetLevelFakes.mjs');
|
||||
const summary = summarizeCoverage([
|
||||
providerSnapshot({ hint: 'zoom in for sequences' }),
|
||||
providerSnapshot({
|
||||
id: 'google',
|
||||
hint: 'click a street to open Street View',
|
||||
}),
|
||||
providerSnapshot({ id: 'off', on: false, hint: 'not shown' }),
|
||||
providerSnapshot({ id: 'dup', hint: 'zoom in for sequences' }),
|
||||
]);
|
||||
assert.equal(
|
||||
summary.hint,
|
||||
'zoom in for sequences · click a street to open Street View',
|
||||
);
|
||||
});
|
||||
|
||||
test('a key-gated layer offers the switched-off sources that would draw with a key already set', async () => {
|
||||
const { keyAlternatives } = await import('./uiState.js');
|
||||
const mapillary = providerSnapshot({ keyRequired: true, configured: false });
|
||||
const streetView = providerSnapshot({
|
||||
id: 'google',
|
||||
label: 'STREET VIEW',
|
||||
on: false,
|
||||
configured: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
});
|
||||
assert.deepEqual(keyAlternatives([mapillary, streetView]), [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
]);
|
||||
// Nothing to offer: Mapillary has its key, or Street View has none either.
|
||||
assert.deepEqual(keyAlternatives([providerSnapshot(), streetView]), []);
|
||||
assert.deepEqual(
|
||||
keyAlternatives([
|
||||
mapillary,
|
||||
{ ...streetView, configured: false, keyRequired: true },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -33,11 +33,18 @@ export function createViewerHost({ state, parts }) {
|
||||
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
|
||||
isPano: pose.isPano === true,
|
||||
capturedAt: pose.capturedAt ?? null,
|
||||
capturedAtPrecision:
|
||||
pose.capturedAtPrecision === 'month' ? 'month' : 'day',
|
||||
title: pose.title || null,
|
||||
sequenceId: pose.sequenceId ?? null,
|
||||
creator: pose.creator || null,
|
||||
externalUrl: pose.externalUrl || null,
|
||||
});
|
||||
parts.marker.set(state.street.position, state.street.bearing);
|
||||
parts.marker.set(
|
||||
state.street.position,
|
||||
state.street.bearing,
|
||||
providerEntry(pose.providerId)?.def.marker,
|
||||
);
|
||||
parts.follow.followCamera();
|
||||
// Select the sequence only once the image is on screen, so its lookup
|
||||
// never competes with the image download.
|
||||
@@ -68,6 +75,12 @@ export function createViewerHost({ state, parts }) {
|
||||
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);
|
||||
@@ -128,6 +141,8 @@ export function createViewerHost({ state, parts }) {
|
||||
try {
|
||||
const adapter = await mount(entry);
|
||||
if (!current()) return false;
|
||||
// Per open: close() resets it, and a reopen skips mount().
|
||||
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);
|
||||
@@ -183,14 +198,17 @@ export function createViewerHost({ state, parts }) {
|
||||
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) continue;
|
||||
// Switched off while it loaded: release it now rather than keep it.
|
||||
if (!state.enabled || !entry.on) adapter.unmount();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,3 +576,86 @@ test('a photo asks for the camera when it starts opening and frames with that ti
|
||||
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. */
|
||||
function twoProviders() {
|
||||
const mapillary = fakeAdapter();
|
||||
mapillary.prewarm = async () => {};
|
||||
mapillary.setRenderMode = () => {};
|
||||
const google = fakeAdapter();
|
||||
google.prewarm = async () => {};
|
||||
const h = harness(mapillary);
|
||||
h.state.providers.set('google', {
|
||||
def: { id: 'google', name: 'Google Street View', label: 'STREET VIEW' },
|
||||
instance: { viewer: google },
|
||||
on: true,
|
||||
});
|
||||
h.state.providers.get('mapillary').on = true;
|
||||
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');
|
||||
assert.equal(state.street.renderModes, true);
|
||||
await host.open('google', 'g-1');
|
||||
assert.equal(state.street.renderModes, false, 'Google always fills');
|
||||
});
|
||||
|
||||
test('a pose can date its image by month and name its place', async () => {
|
||||
const { state, host, google } = twoProviders();
|
||||
await host.open('google', 'g-1');
|
||||
google.emitPose('g-1', {
|
||||
providerId: 'google',
|
||||
capturedAtPrecision: 'month',
|
||||
title: '1014 10th St',
|
||||
capturedAt: Date.UTC(2023, 0, 1),
|
||||
});
|
||||
assert.equal(state.street.capturedAtPrecision, 'month');
|
||||
assert.equal(state.street.title, '1014 10th St');
|
||||
host.close();
|
||||
assert.deepEqual(
|
||||
[state.street.capturedAtPrecision, state.street.title],
|
||||
['day', null],
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
assert.equal(state.street.renderModes, false);
|
||||
host.close();
|
||||
await host.open('google', 'g-2');
|
||||
assert.equal(state.street.renderModes, false);
|
||||
});
|
||||
@@ -50,6 +50,26 @@ export function extractStationsArray(payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
|
||||
function stationName(value) {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (!Array.isArray(value)) return '';
|
||||
|
||||
let fallback = '';
|
||||
for (const translation of value) {
|
||||
if (typeof translation?.text !== 'string') continue;
|
||||
const text = translation.text.trim();
|
||||
if (!text) continue;
|
||||
if (!fallback) fallback = text;
|
||||
if (
|
||||
typeof translation.language === 'string' &&
|
||||
/^en(?:-|$)/i.test(translation.language.trim())
|
||||
)
|
||||
return text;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
@@ -63,7 +83,7 @@ export function parseStationInformation(payload) {
|
||||
const stationMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
// Accept both GBFS 2.x (station_id) and alternate (id) field names
|
||||
// Accept both GBFS (station_id) and alternate (id) field names
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||
@@ -71,7 +91,7 @@ export function parseStationInformation(payload) {
|
||||
|
||||
stationMap.set(stationId, {
|
||||
stationId,
|
||||
name: String(raw?.name || raw?.short_name || '').trim(),
|
||||
name: stationName(raw?.name) || stationName(raw?.short_name),
|
||||
lat,
|
||||
lon,
|
||||
capacity: toNonNegativeInteger(raw?.capacity),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseStationInformation } from './gbfsStations.js';
|
||||
|
||||
const station = (fields = {}) => ({
|
||||
station_id: 's1',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
...fields,
|
||||
});
|
||||
const parseName = (fields, version) =>
|
||||
parseStationInformation({
|
||||
version,
|
||||
data: { stations: [station(fields)] },
|
||||
}).get('s1').name;
|
||||
|
||||
test('GBFS v2 station names remain trimmed strings', () => {
|
||||
assert.equal(
|
||||
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
|
||||
'Republic Square',
|
||||
);
|
||||
});
|
||||
|
||||
test('GBFS v3 station names prefer English over the first translation', () => {
|
||||
assert.equal(
|
||||
parseName(
|
||||
{
|
||||
name: [
|
||||
{ language: 'hu', text: 'Köztársaság tér' },
|
||||
{ language: 'en', text: ' Republic Square ' },
|
||||
],
|
||||
},
|
||||
'3.0',
|
||||
),
|
||||
'Republic Square',
|
||||
);
|
||||
});
|
||||
|
||||
test('English preference accepts case differences and regional language tags', () => {
|
||||
for (const language of ['EN', 'en-GB', 'En-US']) {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [
|
||||
{ language: 'hu', text: 'Hungarian' },
|
||||
{ language, text: 'English' },
|
||||
],
|
||||
}),
|
||||
'English',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('without usable English text the first usable translation wins', () => {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [
|
||||
null,
|
||||
{ language: 'en', text: ' ' },
|
||||
{ language: 'en', text: 42 },
|
||||
{ language: 'hu', text: ' Első ' },
|
||||
{ language: 'fr', text: 'Deuxième' },
|
||||
],
|
||||
}),
|
||||
'Első',
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed language fields cannot abort translation selection', () => {
|
||||
const name = [
|
||||
null,
|
||||
false,
|
||||
'bad',
|
||||
{},
|
||||
{ language: 42, text: 'Fallback' },
|
||||
{ language: { unexpected: true }, text: 'Other' },
|
||||
{ language: 'en', text: 'English' },
|
||||
];
|
||||
assert.equal(parseName({ name }), 'English');
|
||||
assert.equal(
|
||||
parseName({ name: [{ text: ' Usable text without a locale ' }] }),
|
||||
'Usable text without a locale',
|
||||
);
|
||||
});
|
||||
|
||||
test('a usable name takes precedence over an English short name', () => {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [{ language: 'hu', text: 'Full name' }],
|
||||
short_name: [{ language: 'en', text: 'Short name' }],
|
||||
}),
|
||||
'Full name',
|
||||
);
|
||||
});
|
||||
|
||||
test('missing, empty and malformed names fall back to localized short_name', () => {
|
||||
for (const name of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
' ',
|
||||
[],
|
||||
{},
|
||||
0,
|
||||
42,
|
||||
false,
|
||||
[null, {}, { language: 'en', text: [] }, { language: 'hu', text: ' ' }],
|
||||
]) {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name,
|
||||
short_name: [
|
||||
{ language: 'hu', text: 'Rövid' },
|
||||
{ language: 'en', text: ' Short ' },
|
||||
],
|
||||
}),
|
||||
'Short',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('localized names can fall back to legacy short_name strings', () => {
|
||||
assert.equal(parseName({ name: [], short_name: ' Short ' }), 'Short');
|
||||
});
|
||||
|
||||
test('no usable name leaves the station available with an empty name', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
[],
|
||||
{},
|
||||
42,
|
||||
true,
|
||||
[null, { text: false }, { language: 'en', text: ' ' }],
|
||||
]) {
|
||||
assert.equal(parseName({ name: value, short_name: value }), '');
|
||||
}
|
||||
});
|
||||
|
||||
test('name shape is normalized even when a feed omits or misstates its version', () => {
|
||||
const name = [{ language: 'en', text: 'Republic Square' }];
|
||||
for (const version of [undefined, '2.3', '3.0', '3.1', 'unknown']) {
|
||||
assert.equal(parseName({ name }, version), 'Republic Square');
|
||||
assert.equal(parseName({ name: 'Legacy name' }, version), 'Legacy name');
|
||||
}
|
||||
});
|
||||
|
||||
test('bad names do not lose valid stations or their metadata', () => {
|
||||
const information = parseStationInformation({
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
station({ station_id: 'bad', name: [null, {}] }),
|
||||
station({
|
||||
station_id: 'short',
|
||||
name: [],
|
||||
short_name: [{ language: 'en', text: 'Short' }],
|
||||
}),
|
||||
station({
|
||||
station_id: 'good',
|
||||
name: [{ language: 'en', text: 'Republic Square' }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(information.size, 3);
|
||||
assert.equal(information.get('bad').name, '');
|
||||
assert.equal(information.get('short').name, 'Short');
|
||||
assert.deepEqual(information.get('good'), {
|
||||
stationId: 'good',
|
||||
name: 'Republic Square',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
isInstalled: true,
|
||||
isRenting: true,
|
||||
isReturning: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizing a localized name leaves the feed document unchanged', () => {
|
||||
const payload = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
station({
|
||||
name: [
|
||||
{ language: 'hu', text: 'Magyar' },
|
||||
{ language: 'en', text: ' English ' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
const original = structuredClone(payload);
|
||||
parseStationInformation(payload);
|
||||
assert.deepEqual(payload, original);
|
||||
});
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
|
||||
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
|
||||
* Malformed sets are skipped without dropping the valid sets that follow.
|
||||
*/
|
||||
export function parseTleText(text) {
|
||||
const lines = String(text)
|
||||
@@ -9,12 +9,13 @@ export function parseTleText(text) {
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const result = [];
|
||||
for (let i = 0; i < lines.length - 2; i += 3) {
|
||||
for (let i = 0; i < lines.length - 2; i++) {
|
||||
const name = lines[i];
|
||||
const line1 = lines[i + 1];
|
||||
const line2 = lines[i + 2];
|
||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||
result.push({ name, line1, line2 });
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -253,3 +253,14 @@ test('the Cyber theme frames the Street Level panel like its rail peers (P3)', (
|
||||
// A theme header rule must be able to outrank the panel's own.
|
||||
assert.doesNotMatch(panelCss, /#street-level-panel \.panel-header/);
|
||||
});
|
||||
|
||||
test('OPEN STREET VIEW keeps its width beside a long coverage line', () => {
|
||||
// .scene-btn grows and shrinks (flex: 1) and loads after this stylesheet:
|
||||
// only a more specific rule keeps the button from being squeezed.
|
||||
assert.match(css, /\.scene-btn \{[^}]*flex: 1;/);
|
||||
assert.match(
|
||||
panelCss,
|
||||
/\.sl-meta-row \.sl-nearest-btn \{\s*flex: 0 0 auto;\s*\}/,
|
||||
);
|
||||
assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Stand-ins for the Google Maps JavaScript API's Street View library and the
|
||||
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate,
|
||||
* copyright, description}}; `nearest(location, radius)` answers location
|
||||
* lookups with an id or null; `substitutes` maps an id Google answers with a
|
||||
* newer one. Like Google, `setPano` sets the id (pano_changed) at once, and
|
||||
* the position and status follow when the data arrives; status_changed fires
|
||||
* only when the status changes.
|
||||
*/
|
||||
|
||||
function latLng(lat, lng) {
|
||||
return { lat: () => lat, lng: () => lng };
|
||||
}
|
||||
|
||||
export function fakeStreetViewLibrary({
|
||||
panoramas = {},
|
||||
nearest = () => null,
|
||||
substitutes = {},
|
||||
} = {}) {
|
||||
const built = [];
|
||||
const lookups = [];
|
||||
|
||||
class StreetViewPanorama {
|
||||
constructor(element, options) {
|
||||
this.element = element;
|
||||
this.options = options;
|
||||
this.visible = options?.visible ?? true;
|
||||
this.pano = null;
|
||||
this.status = null;
|
||||
this.position = null;
|
||||
this.pov = { heading: 0, pitch: 0 };
|
||||
this.listeners = new Map();
|
||||
built.push(this);
|
||||
}
|
||||
|
||||
addListener(name, listener) {
|
||||
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
||||
this.listeners.get(name).add(listener);
|
||||
return { remove: () => this.listeners.get(name)?.delete(listener) };
|
||||
}
|
||||
|
||||
fire(name) {
|
||||
for (const listener of [...(this.listeners.get(name) || [])]) listener();
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
let count = 0;
|
||||
for (const set of this.listeners.values()) count += set.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Like Google: the id is set at once; the data loads asynchronously. */
|
||||
setPano(id) {
|
||||
this.pano = id;
|
||||
this.fire('pano_changed');
|
||||
queueMicrotask(() => {
|
||||
if (this.pano !== id) return;
|
||||
const shownId = substitutes[id] ?? id;
|
||||
const known = panoramas[shownId];
|
||||
const status = known ? 'OK' : 'ZERO_RESULTS';
|
||||
if (shownId !== id) {
|
||||
this.pano = shownId;
|
||||
this.fire('pano_changed');
|
||||
}
|
||||
if (known) {
|
||||
this.position = latLng(known.lat, known.lng);
|
||||
this.fire('position_changed');
|
||||
}
|
||||
if (status !== this.status) {
|
||||
this.status = status;
|
||||
this.fire('status_changed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPano() {
|
||||
return this.pano;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
getPov() {
|
||||
return { ...this.pov };
|
||||
}
|
||||
|
||||
/** The user drags the view. */
|
||||
setPov(pov) {
|
||||
this.pov = { ...pov };
|
||||
this.fire('pov_changed');
|
||||
}
|
||||
|
||||
setVisible(visible) {
|
||||
this.visible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
class StreetViewService {
|
||||
async getPanorama(request) {
|
||||
lookups.push(request);
|
||||
const id = request.pano ?? nearest(request.location, request.radius);
|
||||
const known = id && panoramas[id];
|
||||
if (!known)
|
||||
throw Object.assign(new Error('ZERO_RESULTS'), {
|
||||
code: 'ZERO_RESULTS',
|
||||
});
|
||||
return {
|
||||
data: {
|
||||
location: {
|
||||
pano: id,
|
||||
latLng: latLng(known.lat, known.lng),
|
||||
description: known.description,
|
||||
},
|
||||
imageDate: known.imageDate,
|
||||
copyright: known.copyright,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
library: {
|
||||
StreetViewPanorama,
|
||||
StreetViewService,
|
||||
StreetViewSource: { GOOGLE: 'google', OUTDOOR: 'outdoor' },
|
||||
StreetViewPreference: { NEAREST: 'nearest', BEST: 'best' },
|
||||
},
|
||||
built,
|
||||
lookups,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A loader over `library`; `refuseKey()` plays Google's gm_authFailure. With
|
||||
* `deferred`, imports wait for `release()`.
|
||||
*/
|
||||
export function fakeMapsLoader(library, { deferred = false } = {}) {
|
||||
let failed = false;
|
||||
const listeners = new Set();
|
||||
const imports = [];
|
||||
let release = null;
|
||||
const gate = deferred
|
||||
? new Promise((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
imports,
|
||||
async importLibrary(name) {
|
||||
imports.push(name);
|
||||
await gate;
|
||||
return library;
|
||||
},
|
||||
release: () => release?.(),
|
||||
authFailed: () => failed,
|
||||
onAuthFailure(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
refuseKey() {
|
||||
failed = true;
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** A host element with just what the viewer adapter touches. */
|
||||
export function fakeHost() {
|
||||
const children = [];
|
||||
const host = {
|
||||
children,
|
||||
ownerDocument: {
|
||||
createElement: () => {
|
||||
const element = {
|
||||
style: {},
|
||||
className: '',
|
||||
remove() {
|
||||
const index = children.indexOf(element);
|
||||
if (index !== -1) children.splice(index, 1);
|
||||
},
|
||||
};
|
||||
return element;
|
||||
},
|
||||
},
|
||||
append: (element) => children.push(element),
|
||||
};
|
||||
return host;
|
||||
}
|
||||
@@ -515,15 +515,20 @@ test('evidence never carries a Mapillary token', () => {
|
||||
test('--strict accepts only the documented skips', () => {
|
||||
assert.deepEqual(
|
||||
[...STRICT_ALLOWED_SKIPS],
|
||||
['no Google 3D', 'fixtures only'],
|
||||
['no Google 3D', 'no Google key', 'fixtures only'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
strictViolations([
|
||||
{ reason: 'no Google 3D', label: 'terrain' },
|
||||
{ reason: 'no Google key', label: 'street view' },
|
||||
{ reason: 'fixtures only', label: 'keyless page' },
|
||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||
{ reason: 'Google key refused', label: 'street view' },
|
||||
]),
|
||||
[{ reason: 'no Mapillary key', label: 'the keyed steps' }],
|
||||
[
|
||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||
{ reason: 'Google key refused', label: 'street view' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
|
||||
}
|
||||
});
|
||||
|
||||
test('the CSP admits the event media embeds, and no other script origin', async () => {
|
||||
test('the CSP admits the event media embeds and the Maps JavaScript API, and no other script origin', async () => {
|
||||
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
|
||||
await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
|
||||
const directive = (name) =>
|
||||
@@ -201,7 +201,8 @@ test('the CSP admits the event media embeds, and no other script origin', async
|
||||
'https://platform.twitter.com',
|
||||
])
|
||||
assert.ok(scripts.includes(origin), origin);
|
||||
// Only those three, and never a wildcard or a scheme.
|
||||
// Only those three and Google Street View's Maps JavaScript API, never a
|
||||
// wildcard or a scheme.
|
||||
assert.deepEqual(
|
||||
scripts.filter(
|
||||
(source) => source.startsWith('http') || source === 'https:',
|
||||
@@ -210,6 +211,7 @@ test('the CSP admits the event media embeds, and no other script origin', async
|
||||
'https://www.youtube.com',
|
||||
'https://connect.facebook.net',
|
||||
'https://platform.twitter.com',
|
||||
'https://maps.googleapis.com',
|
||||
],
|
||||
);
|
||||
assert.ok(!scripts.some((source) => source.includes('*')));
|
||||
|
||||
@@ -37,6 +37,11 @@ const PROVIDER_ORIGINS = Object.freeze([
|
||||
'https://fonts.googleapis.com',
|
||||
'https://fonts.gstatic.com',
|
||||
'https://graph.mapillary.com',
|
||||
// Google Street View's tiles, controls and contributed photos (the Maps
|
||||
// JavaScript API itself comes from maps.googleapis.com above).
|
||||
'https://streetviewpixels-pa.googleapis.com',
|
||||
'https://maps.gstatic.com',
|
||||
'https://*.ggpht.com',
|
||||
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
|
||||
'https://*.xx.fbcdn.net',
|
||||
]);
|
||||
|
||||
@@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async ()
|
||||
assert.equal(panel.PANEL_BASE, '/panel/');
|
||||
assert.match(resource.text, /panelRuntime|function/);
|
||||
});
|
||||
|
||||
test('the panel lets Street Level reach Google Street View from inside the embed', () => {
|
||||
const { csp } = createGlobePanelResource({
|
||||
runtime: panelRuntime,
|
||||
panelKey: 'test-key',
|
||||
})._meta.ui;
|
||||
// The API script and lookups, panorama tiles, control images, contributed photos.
|
||||
for (const url of [
|
||||
'https://maps.googleapis.com/maps/api/js?key=k&v=weekly',
|
||||
'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1',
|
||||
'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png',
|
||||
'https://lh3.ggpht.com/p/photo.jpg',
|
||||
]) {
|
||||
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
|
||||
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
|
||||
}
|
||||
});
|
||||
@@ -106,6 +106,34 @@ test('bike share joins station information with live status inside the area', as
|
||||
assert.equal(result.data.rows[1].capacity, 10);
|
||||
});
|
||||
|
||||
test('bike share returns localized names without losing stations with malformed names', async () => {
|
||||
const informationUrl = 'https://gbfs.example/austin/station_information.json';
|
||||
const information = structuredClone(documents[informationUrl]);
|
||||
information.version = '3.0';
|
||||
information.data.stations[0].name = [
|
||||
{ language: 'hu', text: 'Magyar név' },
|
||||
{ language: 'en', text: 'Congress & 2nd' },
|
||||
];
|
||||
information.data.stations[2].name = [null, { language: 42, text: [] }];
|
||||
const source = bikeshare([system('austin', 'Austin, TX', 30.27, -97.74)]);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: {
|
||||
...source,
|
||||
getStations: (url) =>
|
||||
url === informationUrl ? information : source.getStations(url),
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_bike_share', { area: downtown });
|
||||
assert.equal(result.data.total, 2);
|
||||
assert.equal(result.data.rows[0].name, 'Congress & 2nd');
|
||||
assert.equal(result.data.rows[0].bikes_available, 4);
|
||||
assert.equal(result.data.rows[1].name, null);
|
||||
assert.doesNotMatch(result.summary, /unavailable/);
|
||||
});
|
||||
|
||||
test('bike share reports uncovered areas and failed systems honestly', async () => {
|
||||
const none = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -30,6 +30,27 @@ test('TLE text parses into named entries with catalog numbers', () => {
|
||||
assert.equal(tleCatalogNumber('1 xx'), null);
|
||||
});
|
||||
|
||||
for (const missingLine of [1, 2]) {
|
||||
test(`TLE parsing resumes after a set missing line ${missingLine}`, () => {
|
||||
const sets = Array.from({ length: 8 }, (_, i) => {
|
||||
const lines = TLE.trim().split('\n').slice(0, 3);
|
||||
lines[0] = `SAT ${i}`;
|
||||
return lines;
|
||||
});
|
||||
sets[1].splice(missingLine, 1);
|
||||
const entries = parseTleText(sets.flat().join('\r\n'));
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
['SAT 0', 'SAT 2', 'SAT 3', 'SAT 4', 'SAT 5', 'SAT 6', 'SAT 7'],
|
||||
);
|
||||
assert.ok(
|
||||
entries.every(
|
||||
(entry) => entry.line1.startsWith('1 ') && entry.line2.startsWith('2 '),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => {
|
||||
);
|
||||
assert.equal(layerKeyRequirementTooltip(ungated), '');
|
||||
});
|
||||
|
||||
test('a key-gated layer with another source ready says how to use it', () => {
|
||||
const text = layerKeyRequirementTooltip({
|
||||
requiresKeyId: 'mapillary',
|
||||
stats: {
|
||||
keyRequired: true,
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
text,
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { syncRowList } from './rowList.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
@@ -100,7 +103,11 @@ function panelLabel(layer) {
|
||||
export function layerKeyRequirementTooltip(layer = {}) {
|
||||
if (layer?.stats?.keyRequired !== true) return '';
|
||||
const requiresKeyId = String(layer.requiresKeyId || '').trim();
|
||||
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
|
||||
// A layer with several sources also names the ones it could use instead.
|
||||
return keySetupWithAlternatives(
|
||||
requiresKeyId ? keySetupRequirement(requiresKeyId) : '',
|
||||
layer.stats.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
|
||||
@@ -66,8 +66,11 @@ export class StreetLevelControls {
|
||||
status: byId('sl-status'),
|
||||
controls: byId('sl-controls'),
|
||||
providerChips: byId('sl-provider-chips'),
|
||||
nearestBtn: byId('sl-nearest-btn'),
|
||||
error: byId('sl-error'),
|
||||
errorText: byId('sl-error-text'),
|
||||
panoRow: byId('sl-pano-row'),
|
||||
sinceRow: byId('sl-since-row'),
|
||||
sinceRange: byId('sl-since'),
|
||||
sinceLabel: byId('sl-since-label'),
|
||||
legend: byId('sl-legend'),
|
||||
@@ -103,9 +106,11 @@ export class StreetLevelControls {
|
||||
this.listen(el.providerChips, 'click', (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
// Without a key the chips stay focusable for their tooltip; a click
|
||||
// says the same thing rather than switching on a layer that cannot draw.
|
||||
if (this._view?.controlsDisabled) {
|
||||
// A keyless chip shows its tooltip instead of switching; others switch.
|
||||
const chip = this._view?.providers.find(
|
||||
(entry) => entry.id === button.dataset.chipId,
|
||||
);
|
||||
if (chip?.state === 'error') {
|
||||
if (button.title) this.actions.showToast?.(button.title);
|
||||
return;
|
||||
}
|
||||
@@ -129,6 +134,15 @@ export class StreetLevelControls {
|
||||
this.listen(el.sinceRange, 'change', () =>
|
||||
this._setParams({ sinceDays: sinceDays() }),
|
||||
);
|
||||
this.listen(el.nearestBtn, 'click', () => {
|
||||
const nearest = this._view?.nearest;
|
||||
if (!nearest?.visible || nearest.disabled) return;
|
||||
this.layer.openNearest?.(undefined, {
|
||||
providerIds: nearest.providerIds,
|
||||
frame: false,
|
||||
aim: 'screen-centre',
|
||||
});
|
||||
});
|
||||
this.listen(el.followBtn, 'click', () => {
|
||||
this.layer.setFollow?.(!(this._state?.street?.follow === true));
|
||||
});
|
||||
@@ -238,24 +252,23 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighting a chip turns the layer on; darkening the last lit one turns the
|
||||
* layer off but keeps the provider on, so the layer comes back with it.
|
||||
* Lighting a chip turns the layer on and darkens the others (one source at
|
||||
* a time); darkening the lit one turns the layer off, keeping the provider.
|
||||
*/
|
||||
async _toggleProvider(providerId) {
|
||||
const chip = this._view?.providers.find((entry) => entry.id === providerId);
|
||||
if (!chip) return;
|
||||
if (!chip.active) {
|
||||
this._setParams({ [providerId]: true });
|
||||
// Every registered provider in one update (those hidden on this map
|
||||
// too), so the share link records the swap.
|
||||
const ids =
|
||||
this.layer.providerIds || this._view.providers.map((entry) => entry.id);
|
||||
this._setParams(
|
||||
Object.fromEntries(ids.map((id) => [id, id === providerId])),
|
||||
);
|
||||
await this._ensureEnabled();
|
||||
return;
|
||||
}
|
||||
const othersLit = this._view.providers.some(
|
||||
(entry) => entry.id !== providerId && entry.active,
|
||||
);
|
||||
if (othersLit) {
|
||||
this._setParams({ [providerId]: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.actions.setEnabled?.(false);
|
||||
} catch (error) {
|
||||
@@ -466,6 +479,7 @@ export class StreetLevelControls {
|
||||
this._renderFilters(view);
|
||||
this._renderViewer(view);
|
||||
this._renderMeta(view);
|
||||
this._renderNearest(view);
|
||||
this._reactToTransitions(view, state);
|
||||
}
|
||||
|
||||
@@ -510,6 +524,11 @@ export class StreetLevelControls {
|
||||
|
||||
_renderFilters(view) {
|
||||
const el = this._elements;
|
||||
const shows = view.filterControls || { pano: true, since: true };
|
||||
setProp(el.panoRow, 'hidden', !shows.pano);
|
||||
setProp(el.sinceRow, 'hidden', !shows.since);
|
||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||
setProp(el.legend, 'hidden', view.legend.length === 0);
|
||||
this._renderRadios(
|
||||
el.panoButtons,
|
||||
(button) => button.dataset.slPano === view.filter.pano,
|
||||
@@ -577,6 +596,9 @@ export class StreetLevelControls {
|
||||
el.renderButtons,
|
||||
(button) => button.dataset.slRender === viewer.renderMode,
|
||||
);
|
||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||
if (!viewer.open) return;
|
||||
setProp(el.imageBy, 'textContent', viewer.captionLeft);
|
||||
setProp(el.imageWhen, 'textContent', viewer.captionRight);
|
||||
@@ -592,6 +614,17 @@ export class StreetLevelControls {
|
||||
setProp(this._elements.coverageMeta, 'textContent', view.meta);
|
||||
}
|
||||
|
||||
_renderNearest(view) {
|
||||
const button = this._elements.nearestBtn;
|
||||
if (!button) return;
|
||||
const { nearest } = view;
|
||||
setProp(button, 'hidden', !nearest.visible);
|
||||
if (!nearest.visible) return;
|
||||
setProp(button, 'textContent', nearest.label);
|
||||
setProp(button, 'title', nearest.title);
|
||||
setProp(button, 'disabled', nearest.disabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the panel on a switch-on or when an image opens. Never persisted:
|
||||
* the stored collapse state stays the user's own choice.
|
||||
|
||||
@@ -307,12 +307,16 @@ function panelDom() {
|
||||
add(settings, 'div', { id });
|
||||
// A missing key gates the filters only; the chips stay outside the gate.
|
||||
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
|
||||
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
|
||||
for (const pano of ['all', 'pano', 'flat'])
|
||||
add(controls, 'button', { dataset: { slPano: pano } });
|
||||
add(controls, 'input', { id: 'sl-since' });
|
||||
add(controls, 'output', { id: 'sl-since-label' });
|
||||
add(panoRow, 'button', { dataset: { slPano: pano } });
|
||||
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
|
||||
add(sinceRow, 'input', { id: 'sl-since' });
|
||||
add(sinceRow, 'output', { id: 'sl-since-label' });
|
||||
add(settings, 'ul', { id: 'sl-legend' });
|
||||
add(settings, 'div', { id: 'sl-coverage-meta' });
|
||||
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
|
||||
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
||||
add(metaRow, 'button', { id: 'sl-nearest-btn' });
|
||||
// The globe's canvas is focusable too (tabindex=0).
|
||||
const globe = add(
|
||||
add(document.body, 'div', { id: 'cesiumContainer' }),
|
||||
@@ -1136,3 +1140,122 @@ test('a <body> child added while the viewer is expanded goes inert with the rest
|
||||
assert.equal(observer.watching(), false);
|
||||
}),
|
||||
));
|
||||
|
||||
test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
state.providers.push({
|
||||
...state.providers[0],
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
groundClick: true,
|
||||
count: 0,
|
||||
});
|
||||
state.groundClickReady = true;
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const asked = [];
|
||||
layer.openNearest = (point, options) => asked.push([point, options]);
|
||||
const button = dom.root.querySelector('#sl-nearest-btn');
|
||||
assert.equal(button.hidden, false);
|
||||
assert.equal(button.textContent, 'OPEN STREET VIEW');
|
||||
button.click();
|
||||
assert.deepEqual(asked, [
|
||||
[
|
||||
undefined,
|
||||
{ providerIds: ['google'], frame: false, aim: 'screen-centre' },
|
||||
],
|
||||
]);
|
||||
// Too high to mean a street: disabled, and a click does nothing.
|
||||
layer.publish({ ...state, groundClickReady: false });
|
||||
assert.equal(button.disabled, true);
|
||||
button.click();
|
||||
assert.equal(asked.length, 1);
|
||||
// Off Google 3D the provider is not listed: no button.
|
||||
layer.publish({ ...state, providers: [state.providers[0]] });
|
||||
assert.equal(button.hidden, true);
|
||||
}));
|
||||
|
||||
test('a refused Street View key cannot lock Mapillary out; its chip only explains itself', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState({ on: false, keyRequired: true });
|
||||
state.providers.push({
|
||||
...state.providers[0],
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
on: true,
|
||||
keyRequired: true,
|
||||
keyRejected: true,
|
||||
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
|
||||
});
|
||||
state.providers[0].keyRequired = false;
|
||||
const { calls, layer } = stubPanel(dom, state);
|
||||
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
|
||||
chips()[0].click(); // Mapillary: configured, dark
|
||||
await settle();
|
||||
// One source at a time: lighting Mapillary darkens Street View too.
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true, google: false }, { origin: 'user' }],
|
||||
]);
|
||||
// Now dark, the refused chip says why instead of lighting.
|
||||
state.providers[0].on = true;
|
||||
state.providers[1].on = false;
|
||||
state.keyRequired = false;
|
||||
layer.publish({ ...state });
|
||||
chips()[1].click();
|
||||
await settle();
|
||||
assert.equal(calls.setParams.length, 1);
|
||||
assert.deepEqual(calls.toasts, [
|
||||
dom.root.querySelector('#sl-provider-chips').children[1].title,
|
||||
]);
|
||||
}));
|
||||
|
||||
test('a swap names every registered provider, also those hidden on this map', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState({ on: false });
|
||||
const { calls, layer, chip } = stubPanel(dom, state);
|
||||
// Off Google 3D the Street View chip is not listed, but stays registered.
|
||||
layer.providerIds = ['mapillary', 'google'];
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true, google: false }, { origin: 'user' }],
|
||||
]);
|
||||
}));
|
||||
|
||||
test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const shown = () => ({
|
||||
pano: !dom.root.querySelector('#sl-pano-row').hidden,
|
||||
since: !dom.root.querySelector('#sl-since-row').hidden,
|
||||
controls: !dom.root.querySelector('#sl-controls').hidden,
|
||||
legend: !dom.root.querySelector('#sl-legend').hidden,
|
||||
});
|
||||
assert.deepEqual(shown(), {
|
||||
pano: true,
|
||||
since: true,
|
||||
controls: true,
|
||||
legend: true,
|
||||
});
|
||||
layer.publish({
|
||||
...state,
|
||||
filterControls: { pano: false, since: false },
|
||||
legend: [],
|
||||
});
|
||||
assert.deepEqual(shown(), {
|
||||
pano: false,
|
||||
since: false,
|
||||
controls: false,
|
||||
legend: false,
|
||||
});
|
||||
layer.publish(state);
|
||||
assert.deepEqual(shown(), {
|
||||
pano: true,
|
||||
since: true,
|
||||
controls: true,
|
||||
legend: true,
|
||||
});
|
||||
}));
|
||||
@@ -1,4 +1,7 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
@@ -44,22 +47,43 @@ function presentSince(days, now) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(ms) {
|
||||
/** YYYY-MM-DD, or YYYY-MM for a provider that dates images by month. */
|
||||
function formatDate(ms, precision = 'day') {
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
try {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the key-gated layer needs: each switched-on provider's key, and the
|
||||
* switched-off ones that would draw with a key already set.
|
||||
*/
|
||||
function keyGuidance(state) {
|
||||
const needs = (state.providers || [])
|
||||
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
|
||||
.map((p) => keySetupRequirement(p.requiresKeyId))
|
||||
.filter(Boolean);
|
||||
return keySetupWithAlternatives(
|
||||
[...new Set(needs)].join('; '),
|
||||
state.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
function presentStatus(state) {
|
||||
const pressed = state.enabled === true;
|
||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||
if (state.keyRejected)
|
||||
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
|
||||
if (state.keyRequired)
|
||||
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
|
||||
return {
|
||||
text: 'KEY REQUIRED',
|
||||
tone: 'warn',
|
||||
pressed,
|
||||
title: keyGuidance(state) || title,
|
||||
};
|
||||
if (state.coverage.loading)
|
||||
return { text: 'LOADING', tone: 'busy', pressed, title };
|
||||
return pressed
|
||||
@@ -104,12 +128,17 @@ function presentViewer(state) {
|
||||
if (street.isPano) right.push('360°');
|
||||
if (Number.isFinite(street.bearing))
|
||||
right.push(`${Math.round(street.bearing)}°`);
|
||||
if (street.capturedAt) right.push(formatDate(street.capturedAt));
|
||||
if (street.capturedAt)
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
if (street.title) left.push(street.title);
|
||||
if (street.creator) left.push(`Image by ${street.creator}`);
|
||||
return {
|
||||
open: street.open === true,
|
||||
loading: street.loading === true && !street.imageId,
|
||||
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
|
||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||
renderModes: street.renderModes !== false,
|
||||
captionLeft: left.join(' · '),
|
||||
captionRight: right.join(' · '),
|
||||
link: street.externalUrl || null,
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
@@ -124,16 +153,52 @@ function presentViewer(state) {
|
||||
};
|
||||
}
|
||||
|
||||
/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */
|
||||
function presentNearest(state) {
|
||||
const targets = (state.providers || []).filter(
|
||||
(provider) =>
|
||||
provider.on && provider.groundClick && provider.keyRequired !== true,
|
||||
);
|
||||
if (state.enabled !== true || !targets.length)
|
||||
return {
|
||||
visible: false,
|
||||
label: '',
|
||||
title: '',
|
||||
disabled: true,
|
||||
providerIds: [],
|
||||
};
|
||||
const names = targets.map((provider) => provider.name).join(' or ');
|
||||
const ready = state.groundClickReady === true;
|
||||
return {
|
||||
visible: true,
|
||||
label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`,
|
||||
title: ready
|
||||
? `Open ${names} at the centre of the view`
|
||||
: `Zoom in to a street to open ${names} here`,
|
||||
disabled: !ready || state.street.loading === true,
|
||||
providerIds: targets.map((provider) => provider.id),
|
||||
};
|
||||
}
|
||||
|
||||
function presentMeta(state) {
|
||||
if (state.providers.length === 0) return state.coverage.hint || '';
|
||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||
// Key-gated with another source ready: say how to use it.
|
||||
if (state.keyRequired && state.keyAlternatives?.length)
|
||||
return keyGuidance(state);
|
||||
if (state.sequence.selectedId)
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
|
||||
const hint = state.coverage.hint || '';
|
||||
if (state.coverage.count > 0)
|
||||
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
|
||||
return state.coverage.hint || '';
|
||||
return [
|
||||
`${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`,
|
||||
hint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return hint;
|
||||
}
|
||||
|
||||
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
||||
@@ -154,10 +219,15 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
? null
|
||||
: state.street.error || state.coverage.error || null,
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
filterControls: {
|
||||
pano: state.filterControls?.pano !== false,
|
||||
since: state.filterControls?.since !== false,
|
||||
},
|
||||
since: presentSince(filter.sinceDays, now),
|
||||
legend: state.legend || [],
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
nearest: presentNearest(state),
|
||||
wantsOpen: state.street.open === true,
|
||||
};
|
||||
}
|
||||
@@ -405,3 +405,137 @@ test('FOLLOW is disabled off Google 3D and says where to switch', () => {
|
||||
);
|
||||
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
|
||||
});
|
||||
|
||||
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
street: {
|
||||
open: true,
|
||||
providerId: 'google',
|
||||
providerName: 'Google Street View',
|
||||
providerLabel: 'STREET VIEW',
|
||||
imageId: 'pano-1',
|
||||
title: '1014 10th St',
|
||||
creator: 'Google',
|
||||
capturedAt: Date.UTC(2023, 0, 1),
|
||||
capturedAtPrecision: 'month',
|
||||
bearing: 0,
|
||||
isPano: true,
|
||||
renderModes: false,
|
||||
externalUrl:
|
||||
'https://www.google.com/maps/@?api=1&map_action=pano&pano=pano-1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(view.viewer.captionLeft, '1014 10th St · Image by Google');
|
||||
assert.equal(view.viewer.captionRight, '360° · 0° · 2023-01');
|
||||
assert.equal(view.viewer.linkLabel, 'STREET VIEW ↗');
|
||||
assert.equal(view.viewer.renderModes, false);
|
||||
});
|
||||
|
||||
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
coverage: {
|
||||
loading: false,
|
||||
count: 2514,
|
||||
hint: 'click a street to open Street View',
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
view.meta,
|
||||
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
|
||||
);
|
||||
});
|
||||
|
||||
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
|
||||
const streetView = provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
groundClick: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
});
|
||||
const view = (overrides) =>
|
||||
presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
providers: [provider(), streetView],
|
||||
...overrides,
|
||||
}),
|
||||
).nearest;
|
||||
const ready = view({ groundClickReady: true });
|
||||
assert.deepEqual(ready, {
|
||||
visible: true,
|
||||
label: 'OPEN STREET VIEW',
|
||||
title: 'Open Google Street View at the centre of the view',
|
||||
disabled: false,
|
||||
providerIds: ['google'],
|
||||
});
|
||||
const high = view({ groundClickReady: false });
|
||||
assert.equal(high.disabled, true);
|
||||
assert.equal(
|
||||
high.title,
|
||||
'Zoom in to a street to open Google Street View here',
|
||||
);
|
||||
// Hidden with Street View off, without its key, or with the layer off
|
||||
// (off Google 3D the provider is not listed at all).
|
||||
assert.equal(
|
||||
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
|
||||
.visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(view({ providers: [provider()] }).visible, false);
|
||||
assert.equal(
|
||||
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
|
||||
.nearest.visible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [
|
||||
provider({ keyRequired: true, configured: false }),
|
||||
provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
on: false,
|
||||
configured: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
}),
|
||||
],
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const guidance =
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
|
||||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||||
assert.equal(view.status.title, guidance);
|
||||
assert.equal(view.meta, guidance);
|
||||
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
|
||||
const plain = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [provider({ keyRequired: true, configured: false })],
|
||||
keyAlternatives: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(plain.status.text, 'KEY REQUIRED');
|
||||
assert.notEqual(plain.meta, guidance);
|
||||
});
|
||||
@@ -183,6 +183,28 @@ button.sl-status:focus-visible {
|
||||
border-color: rgba(255, 179, 0, 0.55);
|
||||
}
|
||||
|
||||
/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */
|
||||
.sl-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sl-meta-row .sl-meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scoped to outrank .scene-btn's flex: 1, which squeezed the button to its
|
||||
min-width beside a long coverage line and clipped its one-line label. */
|
||||
.sl-meta-row .sl-nearest-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sl-nearest-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||
.sl-error {
|
||||
font-family: var(--font-sans);
|
||||
@@ -207,6 +229,14 @@ button.sl-status:focus-visible {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sl-seg[hidden],
|
||||
.sl-controls[hidden],
|
||||
.sl-filters[hidden],
|
||||
.sl-since[hidden],
|
||||
.sl-legend[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-seg-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
@@ -480,7 +510,9 @@ button.sl-status:focus-visible {
|
||||
/* A dragged window keeps its natural height, so bound it to the viewport:
|
||||
the rail's max-height: 100% does not bind an auto-height parent.
|
||||
Id-qualified to outrank that rule. */
|
||||
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
|
||||
#right-context-rail
|
||||
> #street-level-panel.panel-floating
|
||||
> .street-level-panel-inner {
|
||||
max-height: calc(100vh - 12px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
<!-- A missing key gates the filters only: the chips above stay
|
||||
focusable so their tooltip can say how to add the key. -->
|
||||
<fieldset id="sl-controls" class="sl-controls">
|
||||
<div class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
<span class="cctv-summary-label">IMAGERY</span>
|
||||
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
|
||||
@@ -126,14 +126,18 @@
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-since">
|
||||
<div id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since">SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
|
||||
</div>
|
||||
</fieldset>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
<div class="sl-meta-row">
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user