mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into upstream/ontario-511-key
This commit is contained in:
209 files changed
+24008
-17286
No files matched your search
@@ -83,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
|||||||
# keep them while debugging; delete the log when you are done.
|
# keep them while debugging; delete the log when you are done.
|
||||||
GEV_VOICE_LOG_CONTENT=
|
GEV_VOICE_LOG_CONTENT=
|
||||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||||
|
# Optional: Gemini Live voice, selected in the GEV MIC provider chooser.
|
||||||
|
# Server-only key. Do not prefix with VITE_. Provider Settings can save it.
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
GEMINI_LIVE_MODEL=gemini-3.8-live
|
||||||
|
# Token starts per IP per minute; 0 disables the throttle, not provider quotas.
|
||||||
|
# GEV_RATELIMIT_GEMINI_PER_MIN=30
|
||||||
# Per-IP rate limit for the OpenAI cost endpoints
|
# Per-IP rate limit for the OpenAI cost endpoints
|
||||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||||
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
||||||
@@ -229,6 +235,7 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
|
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
|
||||||
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
|
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
|
||||||
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
|
# CCTV_VEGVESEN_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_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_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_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
GEV_REQUIRE_ALLOCATION_GATE: '0'
|
GEV_REQUIRE_ALLOCATION_GATE: '0'
|
||||||
# Fail on a missed header press unless the panel itself moved.
|
|
||||||
QA_FAIL_ON_RETRY: '1'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out source
|
- name: Check out source
|
||||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
@@ -104,10 +102,6 @@ jobs:
|
|||||||
- name: Street Level gate with Mapillary fixtures
|
- name: Street Level gate with Mapillary fixtures
|
||||||
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
|
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
|
||||||
|
|
||||||
- name: Portable panel gate
|
|
||||||
if: success() || failure()
|
|
||||||
run: npm run qa:panel-resize -- --url http://localhost:4173
|
|
||||||
|
|
||||||
- name: Preview server log
|
- name: Preview server log
|
||||||
if: failure()
|
if: failure()
|
||||||
run: cat preview.log
|
run: cat preview.log
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ output/
|
|||||||
.gev-cache/
|
.gev-cache/
|
||||||
# Browser QA gate evidence (screenshots, console and network dumps).
|
# Browser QA gate evidence (screenshots, console and network dumps).
|
||||||
qa-artifacts/
|
qa-artifacts/
|
||||||
|
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
|
||||||
|
.playwright-mcp/
|
||||||
|
# Local Claude Code settings and preview configs.
|
||||||
|
.claude/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.gstack/
|
.gstack/
|
||||||
3d-models/
|
3d-models/
|
||||||
|
|||||||
+14
-20
@@ -317,9 +317,8 @@
|
|||||||
[NotProniss](https://github.com/NotProniss) (#1084).
|
[NotProniss](https://github.com/NotProniss) (#1084).
|
||||||
|
|
||||||
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
|
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
|
||||||
(orange; off by default, one provider at a time): from orbit dots show
|
(orange; off by default, one provider at a time): below about 60 km the
|
||||||
where the federated commons has pictures, and below about 60 km its
|
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
|
||||||
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
|
|
||||||
line opens that sequence's picture nearest the click without moving the
|
line opens that sequence's picture nearest the click without moving the
|
||||||
camera, flat or 360°, with cones along the sequence around it and ‹ › /
|
camera, flat or 360°, with cones along the sequence around it and ‹ › /
|
||||||
← → following the picture's own links. The 360° and since filters apply,
|
← → following the picture's own links. The 360° and since filters apply,
|
||||||
@@ -377,10 +376,10 @@
|
|||||||
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
|
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
|
||||||
open Google's own panorama in
|
open Google's own panorama in
|
||||||
the panel, with its arrows, the address and month in the caption, a link
|
the panel, with its arrows, the address and month in the caption, a link
|
||||||
to Google Maps and FOLLOW. Share links carry it in the provider switches
|
to Google Maps. Share links carry it in the provider switches
|
||||||
(`r.google-1`). One provider is lit at a time (lighting one switches the
|
(`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
|
other off), Mapillary by default since it is free. Street View's panoramas
|
||||||
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
|
are all 360°, so the IMAGERY and SINCE filters apply to
|
||||||
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
|
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
|
||||||
is a Google-blue view cone.
|
is a Google-blue view cone.
|
||||||
|
|
||||||
@@ -419,21 +418,16 @@
|
|||||||
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
|
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
|
||||||
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
|
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
|
||||||
|
|
||||||
- Street Level: a street-level imagery layer modelled on the iD editor's photo
|
- Street Level: street-level imagery from providers, Mapillary first (free
|
||||||
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
|
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
|
||||||
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
|
360°/flat and captured-since filters and an embedded viewer with EXPAND and
|
||||||
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
|
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
|
||||||
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
|
Google 3D tiles; opening a photo frames it with one camera flight. Share
|
||||||
down to a single street; on Google 3D they sit on the bare earth, refined
|
links carry the provider switches, the filters and the panel's collapsed
|
||||||
against the rendered mesh near the camera, so trees and buildings hide them.
|
state. A server proxy adds the token to Mapillary coverage tiles, trims and
|
||||||
Share links carry the provider switches, the filters and the panel's collapsed
|
caches them for 24 h, refuses cross-site requests and redirects, and limits
|
||||||
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
|
load per IP and upstream. The panel reads KEY REQUIRED without a token and
|
||||||
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
|
KEY REJECTED when Mapillary refuses it.
|
||||||
rejects the token or rate-limits.
|
|
||||||
|
|
||||||
- The CCTV and Street Level panels are portable: drag the header to float the
|
|
||||||
panel, resize it from any edge or corner, and double-click the header to dock
|
|
||||||
it again. The position persists across reloads.
|
|
||||||
|
|
||||||
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
|
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
|
||||||
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
|
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
|
||||||
|
|||||||
+6
-8
@@ -54,9 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
|
|||||||
Runnable gate entrypoints document what they assert and how to invoke them.
|
Runnable gate entrypoints document what they assert and how to invoke them.
|
||||||
Most need a dev server; some also need a specific provider key or port. Follow
|
Most need a dev server; some also need a specific provider key or port. Follow
|
||||||
the entrypoint's header rather than assuming every matching file accepts the
|
the entrypoint's header rather than assuming every matching file accepts the
|
||||||
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
|
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
|
||||||
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
|
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
|
||||||
`qa:transit`.
|
|
||||||
|
|
||||||
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
||||||
for the feature: it names the gate for many of them, and it's the authoritative
|
for the feature: it names the gate for many of them, and it's the authoritative
|
||||||
@@ -66,11 +65,10 @@ gate for the feature you changed.
|
|||||||
|
|
||||||
> **CI covers only two of these for you.** The workflow runs the setup policy
|
> **CI covers only two of these for you.** The workflow runs the setup policy
|
||||||
> checks, the formatting and package-boundary checks, the unit suite and the
|
> checks, the formatting and package-boundary checks, the unit suite and the
|
||||||
> production build, plus a Windows onboarding job, and against the built preview
|
> production build, plus a Windows onboarding job. It runs neither
|
||||||
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
|
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
|
||||||
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
|
> gate (`street-level-browser` job) — they need a live dev server and a
|
||||||
> server and a browser. Include the applicable local run in your PR's
|
> browser. Include the applicable local run in your PR's validation evidence.
|
||||||
> validation evidence.
|
|
||||||
|
|
||||||
## Share-link layer tokens
|
## Share-link layer tokens
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -20,10 +20,10 @@ How to read this:
|
|||||||
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
|
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
|
||||||
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
|
| **NOAA 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" |
|
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
||||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||||
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
|
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
|
||||||
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||||
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
|
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
|
||||||
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
|
| **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 |
|
| **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 |
|
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||||
@@ -64,6 +64,7 @@ How to read this:
|
|||||||
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
|
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
|
||||||
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
|
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
|
||||||
| **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" |
|
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
|
||||||
|
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||||
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
||||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||||
@@ -168,6 +169,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||||
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||||
- **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.
|
- **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: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
||||||
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[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.
|
- **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.
|
||||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||||
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -309,6 +309,10 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
|||||||
|
|
||||||
## 🎙️ Talk to It
|
## 🎙️ Talk to It
|
||||||
|
|
||||||
|
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||||
|
|
||||||
|
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
|
||||||
|
|
||||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||||
|
|
||||||
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
|
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
|
||||||
@@ -379,7 +383,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
|||||||
| 🚗 **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 |
|
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||||
@@ -421,9 +425,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
|||||||
|
|
||||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||||
|
|
||||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||||
|
|
||||||
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||||
|
|
||||||
## 🎖️ Field Missions
|
## 🎖️ Field Missions
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
|
|||||||
|
|
||||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
|
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
|
||||||
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
||||||
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
|
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
|
||||||
|
|
||||||
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
|
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
|
||||||
|
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
|
||||||
|
video camera could only be found by opening it. Stills stay cyan and the
|
||||||
|
active camera stays amber, and focus de-emphasis fades the violet the same
|
||||||
|
way (Danielslay86, #1200).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Draw CCTV stills at their source aspect on the monitor plane instead of
|
||||||
|
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
|
||||||
|
every still was widened; they are now centered and pillarboxed on the dark
|
||||||
|
the placeholder already ends on (Danielslay86, #1201).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI
|
||||||
|
command, while preserving an earlier accepted deferred input
|
||||||
|
(Manjunath, #1220).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
|
||||||
|
cancel both response streams when either response fails or overflows the 4 MB
|
||||||
|
byte cap, and deduplicate camera IDs before per-pack prioritization
|
||||||
|
(yaka0007, #1233).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Serve the camera catalog while it refreshes. An expired catalog made every
|
||||||
|
camera request wait for the slowest pack to reload; it is now answered at
|
||||||
|
once while one background refresh runs, so only the first load waits. Estonia's
|
||||||
|
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
|
||||||
|
on about a third of loads (quanticsoul4772, #1234).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Report cached CelesTrak satellite records as stale in the Data Layers row,
|
||||||
|
preserving separate core and dense catalog provenance through refresh,
|
||||||
|
removal and lifecycle cleanup (rudycelekli, #1283).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Keep aircraft route labels on the scheduled airport leg instead of accepting
|
||||||
|
its distant great-circle extensions, preserving generous endpoint tolerance
|
||||||
|
across the signed-angle branch cut (rudycelekli, #1284).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
|
||||||
|
server-held API key, microphone streaming, shared map actions and captions.
|
||||||
|
Switching providers ends the old conversation and preserves the input mode
|
||||||
|
(Manjunath, #872).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
- Give each Statens vegvesen camera its own ground height from Kartverket's
|
||||||
|
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
|
||||||
|
mountain-pass cameras no longer sit hundreds of metres below the terrain
|
||||||
|
where neither the point-height prior nor the ground snap resolves. About 850
|
||||||
|
of ~855 cameras get a height; the rest keep the old prior. Requests carry 20
|
||||||
|
points, the most the API answers from its 1 m terrain model, eight at a
|
||||||
|
time; a cold lookup takes about 6–13 s. Cameras over water use the water
|
||||||
|
surface rather than the sea-floor depth the API returns there; for bridge
|
||||||
|
cameras that is still below the deck. Heights are cached for good and misses
|
||||||
|
for 6 h, so a refresh only asks about new or moved cameras. After a
|
||||||
|
failing request (a 408, 429 or 5xx, a network error, a request over 10 s or
|
||||||
|
an unreadable body) the lookup stops and backs off (Retry-After, else
|
||||||
|
30 min, longer than the refresh). The lane waits at most 12 s from its
|
||||||
|
start, and only on a cold cache; a slower first lookup leaves the cameras it
|
||||||
|
had not reached on the flat prior until the next 15-minute refresh, and
|
||||||
|
keeps filling the cache meanwhile.
|
||||||
|
`CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Street Level is rebuilt around one small provider registry: Mapillary,
|
||||||
|
KartaView, Panoramax and Google Street View share one coverage drawer and
|
||||||
|
one photo viewer, one source lit at a time. Floating and docked panels,
|
||||||
|
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
|
||||||
|
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
|
||||||
|
deleted (gekh, #974).
|
||||||
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
|
|||||||
generic modules own DOM/listeners/timers and release them in `destroy()`.
|
generic modules own DOM/listeners/timers and release them in `destroy()`.
|
||||||
|
|
||||||
Keep persistence with panel chrome: collapse uses
|
Keep persistence with panel chrome: collapse uses
|
||||||
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
|
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
|
||||||
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
|
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
|
||||||
Do not rename existing
|
Do not rename existing
|
||||||
ids or reset persistence versions when adding a body. The WEATHER adapter is
|
ids or reset persistence versions when adding a body. The WEATHER adapter is
|
||||||
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
|
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
|
||||||
|
|||||||
+89
-74
@@ -618,7 +618,9 @@ Reference feed construction is exported through `sources/reference`; the cable s
|
|||||||
|
|
||||||
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
|
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
|
||||||
|
|
||||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner.
|
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
||||||
|
|
||||||
|
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||||
|
|
||||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
||||||
|
|
||||||
@@ -649,7 +651,7 @@ The UI facade composes separate owners for navigation authority, share restorati
|
|||||||
visual settings and panel chrome. Navigation owns request generations and follow
|
visual settings and panel chrome. Navigation owns request generations and follow
|
||||||
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
||||||
visual settings own style/detection overrides and sensor cleanup; panel chrome
|
visual settings own style/detection overrides and sensor cleanup; panel chrome
|
||||||
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
|
owns disclosure and Cockpit panel snapshots. Each takes explicit
|
||||||
operations and reads instead of the complete shell. Existing controls, share
|
operations and reads instead of the complete shell. Existing controls, share
|
||||||
formats and one-application-per-page behavior are preserved.
|
formats and one-application-per-page behavior are preserved.
|
||||||
|
|
||||||
@@ -815,7 +817,11 @@ Viewport limits, saturation retry, placement, query caps and controls are retain
|
|||||||
|
|
||||||
Satellite and mission layers expose separate factories through `./layers/satellites`
|
Satellite and mission layers expose separate factories through `./layers/satellites`
|
||||||
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
|
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
|
||||||
and teardown. Source adapters retain the existing CelesTrak and Launch Library
|
and teardown. Satellite core and dense catalogs retain upstream stale-cache
|
||||||
|
provenance only for admitted records; duplicate or invalid entries cannot taint
|
||||||
|
a fresh winning record. Dense removal clears only its own provenance, and a
|
||||||
|
fresh core rebuild or lifecycle reset replaces the corresponding ownership.
|
||||||
|
Source adapters retain the existing CelesTrak and Launch Library
|
||||||
endpoints; standalone entries provide scene services and the satellite dependency.
|
endpoints; standalone entries provide scene services and the satellite dependency.
|
||||||
The mission factory separates paths, replay, camera, panel, overlays and placement.
|
The mission factory separates paths, replay, camera, panel, overlays and placement.
|
||||||
Launch Library pad coordinates retain unknown values: missing fields use the
|
Launch Library pad coordinates retain unknown values: missing fields use the
|
||||||
@@ -975,10 +981,9 @@ start with current state, isolate listener failures and stop on disposal.
|
|||||||
|
|
||||||
The standalone entry composes the UI with the application's existing layer,
|
The standalone entry composes the UI with the application's existing layer,
|
||||||
terrain, navigation and rendering operations. The shell receives those instances
|
terrain, navigation and rendering operations. The shell receives those instances
|
||||||
and assembles the controls. Panel layout scheduling, position preferences and
|
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
|
||||||
active drags, loading notices, recording presentation and DOM lookup have focused
|
|
||||||
owners. Disposal revokes queued presentation and listeners before asynchronous
|
owners. Disposal revokes queued presentation and listeners before asynchronous
|
||||||
Context restoration, cancels an unfinished drag without saving it, restores the
|
Context restoration, restores the
|
||||||
recording HUD and releases status decoration without replacing accessible text.
|
recording HUD and releases status decoration without replacing accessible text.
|
||||||
The ordinary control snapshot includes the current 3D model toggle and mode.
|
The ordinary control snapshot includes the current 3D model toggle and mode.
|
||||||
|
|
||||||
@@ -2984,6 +2989,13 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
||||||
> Cartesian already consumed by the tracked visual and camera, and never
|
> Cartesian already consumed by the tracked visual and camera, and never
|
||||||
> advances dead reckoning from the host frame.
|
> advances dead reckoning from the host frame.
|
||||||
|
> - **Scheduled route plausibility:** Aircraft route labels and selected-route
|
||||||
|
> context reject the distant backwards, forwards and antipodal extensions of
|
||||||
|
> an airport pair's great circle. The existing 130 km endpoint admission and
|
||||||
|
> 200 km cross-track tolerance remain; a projected fix outside the finite
|
||||||
|
> origin-to-destination minor arc also has a generous 200 km extension at
|
||||||
|
> either end. Missing coordinates and coincident or antipodal endpoint pairs
|
||||||
|
> retain their loose, unjudgeable behavior; vertical-trend checks are unchanged.
|
||||||
> - **Aircraft label convention (both flight layers, 2026-08-18):** every civil
|
> - **Aircraft label convention (both flight layers, 2026-08-18):** every civil
|
||||||
> and military label surface resolves **callsign → registration → icao24**
|
> and military label surface resolves **callsign → registration → icao24**
|
||||||
> (`_contactLabel()` in `flights.js`; the same chain inline in
|
> (`_contactLabel()` in `flights.js`; the same chain inline in
|
||||||
@@ -3446,7 +3458,7 @@ its criteria cannot be silently ignored.
|
|||||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
| 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 |
|
| 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 |
|
| 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) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + 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) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, 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 |
|
| 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) |
|
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||||
@@ -3455,7 +3467,7 @@ its criteria cannot be silently ignored.
|
|||||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||||
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
|
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||||
@@ -3487,8 +3499,8 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
|||||||
|
|
||||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||||
|
|
||||||
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
|
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||||
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||||
collapsed. It opens itself only when the user, voice or a tool switches the
|
collapsed. It opens itself only when the user, voice or a tool switches the
|
||||||
layer on or a photo opens, never on a restore, and those opens are not stored.
|
layer on or a photo opens, never on a restore, and those opens are not stored.
|
||||||
Like the iD editor's photo overlay, it has one chip per registered provider,
|
Like the iD editor's photo overlay, it has one chip per registered provider,
|
||||||
@@ -3500,31 +3512,30 @@ sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers'
|
|||||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||||
Street View, KartaView and Panoramax are registered. Without
|
Street View, KartaView and Panoramax are registered. Without
|
||||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
`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
|
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||||
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
|
||||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||||
requests for a tile.
|
requests for a tile.
|
||||||
|
|
||||||
Mapillary coverage ranks unique tile addresses before applying the 16-tile
|
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
||||||
overview budget. Overlapping dateline halves in a polar view therefore do not
|
the panel says to zoom in. Tiles come from the visible ground within range of
|
||||||
waste overview slots on the same tile; loading already skips duplicate keys.
|
the camera, nearest first, at most 9 (the halves of a view across
|
||||||
|
the date line count a shared tile once). Lines are draped
|
||||||
|
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||||
|
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||||
|
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||||
|
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||||
|
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||||
|
pauses them for its Retry-After.
|
||||||
|
|
||||||
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
|
The server proxy (`server/providers/mapillary.js`) adds the token to
|
||||||
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
|
coverage-tile requests (the browser holds it too, for MapillaryJS and the Graph
|
||||||
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
|
API), serves only z11–14 coverage, refuses upstream redirects, strips the
|
||||||
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
|
unused `image` layer, shares one fetch per tile, runs at most six at once
|
||||||
the mesh hides what is behind buildings and trees. A tile is drawn draped until
|
(cancelling those every client has left) and keeps tiles in memory for 24 h
|
||||||
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
|
(a body too big to be a trimmed tile is served but not kept). While Mapillary
|
||||||
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
|
rate-limits, the proxy answers 429 itself until the Retry-After (seconds or an
|
||||||
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
|
HTTP date, at most 10 minutes) is over, so the rest of a view does not go on
|
||||||
steep streets and roads under trees. It uses the mesh floor's rules (the
|
asking.
|
||||||
visible tileset has finished streaming, a real bare-earth prior, the shared
|
|
||||||
mesh window) and probes a cell again once the camera is half as far from it,
|
|
||||||
down to 40 m, so coarse early samples are replaced as finer tiles load. A
|
|
||||||
failed probe keeps the last good sample and distance and is retried after the
|
|
||||||
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. Lighting a chip swaps the source;
|
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
|
clicking the lit chip turns the layer off. A chip without a usable key
|
||||||
@@ -3535,19 +3546,18 @@ 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
|
Mapillary token, Street View on Google 3D with a Google key), the layer
|
||||||
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
||||||
line and the layer row's tooltip say to light it. The
|
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
|
viewer sits under the header with EXPAND, FIT/FILL and close above the
|
||||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
image. SINCE is a stepped slider whose readout names the cut-off date.
|
||||||
stops when the stack changes. Camera moves go through the application's
|
|
||||||
navigation (`attachNavigation`), which releases aircraft and satellite tracking
|
MapillaryJS loads on the first photo. Opening one frames it with a single
|
||||||
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and
|
camera flight through the application's deferred navigation (`begin` /
|
||||||
FOLLOW stops when another feature takes it; a photo takes a deferred ticket
|
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
|
||||||
when it starts opening and frames only if nothing newer took the camera while
|
if newer navigation took the camera while the photo loaded; closing the photo
|
||||||
it loaded. SINCE is a stepped slider whose readout names the
|
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
|
||||||
cut-off date. The panel is portable (`street-level-panel` spec, minimum
|
iPhone, which has no element fullscreen, the viewer opens as a top-layer
|
||||||
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
|
popover instead (hidden where neither API exists). Clean View, recording and
|
||||||
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
|
the cockpit take an expanded photo out of full screen. At phone width the
|
||||||
docks it. At phone width (≤720 px) the viewer height is derived from the rail
|
viewer height comes from the rail band so the whole photo fits.
|
||||||
band so the whole photo fits.
|
|
||||||
|
|
||||||
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
||||||
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
||||||
@@ -3556,9 +3566,10 @@ add a provider: implement the definition, register it in
|
|||||||
`src/app/layers/streetLevel.js` and add its modules to
|
`src/app/layers/streetLevel.js` and add its modules to
|
||||||
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
|
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
|
||||||
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
|
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
|
||||||
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
|
XYZ tiles near the ground, draped, the selected sequence drawn again over
|
||||||
|
them) and its cones
|
||||||
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
|
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
|
||||||
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
|
prefixes; Mapillary, KartaView and Panoramax all do. With providers on different keys the
|
||||||
layer's `requiresKeyId` is null and each chip reports its own key.
|
layer's `requiresKeyId` is null and each chip reports its own key.
|
||||||
|
|
||||||
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
||||||
@@ -3567,7 +3578,7 @@ 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
|
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
|
panorama load is billed to the key. Google's terms forbid Street View beside
|
||||||
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
|
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
|
||||||
off Google 3D it does not appear at all (no chip, legend swatch, globe
|
off Google 3D it does not appear at all (no chip, globe
|
||||||
credit, layer source or button, and Google's script is not loaded), lookups
|
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
|
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
|
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
|
||||||
@@ -3589,9 +3600,8 @@ marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
|
|||||||
of GEV's amber pin. Its
|
of GEV's amber pin. Its
|
||||||
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
|
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
|
(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
|
the IMAGERY and SINCE filters, and the panel hides each filter row no lit provider
|
||||||
honours (`filterControls`), and the legend lists only providers that draw
|
honours (`filterControls`). The viewer is Google's `StreetViewPanorama` in its own
|
||||||
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
|
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
|
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
|
again when the panorama's month, photographer and address arrive (one
|
||||||
@@ -3618,8 +3628,7 @@ are no KartaView photos in it. A tile's lines are OSM way segments with
|
|||||||
photos and carry no sequence, date or projection, so `decode.js` chains
|
photos and carry no sequence, date or projection, so `decode.js` chains
|
||||||
segments that meet end to start (about four times fewer lines; linear time,
|
segments that meet end to start (about four times fewer lines; linear time,
|
||||||
at most 20,000 segments a tile and 5,000 points a line), they are
|
at most 20,000 segments a tile and 5,000 points a line), they are
|
||||||
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
|
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted, and the
|
||||||
(`highlightsSequence: false`, so the legend has no "Selected"), and the
|
|
||||||
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
|
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
|
||||||
streets in view · click a line for its nearest photo"). KartaView filters
|
streets in view · click a line for its nearest photo"). KartaView filters
|
||||||
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
|
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
|
||||||
@@ -3628,8 +3637,7 @@ asked for), so a filtered tile is drawn from the cached JSON segments that
|
|||||||
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
|
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
|
||||||
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
|
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
|
||||||
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
|
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
|
||||||
A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show
|
A filter change reloads the lines (`lineCoverage.reload`), keeps the JSON segments, and caches masks per
|
||||||
until the new ones are in), keeps the JSON segments, and caches masks per
|
|
||||||
filter and tile. A dense filtered PNG can take KartaView over a minute the
|
filter and tile. A dense filtered PNG can take KartaView over a minute the
|
||||||
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
|
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
|
||||||
asked again 4 s later, four tries in all; then the panel says it did not
|
asked again 4 s later, four tries in all; then the panel says it did not
|
||||||
@@ -3656,7 +3664,7 @@ remembered; a storage timeout or 5xx is asked again. The open photo's
|
|||||||
sequence draws as cones from the 250-photo page holding it
|
sequence draws as cones from the 250-photo page holding it
|
||||||
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
|
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
|
||||||
(`countActivePhotos`, asked once per sequence). The camera direction (the
|
(`countActivePhotos`, asked once per sequence). The camera direction (the
|
||||||
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
|
marker, cones and a panorama's centre) is the direction of travel
|
||||||
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
|
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
|
||||||
imagery, while its `heading` is the device compass, which reads 0 for whole
|
imagery, while its `heading` is the device compass, which reads 0 for whole
|
||||||
drives without one and can sit a steady 110° off inside a car. Until the
|
drives without one and can sit a steady 110° off inside a car. Until the
|
||||||
@@ -3713,23 +3721,18 @@ to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
|
|||||||
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
|
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
|
||||||
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
|
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
|
||||||
view draw their longest lines within 90,000 points (`maxPoints`,
|
view draw their longest lines within 90,000 points (`maxPoints`,
|
||||||
`keepOrder`), shared out once loading settles so a sparse tile keeps all
|
`keepOrder`), split evenly between the tiles in view (10,000 points
|
||||||
it has and a dense one (a city centre) takes the rest; the selected
|
each). Every point becomes ~8 ground-line vertices:
|
||||||
sequence is always drawn. Every point becomes ~8 ground-line vertices:
|
|
||||||
unsimplified, the lines hung the tab. Decoded tiles are kept while the
|
unsimplified, the lines hung the tab. Decoded tiles are kept while the
|
||||||
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
|
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView).
|
||||||
and above
|
Above 60 km nothing is drawn and the panel says to zoom in.
|
||||||
60 km the `grid` layer's 0.1°
|
|
||||||
density cells, binned to at most 96×96 points a tile (a z0 tile holds
|
|
||||||
~70,000 cells) and not counted as sequences: the panel says to zoom in.
|
|
||||||
A tile decodes in slices of about 8 ms, the page getting a turn between
|
A tile decodes in slices of about 8 ms, the page getting a turn between
|
||||||
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
|
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
|
||||||
tiles back for its Retry-After, which the browser cannot read here (the API
|
tiles back for its Retry-After, which the browser cannot read here (the API
|
||||||
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
|
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
|
||||||
answers; an answer may take as long as it keeps arriving (silent for 15 s
|
answers; an answer may take as long as it keeps arriving (silent for 15 s
|
||||||
is given up), so a slow link still gets its megabyte tiles. Both
|
is given up), so a slow link still gets its megabyte tiles. Both
|
||||||
filters apply: lines carry their day and camera type, and a cell stays while
|
filters apply: lines carry their day and camera type. A click on a
|
||||||
it holds pictures of the kind asked for (cells are undated). A click on a
|
|
||||||
line asks the core (`actions.openNearest`) for that sequence's picture
|
line asks the core (`actions.openNearest`) for that sequence's picture
|
||||||
nearest the click, within the camera height / 100 (50–400 m: from high up
|
nearest the click, within the camera height / 100 (50–400 m: from high up
|
||||||
a click lands well off a line), without framing, then any sequence's within
|
a click lands well off a line), without framing, then any sequence's within
|
||||||
@@ -3778,8 +3781,9 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
|||||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||||
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||||
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
|
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
|
||||||
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
|
needs no network. It opens and closes a photo from Mapillary, KartaView and
|
||||||
against a production build served by `vite preview` with a dummy token.
|
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
|
||||||
|
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
|
||||||
|
|
||||||
Directions is a keyless front end to the routing the voice agent already
|
Directions is a keyless front end to the routing the voice agent already
|
||||||
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
|
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
|
||||||
@@ -4417,7 +4421,7 @@ silently demoting every later lookup for the session.
|
|||||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||||
- **CCTV v2 foundation:** a pitched
|
- **CCTV v2 foundation:** a pitched
|
||||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||||
far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only —
|
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
||||||
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
|
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
|
||||||
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
|
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
|
||||||
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
|
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
|
||||||
@@ -4435,7 +4439,7 @@ silently demoting every later lookup for the session.
|
|||||||
ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to
|
ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to
|
||||||
the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active
|
the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active
|
||||||
camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a
|
camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a
|
||||||
periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
periodic white flash). Camera icons are cyan for stills, violet for cameras with a video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
||||||
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
|
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
|
||||||
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
|
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
|
||||||
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
|
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
|
||||||
@@ -4450,7 +4454,18 @@ silently demoting every later lookup for the session.
|
|||||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||||
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
||||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
across packs, so a lower global cap thins every region instead of starving the last pack.
|
||||||
~1,100 cameras total, all RAW PRIOR poses, stills-first.
|
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
||||||
|
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
|
||||||
|
pack, so only the very first load waits for the slowest pack (a live first load took about
|
||||||
|
22 s, and is bounded by Tarktee's 30 s). A background refresh that fails or comes back
|
||||||
|
empty keeps the old catalog and is retried after another 15 minutes, not on the next
|
||||||
|
request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
|
||||||
|
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
|
||||||
|
Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document
|
||||||
|
limit, enforced against declared length and streamed bytes. A rejected fetch,
|
||||||
|
unsuccessful response, or body-read failure releases both available response
|
||||||
|
bodies; read failures also cancel an active sibling reader. Cameras sharing
|
||||||
|
an image number are deduplicated before the Tarktee pack cap is applied.
|
||||||
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
||||||
validated 2026-07-21): the COVERAGE toggle is a
|
validated 2026-07-21): the COVERAGE toggle is a
|
||||||
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
||||||
@@ -4767,7 +4782,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
|
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
|
||||||
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
|
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
|
||||||
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
|
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
|
||||||
- **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items.
|
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
|
||||||
- **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance.
|
- **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance.
|
||||||
- **One aircraft-proximity snapshot** (2026-08-22; hardened 2026-09-30). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) computes the Contacts panel cohort once. The voice analyst reads the exact retained snapshot and evaluation timestamp instead of re-scanning live layers, so the panel readout and spoken count for one centre cannot cross snapshots. **Invariant: do not re-derive a proximity count anywhere else.** Each layer retains 20,000 rows and samples one row beyond the cap; a saturated cohort reports `complete:false`, `truncated:true`, and is spoken as a lower bound. They diverged before because the panel read live billboard positions while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions, arbitrary points, and a radius different from the panel radius deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft, complete, evaluatedAt}`.
|
- **One aircraft-proximity snapshot** (2026-08-22; hardened 2026-09-30). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) computes the Contacts panel cohort once. The voice analyst reads the exact retained snapshot and evaluation timestamp instead of re-scanning live layers, so the panel readout and spoken count for one centre cannot cross snapshots. **Invariant: do not re-derive a proximity count anywhere else.** Each layer retains 20,000 rows and samples one row beyond the cap; a saturated cohort reports `complete:false`, `truncated:true`, and is spoken as a lower bound. They diverged before because the panel read live billboard positions while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions, arbitrary points, and a radius different from the panel radius deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft, complete, evaluatedAt}`.
|
||||||
- **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel.
|
- **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel.
|
||||||
@@ -5074,7 +5089,7 @@ pins and share links already supply complete boxes.
|
|||||||
Pixel-mode pinch deltas receive bounded amplification before reaching the
|
Pixel-mode pinch deltas receive bounded amplification before reaching the
|
||||||
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
|
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
|
||||||
unchanged. The application scene owns and removes the relay listener.
|
unchanged. The application scene owns and removes the relay listener.
|
||||||
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
|
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
|
||||||
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
|
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
|
||||||
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
|
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
|
||||||
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
||||||
@@ -5285,8 +5300,8 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
samples and constant elevation during E/N drag; one shared-floor resolution on
|
samples and constant elevation during E/N drag; one shared-floor resolution on
|
||||||
release; late one-shot shared-cell work is permitted during viewshed idle. The
|
release; late one-shot shared-cell work is permitted during viewshed idle. The
|
||||||
A+B harness intentionally excludes citywide LOD assertions.
|
A+B harness intentionally excludes citywide LOD assertions.
|
||||||
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
|
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
|
||||||
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
|
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
|
||||||
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
|
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
|
||||||
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
|
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
|
||||||
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
|
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
|
||||||
|
|||||||
@@ -57,9 +57,8 @@ Related keys (current versions):
|
|||||||
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
|
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
|
||||||
`'1'` closed, absent means the panel's own default. A view opened from a share
|
`'1'` closed, absent means the panel's own default. A view opened from a share
|
||||||
link ignores the stored value entirely.
|
link ignores the stored value entirely.
|
||||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
|
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
|
||||||
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
|
writes none.
|
||||||
the panel header, docks the panel again.
|
|
||||||
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -49,7 +49,6 @@
|
|||||||
"test": "node scripts/run-unit-tests.mjs",
|
"test": "node scripts/run-unit-tests.mjs",
|
||||||
"test:track": "node scripts/track-regression.mjs",
|
"test:track": "node scripts/track-regression.mjs",
|
||||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
||||||
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
|
|
||||||
"qa:street-level": "node scripts/qa-street-level.mjs",
|
"qa:street-level": "node scripts/qa-street-level.mjs",
|
||||||
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
||||||
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||||
|
|||||||
@@ -49,11 +49,6 @@ export function resetKartaViewFixture() {
|
|||||||
nearestLookups = 0;
|
nearestLookups = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nearest lookups answered since the last reset (the first got a 429). */
|
|
||||||
export function kartaViewLookups() {
|
|
||||||
return nearestLookups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
||||||
export function kartaViewPhotos() {
|
export function kartaViewPhotos() {
|
||||||
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapillary Graph API and image CDN answered from fixtures, covering every
|
* Mapillary Graph API and image CDN answered from fixtures, covering every
|
||||||
* request the provider and MapillaryJS make while opening, stepping and
|
* request the provider and MapillaryJS make while opening and closing a
|
||||||
* closing a photo, so the gate needs no network and no real token.
|
* photo, so the gate needs no network and no real token.
|
||||||
*
|
*
|
||||||
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
|
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
|
||||||
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
|
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
|
||||||
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
|
|||||||
north: 38.591,
|
north: 38.591,
|
||||||
});
|
});
|
||||||
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
|
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
|
||||||
export const PHOTO_SPACING_M = 30;
|
const PHOTO_SPACING_M = 30;
|
||||||
/** Fixture thumbnail host (answered, never reached). */
|
/** Fixture thumbnail host (answered, never reached). */
|
||||||
export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
||||||
|
|
||||||
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
|
|||||||
: [];
|
: [];
|
||||||
return { ...json(200, { data }), known: true };
|
return { ...json(200, { data }), known: true };
|
||||||
}
|
}
|
||||||
if (q.has('lat') && q.has('lng')) {
|
|
||||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
|
||||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
|
||||||
const limit = Number(q.get('limit')) || 10;
|
|
||||||
// In no particular order, as the real API answers.
|
|
||||||
const data = photos
|
|
||||||
.filter((image) => metresApart(at, image) <= radius)
|
|
||||||
.reverse()
|
|
||||||
.slice(0, limit)
|
|
||||||
.map((image) => withFields(imageRecord(image), fields));
|
|
||||||
return { ...json(200, { data }), known: true };
|
|
||||||
}
|
|
||||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||||
return {
|
return {
|
||||||
...json(400, { error: 'unsupported images query' }),
|
...json(400, { error: 'unsupported images query' }),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
|
|||||||
height: 700,
|
height: 700,
|
||||||
pitch: -1.4,
|
pitch: -1.4,
|
||||||
});
|
});
|
||||||
export const PNX_CREATOR = 'Fixture Mapper';
|
const PNX_CREATOR = 'Fixture Mapper';
|
||||||
const PNX_SPACING_M = 10;
|
const PNX_SPACING_M = 10;
|
||||||
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
|
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
|
||||||
const API = 'https://api.panoramax.xyz/api';
|
const API = 'https://api.panoramax.xyz/api';
|
||||||
|
|||||||
+25
-32
@@ -46,9 +46,6 @@
|
|||||||
"scripts/qa-voice-auth-focus.mjs",
|
"scripts/qa-voice-auth-focus.mjs",
|
||||||
"src/codexOauthRealtime.test.mjs",
|
"src/codexOauthRealtime.test.mjs",
|
||||||
"src/voice/cloudVoiceAuth.test.mjs",
|
"src/voice/cloudVoiceAuth.test.mjs",
|
||||||
"scripts/qa-panel-resize.mjs",
|
|
||||||
"scripts/qa-panelDrag.mjs",
|
|
||||||
"src/tooling/panelResizeQa.test.mjs",
|
|
||||||
"scripts/qa-terrain-429.mjs",
|
"scripts/qa-terrain-429.mjs",
|
||||||
"src/app/application.js",
|
"src/app/application.js",
|
||||||
"src/app/application.test.mjs",
|
"src/app/application.test.mjs",
|
||||||
@@ -182,7 +179,6 @@
|
|||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
"src/ui/panelResize.test.mjs",
|
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.test.mjs",
|
"src/ui/panelRails.test.mjs",
|
||||||
@@ -285,6 +281,7 @@
|
|||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelPositionControls.js",
|
"src/ui/panelPositionControls.js",
|
||||||
|
"src/ui/panelPositionControls.test.mjs",
|
||||||
"src/ui/recordingControls.js",
|
"src/ui/recordingControls.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
"src/ui/shellFeedback.js",
|
"src/ui/shellFeedback.js",
|
||||||
@@ -446,6 +443,7 @@
|
|||||||
"src/data/satellites.js",
|
"src/data/satellites.js",
|
||||||
"src/data/rocketLaunches.js",
|
"src/data/rocketLaunches.js",
|
||||||
"src/layers/satellites/source.test.mjs",
|
"src/layers/satellites/source.test.mjs",
|
||||||
|
"src/layers/satellites/stale.test.mjs",
|
||||||
"src/layers/launches/source.test.mjs",
|
"src/layers/launches/source.test.mjs",
|
||||||
"src/layers/launches/ingestion.test.mjs",
|
"src/layers/launches/ingestion.test.mjs",
|
||||||
"src/layers/installations/lifecycle.js",
|
"src/layers/installations/lifecycle.js",
|
||||||
@@ -557,6 +555,8 @@
|
|||||||
"src/data/cctvNswSource.test.mjs",
|
"src/data/cctvNswSource.test.mjs",
|
||||||
"src/data/cctvTxdotSource.test.mjs",
|
"src/data/cctvTxdotSource.test.mjs",
|
||||||
"src/layers/cctv/source.test.mjs",
|
"src/layers/cctv/source.test.mjs",
|
||||||
|
"src/layers/cctv/frames.test.mjs",
|
||||||
|
"src/layers/cctv/rendering.test.mjs",
|
||||||
"src/data/alprCameras.js",
|
"src/data/alprCameras.js",
|
||||||
"src/data/alprCameras.test.mjs",
|
"src/data/alprCameras.test.mjs",
|
||||||
"src/data/dataCredits.test.mjs",
|
"src/data/dataCredits.test.mjs",
|
||||||
@@ -910,27 +910,32 @@
|
|||||||
"src/ui/streetLevelPresentation.test.mjs",
|
"src/ui/streetLevelPresentation.test.mjs",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/tooling/mapillaryProvider.test.mjs",
|
"src/tooling/mapillaryProvider.test.mjs",
|
||||||
|
"src/app/layers/streetLevel.test.mjs",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||||
|
"src/layers/streetLevel/params.js",
|
||||||
|
"src/layers/streetLevel/params.test.mjs",
|
||||||
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
|
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||||
|
"src/layers/streetLevel/providerSwitches.test.mjs",
|
||||||
|
"src/layers/streetLevel/registry.js",
|
||||||
|
"src/layers/streetLevel/registry.test.mjs",
|
||||||
|
"src/layers/streetLevel/uiState.js",
|
||||||
|
"src/layers/streetLevel/uiState.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
||||||
"src/streetLevelMarkup.test.mjs",
|
"src/streetLevelMarkup.test.mjs",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
|
||||||
"src/layers/streetLevel/params.js",
|
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
|
||||||
"src/layers/streetLevel/uiState.js",
|
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/lineCoverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequenceCones.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
"src/layers/streetLevel/tileMath.js",
|
"src/layers/streetLevel/tileMath.js",
|
||||||
@@ -943,7 +948,6 @@
|
|||||||
"src/layers/streetLevel/providers/google/viewer.js",
|
"src/layers/streetLevel/providers/google/viewer.js",
|
||||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||||
"src/layers/streetLevel/groundClick.test.mjs",
|
"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/provider.test.mjs",
|
||||||
"src/layers/streetLevel/providers/google/viewer.test.mjs",
|
"src/layers/streetLevel/providers/google/viewer.test.mjs",
|
||||||
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
|
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
|
||||||
@@ -953,8 +957,6 @@
|
|||||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||||
"src/layers/streetLevel/panorama.js",
|
"src/layers/streetLevel/panorama.js",
|
||||||
"src/layers/streetLevel/lineCoverage.js",
|
|
||||||
"src/layers/streetLevel/sequenceCones.js",
|
|
||||||
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
|
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
|
||||||
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
|
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
|
||||||
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
|
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
|
||||||
@@ -983,8 +985,6 @@
|
|||||||
"src/testSupport/kartaViewFakes.mjs",
|
"src/testSupport/kartaViewFakes.mjs",
|
||||||
"src/testSupport/googleStreetViewFakes.mjs",
|
"src/testSupport/googleStreetViewFakes.mjs",
|
||||||
"server/providers/mapillary.js",
|
"server/providers/mapillary.js",
|
||||||
"server/providers/mapillary/routes.js",
|
|
||||||
"server/providers/mapillary/constants.js",
|
|
||||||
"server/providers/mapillary/tiles.js",
|
"server/providers/mapillary/tiles.js",
|
||||||
"server/providers/mapillary/trim.js",
|
"server/providers/mapillary/trim.js",
|
||||||
"src/layers/streetLevel/tileMath.test.mjs",
|
"src/layers/streetLevel/tileMath.test.mjs",
|
||||||
@@ -997,23 +997,15 @@
|
|||||||
"scripts/fixtures/street-level/kartaView.mjs",
|
"scripts/fixtures/street-level/kartaView.mjs",
|
||||||
"scripts/fixtures/street-level/panoramax.mjs",
|
"scripts/fixtures/street-level/panoramax.mjs",
|
||||||
"src/layers/streetLevel/filter.test.mjs",
|
"src/layers/streetLevel/filter.test.mjs",
|
||||||
"src/layers/streetLevel/registry.test.mjs",
|
|
||||||
"src/layers/streetLevel/params.test.mjs",
|
|
||||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
|
||||||
"src/layers/streetLevel/uiState.test.mjs",
|
|
||||||
"src/layers/streetLevel/groundCast.test.mjs",
|
|
||||||
"src/layers/streetLevel/meshSampler.test.mjs",
|
|
||||||
"src/layers/streetLevel/view.test.mjs",
|
"src/layers/streetLevel/view.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
"src/layers/streetLevel/cameraFollow.test.mjs",
|
"src/layers/streetLevel/cameraFraming.test.mjs",
|
||||||
"src/ui/streetLevelControls.test.mjs",
|
"src/ui/streetLevelControls.test.mjs",
|
||||||
"src/ui/panelDock.test.mjs",
|
|
||||||
"src/layers/streetLevel/marker.test.mjs",
|
"src/layers/streetLevel/marker.test.mjs",
|
||||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
|
"src/layers/streetLevel/sequenceCones.test.mjs",
|
||||||
"src/layers/streetLevel/selection.test.mjs",
|
"src/layers/streetLevel/selection.test.mjs",
|
||||||
"src/layers/streetLevel/index.test.mjs",
|
"src/layers/streetLevel/index.test.mjs",
|
||||||
"scripts/qa-overpass-offload.mjs",
|
"scripts/qa-overpass-offload.mjs",
|
||||||
@@ -1068,5 +1060,6 @@
|
|||||||
"scripts/assemble-changelog.mjs",
|
"scripts/assemble-changelog.mjs",
|
||||||
"src/tooling/changelogFragments.test.mjs",
|
"src/tooling/changelogFragments.test.mjs",
|
||||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||||
|
"src/data/routeSegment.test.mjs",
|
||||||
"src/terrainHeightsBounds.test.mjs"
|
"src/terrainHeightsBounds.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -214,8 +214,6 @@
|
|||||||
"exports": ["./server/providers/mapillary"],
|
"exports": ["./server/providers/mapillary"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/mapillary.js",
|
"server/providers/mapillary.js",
|
||||||
"server/providers/mapillary/routes.js",
|
|
||||||
"server/providers/mapillary/constants.js",
|
|
||||||
"server/providers/mapillary/tiles.js",
|
"server/providers/mapillary/tiles.js",
|
||||||
"server/providers/mapillary/trim.js",
|
"server/providers/mapillary/trim.js",
|
||||||
"server/providers/common/same-site.js",
|
"server/providers/common/same-site.js",
|
||||||
@@ -724,7 +722,6 @@
|
|||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelPositionControls.js",
|
"src/ui/panelPositionControls.js",
|
||||||
"src/ui/panelResize.js",
|
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/radio.js",
|
"src/ui/radio.js",
|
||||||
@@ -757,6 +754,7 @@
|
|||||||
"src/ui/shellFacade.js",
|
"src/ui/shellFacade.js",
|
||||||
"src/ui/streetLevelControls.js",
|
"src/ui/streetLevelControls.js",
|
||||||
"src/ui/streetLevelPresentation.js",
|
"src/ui/streetLevelPresentation.js",
|
||||||
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/ui/localSdrControls.js",
|
"src/ui/localSdrControls.js",
|
||||||
"src/layers/localAdsb/feedNames.js",
|
"src/layers/localAdsb/feedNames.js",
|
||||||
"src/ui/localSdrPresentation.js",
|
"src/ui/localSdrPresentation.js",
|
||||||
@@ -1383,15 +1381,13 @@
|
|||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
@@ -1399,9 +1395,8 @@
|
|||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/lineCoverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequenceCones.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
@@ -1428,14 +1423,13 @@
|
|||||||
"src/layers/streetLevel/imageSize.js",
|
"src/layers/streetLevel/imageSize.js",
|
||||||
"src/layers/streetLevel/tileCache.js",
|
"src/layers/streetLevel/tileCache.js",
|
||||||
"src/layers/streetLevel/panorama.js",
|
"src/layers/streetLevel/panorama.js",
|
||||||
"src/layers/streetLevel/lineCoverage.js",
|
|
||||||
"src/layers/streetLevel/sequenceCones.js",
|
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
"scripts/qa-street-level.mjs",
|
"scripts/qa-street-level.mjs",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||||
},
|
},
|
||||||
@@ -1633,7 +1627,15 @@
|
|||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/ui/statusText.js"
|
"src/ui/statusText.js",
|
||||||
|
"src/voice/providerCommands.js",
|
||||||
|
"src/voice/providerCapabilities.js",
|
||||||
|
"src/voice/geminiSession.js",
|
||||||
|
"src/voice/geminiAudio.js",
|
||||||
|
"src/voice/geminiCaptureWorklet.js",
|
||||||
|
"src/sources/httpBody.js",
|
||||||
|
"src/sources/requestDeadline.js",
|
||||||
|
"src/i18n/marker.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -2195,7 +2197,6 @@
|
|||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelPositionControls.js",
|
"src/ui/panelPositionControls.js",
|
||||||
"src/ui/panelResize.js",
|
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/radio.js",
|
"src/ui/radio.js",
|
||||||
@@ -2415,24 +2416,22 @@
|
|||||||
"src/ui/streetLevelControls.js",
|
"src/ui/streetLevelControls.js",
|
||||||
"src/ui/streetLevelPresentation.js",
|
"src/ui/streetLevelPresentation.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
|
||||||
"src/layers/streetLevel/credits.js",
|
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
|
||||||
"src/layers/streetLevel/marker.js",
|
|
||||||
"src/layers/streetLevel/view.js",
|
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
"src/layers/streetLevel/registry.js",
|
||||||
|
"scripts/qa-street-level.mjs",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
|
"src/layers/streetLevel/marker.js",
|
||||||
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/lineCoverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequenceCones.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
@@ -2459,8 +2458,6 @@
|
|||||||
"src/layers/streetLevel/imageSize.js",
|
"src/layers/streetLevel/imageSize.js",
|
||||||
"src/layers/streetLevel/tileCache.js",
|
"src/layers/streetLevel/tileCache.js",
|
||||||
"src/layers/streetLevel/panorama.js",
|
"src/layers/streetLevel/panorama.js",
|
||||||
"src/layers/streetLevel/lineCoverage.js",
|
|
||||||
"src/layers/streetLevel/sequenceCones.js",
|
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
@@ -2473,7 +2470,6 @@
|
|||||||
"src/data/cameraArrival.js",
|
"src/data/cameraArrival.js",
|
||||||
"src/layers/alpr/floors.js",
|
"src/layers/alpr/floors.js",
|
||||||
"src/data/militaryNames.js",
|
"src/data/militaryNames.js",
|
||||||
"src/sources/requestDeadline.js",
|
|
||||||
"src/data/local_data/osm_military_names/names.json",
|
"src/data/local_data/osm_military_names/names.json",
|
||||||
"src/layers/installations/namedMarkers.js",
|
"src/layers/installations/namedMarkers.js",
|
||||||
"src/data/infrastructureOverlayEntry.js",
|
"src/data/infrastructureOverlayEntry.js",
|
||||||
@@ -2497,7 +2493,13 @@
|
|||||||
"src/layers/airQuality/index.js",
|
"src/layers/airQuality/index.js",
|
||||||
"src/layers/airQuality/model.js",
|
"src/layers/airQuality/model.js",
|
||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js",
|
||||||
"src/ui/statusText.js"
|
"src/ui/statusText.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||||
|
"src/voice/providerCommands.js",
|
||||||
|
"src/voice/providerCapabilities.js",
|
||||||
|
"src/voice/geminiSession.js",
|
||||||
|
"src/voice/geminiAudio.js",
|
||||||
|
"src/voice/geminiCaptureWorklet.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -2992,24 +2994,22 @@
|
|||||||
"src/layers/recentImagery/thumbnails.js",
|
"src/layers/recentImagery/thumbnails.js",
|
||||||
"src/maps/imageryHost.js",
|
"src/maps/imageryHost.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
|
||||||
"src/layers/streetLevel/credits.js",
|
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
|
||||||
"src/layers/streetLevel/marker.js",
|
|
||||||
"src/layers/streetLevel/view.js",
|
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
"src/layers/streetLevel/registry.js",
|
||||||
|
"scripts/qa-street-level.mjs",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
|
"src/layers/streetLevel/marker.js",
|
||||||
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/lineCoverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequenceCones.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
@@ -3036,8 +3036,6 @@
|
|||||||
"src/layers/streetLevel/imageSize.js",
|
"src/layers/streetLevel/imageSize.js",
|
||||||
"src/layers/streetLevel/tileCache.js",
|
"src/layers/streetLevel/tileCache.js",
|
||||||
"src/layers/streetLevel/panorama.js",
|
"src/layers/streetLevel/panorama.js",
|
||||||
"src/layers/streetLevel/lineCoverage.js",
|
|
||||||
"src/layers/streetLevel/sequenceCones.js",
|
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
@@ -3050,7 +3048,6 @@
|
|||||||
"src/data/cameraArrival.js",
|
"src/data/cameraArrival.js",
|
||||||
"src/layers/alpr/floors.js",
|
"src/layers/alpr/floors.js",
|
||||||
"src/data/militaryNames.js",
|
"src/data/militaryNames.js",
|
||||||
"src/sources/requestDeadline.js",
|
|
||||||
"src/data/local_data/osm_military_names/names.json",
|
"src/data/local_data/osm_military_names/names.json",
|
||||||
"src/layers/installations/namedMarkers.js",
|
"src/layers/installations/namedMarkers.js",
|
||||||
"src/data/infrastructureOverlayEntry.js",
|
"src/data/infrastructureOverlayEntry.js",
|
||||||
@@ -3068,7 +3065,8 @@
|
|||||||
"src/layers/airQuality/model.js",
|
"src/layers/airQuality/model.js",
|
||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js",
|
||||||
"src/ui/statusText.js",
|
"src/ui/statusText.js",
|
||||||
"src/ui/layerNames.js"
|
"src/ui/layerNames.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3232,7 +3230,6 @@
|
|||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelPositionControls.js",
|
"src/ui/panelPositionControls.js",
|
||||||
"src/ui/panelResize.js",
|
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
@@ -3385,7 +3382,8 @@
|
|||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/ui/statusText.js"
|
"src/ui/statusText.js",
|
||||||
|
"src/voice/providerCapabilities.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -326,8 +326,10 @@ async function main() {
|
|||||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
|
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
|
||||||
);
|
);
|
||||||
let sidecar = await loadSidecar(output);
|
let sidecar = await loadSidecar(output);
|
||||||
|
// A cold catalog waits for its slowest pack (Tarktee gets 30 s), so allow
|
||||||
|
// more than that.
|
||||||
const response = await fetch(`${base}/api/cctv/sources`, {
|
const response = await fetch(`${base}/api/cctv/sources`, {
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
|
||||||
const catalog = await response.json();
|
const catalog = await response.json();
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
/** Console messages and failed requests kept per page. */
|
/** Console messages and failed requests kept per page. */
|
||||||
export const EVIDENCE_LIMIT = 200;
|
const EVIDENCE_LIMIT = 200;
|
||||||
|
|
||||||
/** Cesium's console line when the render loop throws (CesiumWidget). */
|
/** Cesium's console line when the render loop throws (CesiumWidget). */
|
||||||
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
||||||
|
|
||||||
/** Mask tokens in a URL or message before it is printed or saved. */
|
/** Mask tokens in a URL or message before it is printed or saved. */
|
||||||
export function redact(text) {
|
export function redact(text) {
|
||||||
|
|||||||
+241
-107
@@ -11,8 +11,10 @@
|
|||||||
* exactly once per camera ACTIVATION (the §9.1 obstruction probe) and
|
* exactly once per camera ACTIVATION (the §9.1 obstruction probe) and
|
||||||
* never again in steady state; shared mesh-floor sampling fires at most
|
* never again in steady state; shared mesh-floor sampling fires at most
|
||||||
* once per coarse cell during the staggered geometry-load drain, and
|
* once per coarse cell during the staggered geometry-load drain, and
|
||||||
* actually COMPLETES once tiles load (>=1 real sample — the
|
* actually COMPLETES once tiles load in photoreal mode (>=1 real sample
|
||||||
* ceiling alone would pass trivially at Δ=0 if the snap never finished),
|
* — the ceiling alone would pass trivially at Δ=0 if the snap never
|
||||||
|
* finished). Globe mode instead proves the rendered mount uses a real
|
||||||
|
* terrain height with zero mesh samples. Sampling
|
||||||
* stays flat while idle, and stays flat for non-positional pose edits.
|
* stays flat while idle, and stays flat for non-positional pose edits.
|
||||||
* 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane
|
* 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane
|
||||||
* exist for the active camera, the plane's corners coincide with the
|
* exist for the active camera, the plane's corners coincide with the
|
||||||
@@ -139,6 +141,56 @@ function record(name, ok, detail) {
|
|||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Each page.evaluate is bounded by puppeteer's `protocolTimeout` (default
|
||||||
|
// 180s). Waits never come close: they poll from Node (below). The headroom is
|
||||||
|
// for the few long evaluates, such as enabling the full ~3,700-camera catalog
|
||||||
|
// on a real GPU, and matches the other qa-*.mjs harnesses.
|
||||||
|
const PROTOCOL_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls an in-page predicate from Node, one short page.evaluate per tick,
|
||||||
|
* until it holds or the budget runs out. Used instead of page.waitForFunction
|
||||||
|
* for two reasons: its default rAF polling stops in a minimised or hidden
|
||||||
|
* headful window (timers keep running, rAF does not), and its result is one
|
||||||
|
* CDP call that protocolTimeout cuts off, while the headful drain budget
|
||||||
|
* reaches ~50 minutes. A predicate that throws fails the run instead of
|
||||||
|
* silently spending the budget.
|
||||||
|
* @param {import('puppeteer').Page} page
|
||||||
|
* @param {Function} predicate - In-page predicate.
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} opts.timeoutMs - Overall budget.
|
||||||
|
* @param {number} [opts.intervalMs=250] - Delay between checks.
|
||||||
|
* @param {unknown[]} [opts.args=[]] - Arguments passed to the predicate.
|
||||||
|
* The predicate must return a boolean: its result crosses CDP by value.
|
||||||
|
* @returns {Promise<boolean>} True if the predicate held within the budget.
|
||||||
|
*/
|
||||||
|
async function waitForPredicate(
|
||||||
|
page,
|
||||||
|
predicate,
|
||||||
|
{ timeoutMs, intervalMs = 250, args = [] },
|
||||||
|
) {
|
||||||
|
// performance.now() is monotonic and doesn't advance while the machine
|
||||||
|
// sleeps, so neither a clock change nor a laptop sleep eats the budget.
|
||||||
|
const deadline = performance.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
if (await page.evaluate(predicate, ...args)) return true;
|
||||||
|
} catch (err) {
|
||||||
|
// A navigation or Vite reload replaces the execution context: poll again.
|
||||||
|
if (
|
||||||
|
!/Execution context was destroyed|Cannot find context/.test(
|
||||||
|
err?.message ?? '',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remaining = deadline - performance.now();
|
||||||
|
if (remaining <= 0) return false;
|
||||||
|
await sleep(Math.min(intervalMs, remaining));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SwiftShader can spend tens of seconds servicing the hover drillPick emitted
|
// SwiftShader can spend tens of seconds servicing the hover drillPick emitted
|
||||||
// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the
|
// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the
|
||||||
// pointer while ADJUST is disabled, then re-enable it and explicitly render a
|
// pointer while ADJUST is disabled, then re-enable it and explicitly render a
|
||||||
@@ -155,29 +207,58 @@ async function positionPointerForGizmoDrag(page, point, frameCount = 3) {
|
|||||||
gev.dataManager.layers
|
gev.dataManager.layers
|
||||||
.get('cctv')
|
.get('cctv')
|
||||||
.module.setParams({ calibrationMode: true });
|
.module.setParams({ calibrationMode: true });
|
||||||
const scene = gev.viewer.scene;
|
return (await window.__qaRenderFrames(count, 5000)) >= count;
|
||||||
let rendered = 0;
|
}, frameCount);
|
||||||
return new Promise((resolve) => {
|
}
|
||||||
let settled = false;
|
|
||||||
const finish = (complete) => {
|
/**
|
||||||
if (settled) return;
|
* Saves a screenshot for human review. These are never a gate, so a capture
|
||||||
settled = true;
|
* that fails (for example when the renderer stalls in a hidden headful window)
|
||||||
clearTimeout(timer);
|
* is logged instead of ending the run.
|
||||||
remove();
|
* @param {import('puppeteer').Page} page
|
||||||
resolve(complete);
|
* @param {string} file
|
||||||
};
|
* @param {string} label
|
||||||
const remove = scene.postRender.addEventListener(() => {
|
*/
|
||||||
rendered += 1;
|
async function saveReviewShot(page, file, label) {
|
||||||
if (rendered >= count) {
|
try {
|
||||||
finish(true);
|
await page.screenshot({ path: file });
|
||||||
return;
|
console.log(` screenshot (${label}) → ${path.relative(REPO_ROOT, file)}`);
|
||||||
}
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
` screenshot skipped (${label}): ${path.relative(REPO_ROOT, file)}: ${err?.message ?? err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs window.__qaRenderFrames(count, capMs) in the page: requests renders
|
||||||
|
* until `count` frames have passed scene.postRender or capMs runs out, and
|
||||||
|
* resolves with the number of frames rendered. The cap keeps a stalled
|
||||||
|
* renderer (headful window asleep or hidden) from hanging the CDP call.
|
||||||
|
* @param {import('puppeteer').Page} page
|
||||||
|
*/
|
||||||
|
function installRenderFramesHelper(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
window.__qaRenderFrames = (count, capMs) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const scene = window.__godsEyeView.viewer.scene;
|
||||||
|
let frames = 0;
|
||||||
|
const finish = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
remove();
|
||||||
|
resolve(frames);
|
||||||
|
};
|
||||||
|
const remove = scene.postRender.addEventListener(() => {
|
||||||
|
if (++frames >= count) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scene.requestRender();
|
||||||
|
});
|
||||||
|
const timer = setTimeout(finish, capMs);
|
||||||
scene.requestRender();
|
scene.requestRender();
|
||||||
});
|
});
|
||||||
const timer = setTimeout(() => finish(false), 5000);
|
});
|
||||||
scene.requestRender();
|
|
||||||
});
|
|
||||||
}, frameCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-page helper (stringified and re-defined inside each evaluate that needs
|
// In-page helper (stringified and re-defined inside each evaluate that needs
|
||||||
@@ -280,23 +361,21 @@ const BEARING_SHIFT_SRC = `
|
|||||||
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
|
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
|
||||||
*/
|
*/
|
||||||
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||||
return page
|
return waitForPredicate(
|
||||||
.waitForFunction(
|
page,
|
||||||
() => {
|
() => {
|
||||||
const scene = window.__godsEyeView.viewer.scene;
|
const scene = window.__godsEyeView.viewer.scene;
|
||||||
const prims = scene.primitives;
|
const prims = scene.primitives;
|
||||||
for (let i = 0; i < prims.length; i++) {
|
for (let i = 0; i < prims.length; i++) {
|
||||||
const p = prims.get(i);
|
const p = prims.get(i);
|
||||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||||
return p.tilesLoaded === true;
|
return p.tilesLoaded === true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true; // no tileset in the scene — nothing to wait for
|
}
|
||||||
},
|
return true; // no tileset in the scene — nothing to wait for
|
||||||
{ timeout: timeoutMs },
|
},
|
||||||
)
|
{ timeoutMs },
|
||||||
.then(() => true)
|
);
|
||||||
.catch(() => false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -364,6 +443,10 @@ async function main() {
|
|||||||
const chromeExecutable = findChromeExecutable();
|
const chromeExecutable = findChromeExecutable();
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: HEADFUL ? false : 'new',
|
headless: HEADFUL ? false : 'new',
|
||||||
|
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||||
|
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||||
|
// the length of a headful run.
|
||||||
|
pipe: true,
|
||||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
@@ -397,13 +480,18 @@ async function main() {
|
|||||||
});
|
});
|
||||||
console.log('Loading app...');
|
console.log('Loading app...');
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
await page.waitForFunction(
|
const appReady = await waitForPredicate(
|
||||||
|
page,
|
||||||
() =>
|
() =>
|
||||||
window.__godsEyeView &&
|
Boolean(
|
||||||
window.__godsEyeView.viewer &&
|
window.__godsEyeView &&
|
||||||
window.__godsEyeView.dataManager,
|
window.__godsEyeView.viewer &&
|
||||||
{ timeout: 60000 },
|
window.__godsEyeView.dataManager,
|
||||||
|
),
|
||||||
|
{ timeoutMs: 60000 },
|
||||||
);
|
);
|
||||||
|
if (!appReady) throw new Error('app did not initialise within 60s');
|
||||||
|
await installRenderFramesHelper(page);
|
||||||
// Let the initial fly-to Austin and first tiles settle.
|
// Let the initial fly-to Austin and first tiles settle.
|
||||||
await sleep(4000);
|
await sleep(4000);
|
||||||
|
|
||||||
@@ -475,18 +563,15 @@ async function main() {
|
|||||||
console.log(
|
console.log(
|
||||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||||
);
|
);
|
||||||
const drained = await page
|
const drained = await waitForPredicate(
|
||||||
.waitForFunction(
|
page,
|
||||||
() => {
|
() => {
|
||||||
const mod =
|
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||||
window.__godsEyeView.dataManager.layers.get('cctv').module;
|
const ui = mod.getUIState();
|
||||||
const ui = mod.getUIState();
|
return Boolean(ui.loading) && ui.loading.active === false;
|
||||||
return ui.loading && ui.loading.active === false;
|
},
|
||||||
},
|
{ timeoutMs: drainBudgetMs, intervalMs: 1000 },
|
||||||
{ timeout: drainBudgetMs },
|
);
|
||||||
)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
record(
|
record(
|
||||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||||
drained ? true : HEADFUL ? false : null,
|
drained ? true : HEADFUL ? false : null,
|
||||||
@@ -575,27 +660,87 @@ async function main() {
|
|||||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||||
// latency of the completion pass (update interval is 10s).
|
// latency of the completion pass (update interval is 10s).
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
const globeMode = await page.evaluate(
|
||||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
||||||
let sampleFloorOk = false;
|
|
||||||
if (tilesSeenLoaded) {
|
|
||||||
sampleFloorOk = await page
|
|
||||||
.waitForFunction(
|
|
||||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
|
||||||
{ timeout: 30000 },
|
|
||||||
c0.sampleHeight,
|
|
||||||
)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
const cFloor = await readCounters();
|
|
||||||
record(
|
|
||||||
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
|
||||||
tilesSeenLoaded ? sampleFloorOk : null,
|
|
||||||
tilesSeenLoaded
|
|
||||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
|
||||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
|
||||||
);
|
);
|
||||||
|
if (globeMode) {
|
||||||
|
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||||
|
// Require independently fetched DEM heights and the actual rendered
|
||||||
|
// mount, so a catalog/geoid fallback cannot turn zero samples into a pass.
|
||||||
|
console.log(
|
||||||
|
'Checking terrain-globe completion against live DEM heights...',
|
||||||
|
);
|
||||||
|
const terrain = await page.evaluate(async () => {
|
||||||
|
const camera = window.__godsEyeView.dataManager.layers
|
||||||
|
.get('cctv')
|
||||||
|
.module.getUIState().activeCamera;
|
||||||
|
const exact = `${camera.lon.toFixed(5)},${camera.lat.toFixed(5)}`;
|
||||||
|
const cell = `${camera.lon.toFixed(3)},${camera.lat.toFixed(3)}`;
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/terrain/heights?points=${encodeURIComponent(`${exact};${cell}`)}`,
|
||||||
|
{ signal: AbortSignal.timeout(30000) },
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
return { ok: response.ok, results: body.results };
|
||||||
|
});
|
||||||
|
const heights = terrain.results?.map((result) => result?.ellipsoid);
|
||||||
|
const terrainReady =
|
||||||
|
terrain.ok && heights?.length === 2 && heights.every(Number.isFinite);
|
||||||
|
const mountResolved =
|
||||||
|
terrainReady &&
|
||||||
|
(await waitForPredicate(
|
||||||
|
page,
|
||||||
|
(expected, base) => {
|
||||||
|
const { viewer, dataManager } = window.__godsEyeView;
|
||||||
|
const camera = dataManager.layers
|
||||||
|
.get('cctv')
|
||||||
|
.module.getUIState().activeCamera;
|
||||||
|
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
|
||||||
|
const mount = ray?.polyline?.positions.getValue(
|
||||||
|
viewer.clock.currentTime,
|
||||||
|
)?.[0];
|
||||||
|
const height =
|
||||||
|
mount &&
|
||||||
|
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
|
||||||
|
.height;
|
||||||
|
return (
|
||||||
|
Number.isFinite(camera.groundPriorM) &&
|
||||||
|
Math.abs(camera.groundPriorM - expected[0]) < 0.01 &&
|
||||||
|
Number.isFinite(height) &&
|
||||||
|
expected.some(
|
||||||
|
(ground) =>
|
||||||
|
Math.abs(height - ground - camera.mountHeightM) < 0.05,
|
||||||
|
) &&
|
||||||
|
window.__qaCounters.sampleHeight === base
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeoutMs: 30000, args: [heights, c0.sampleHeight] },
|
||||||
|
));
|
||||||
|
record(
|
||||||
|
'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples',
|
||||||
|
Boolean(mountResolved),
|
||||||
|
`terrain=${JSON.stringify(heights)}, mesh sample Δ=${(await readCounters()).sampleHeight - c0.sampleHeight}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||||
|
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||||
|
let sampleFloorOk = false;
|
||||||
|
if (tilesSeenLoaded) {
|
||||||
|
sampleFloorOk = await waitForPredicate(
|
||||||
|
page,
|
||||||
|
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||||
|
{ timeoutMs: 30000, args: [c0.sampleHeight] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cFloor = await readCounters();
|
||||||
|
record(
|
||||||
|
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||||
|
tilesSeenLoaded ? sampleFloorOk : null,
|
||||||
|
tilesSeenLoaded
|
||||||
|
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||||
|
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Let the completion pass finish before the steady-state window: require
|
// Let the completion pass finish before the steady-state window: require
|
||||||
// the sampleHeight counter quiet for 12s (> the 10s update interval, so
|
// the sampleHeight counter quiet for 12s (> the 10s update interval, so
|
||||||
@@ -1358,18 +1503,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
window.__qaCctvSiblingOwner = owner;
|
window.__qaCctvSiblingOwner = owner;
|
||||||
await new Promise((resolve) => {
|
const framesRendered = await window.__qaRenderFrames(3, 10000);
|
||||||
let frames = 0;
|
|
||||||
const remove = scene.postRender.addEventListener(() => {
|
|
||||||
if (++frames < 3) {
|
|
||||||
scene.requestRender();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
remove();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
scene.requestRender();
|
|
||||||
});
|
|
||||||
const rect = scene.canvas.getBoundingClientRect();
|
const rect = scene.canvas.getBoundingClientRect();
|
||||||
const picked = scene.pick(canvasPoint);
|
const picked = scene.pick(canvasPoint);
|
||||||
const pickedId = picked?.id?.id ?? picked?.id ?? null;
|
const pickedId = picked?.id?.id ?? picked?.id ?? null;
|
||||||
@@ -1382,26 +1516,33 @@ async function main() {
|
|||||||
typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null),
|
typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null),
|
||||||
ownerId: owner.id,
|
ownerId: owner.id,
|
||||||
ownsExactObject: picked?.id === owner,
|
ownsExactObject: picked?.id === owner,
|
||||||
|
framesRendered,
|
||||||
projectionDelta: Math.hypot(
|
projectionDelta: Math.hypot(
|
||||||
projected.x - canvasPoint.x,
|
projected.x - canvasPoint.x,
|
||||||
projected.y - canvasPoint.y,
|
projected.y - canvasPoint.y,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}, emptyClickPoint);
|
}, emptyClickPoint);
|
||||||
|
const siblingOwnsPick =
|
||||||
|
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||||
|
siblingTarget.ownsExactObject;
|
||||||
|
// No frame at all within the 10s cap points at a stalled renderer rather
|
||||||
|
// than at picking, though the harness can't rule out both. Fail it on the
|
||||||
|
// real-GPU sign-off path; leave it inconclusive under headless GL. A slow
|
||||||
|
// SwiftShader frame (1-2 of 3) still counts as a real miss.
|
||||||
|
const siblingRendererStalled =
|
||||||
|
siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered === 0;
|
||||||
record(
|
record(
|
||||||
'sibling test object owns its canvas pick',
|
'sibling test object owns its canvas pick',
|
||||||
|
!siblingTarget
|
||||||
|
? null
|
||||||
|
: siblingOwnsPick ||
|
||||||
|
(siblingRendererStalled && !HEADFUL ? null : false),
|
||||||
siblingTarget
|
siblingTarget
|
||||||
? siblingTarget.pickedId === 'qa-cctv-sibling-owner' &&
|
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||||
siblingTarget.ownsExactObject
|
|
||||||
: null,
|
|
||||||
siblingTarget
|
|
||||||
? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
|
||||||
: 'no previously verified empty canvas ray was available',
|
: 'no previously verified empty canvas ray was available',
|
||||||
);
|
);
|
||||||
if (
|
if (siblingOwnsPick) {
|
||||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
|
||||||
siblingTarget.ownsExactObject
|
|
||||||
) {
|
|
||||||
await page.mouse.click(siblingTarget.x, siblingTarget.y);
|
await page.mouse.click(siblingTarget.x, siblingTarget.y);
|
||||||
await sleep(250);
|
await sleep(250);
|
||||||
}
|
}
|
||||||
@@ -1426,8 +1567,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'sibling canvas click passes through without CCTV selection or deselection',
|
'sibling canvas click passes through without CCTV selection or deselection',
|
||||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
siblingOwnsPick
|
||||||
siblingTarget.ownsExactObject
|
|
||||||
? siblingClick.activeId === clickEvidenceSetup.activeId &&
|
? siblingClick.activeId === clickEvidenceSetup.activeId &&
|
||||||
siblingClick.transitions === siblingClick.baselineTransitions &&
|
siblingClick.transitions === siblingClick.baselineTransitions &&
|
||||||
siblingClick.focusEvents === 0 &&
|
siblingClick.focusEvents === 0 &&
|
||||||
@@ -1626,10 +1766,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
|
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
|
||||||
await page.screenshot({ path: viewshedShot });
|
await saveReviewShot(page, viewshedShot, 'visual review only');
|
||||||
console.log(
|
|
||||||
` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||||
@@ -1963,10 +2100,7 @@ async function main() {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||||
await page.screenshot({ path: shot });
|
await saveReviewShot(page, shot, 'visual review only, not a gate');
|
||||||
console.log(
|
|
||||||
` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// No console errors during the whole exercise.
|
// No console errors during the whole exercise.
|
||||||
record(
|
record(
|
||||||
|
|||||||
@@ -1,481 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Browser QA for the floating CCTV panel: lift, resize from every handle,
|
|
||||||
* minimum size, reload, and dock again. `--fail-on-retry` (or
|
|
||||||
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
|
|
||||||
*/
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
|
|
||||||
import {
|
|
||||||
describePress,
|
|
||||||
dockPanelByDoubleClick,
|
|
||||||
dragPointer,
|
|
||||||
liftPanelByHeader,
|
|
||||||
nextFrames,
|
|
||||||
} from './qa-panelDrag.mjs';
|
|
||||||
import {
|
|
||||||
hookRenderErrors,
|
|
||||||
readRenderErrors,
|
|
||||||
saveFailureArtifacts,
|
|
||||||
watchPage,
|
|
||||||
} from './qa-browserEvidence.mjs';
|
|
||||||
|
|
||||||
export const PANEL_ID = 'cctv-panel';
|
|
||||||
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
|
|
||||||
export const MIN_SIZE = { width: 300, height: 160 };
|
|
||||||
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
|
|
||||||
const VIEWPORT = { width: 1400, height: 900 };
|
|
||||||
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
|
|
||||||
|
|
||||||
/** Box edges a resize in `dir` must leave exactly where they were. */
|
|
||||||
export function pinnedEdges(dir) {
|
|
||||||
return Object.entries(EDGE_BY_SIDE)
|
|
||||||
.filter(([side]) => !dir.includes(side))
|
|
||||||
.map(([, edge]) => edge);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
|
|
||||||
export function growthDelta(dir, amount) {
|
|
||||||
return {
|
|
||||||
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
|
|
||||||
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Viewport point inside the handle for `dir`, given the panel's client rect.
|
|
||||||
* Edge strips straddle the border by 3px; corners and the grip sit inside.
|
|
||||||
*/
|
|
||||||
export function handlePoint(rect, dir) {
|
|
||||||
const inset = dir.length === 2 ? 4 : 1;
|
|
||||||
const x = dir.includes('w')
|
|
||||||
? rect.left + inset
|
|
||||||
: dir.includes('e')
|
|
||||||
? rect.right - inset
|
|
||||||
: rect.left + rect.width / 2;
|
|
||||||
const y = dir.includes('n')
|
|
||||||
? rect.top + inset
|
|
||||||
: dir.includes('s')
|
|
||||||
? rect.bottom - inset
|
|
||||||
: rect.top + rect.height / 2;
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pinned edges that moved during a resize in `dir`. */
|
|
||||||
export function driftedEdges(before, after, dir, tolerance = 1.5) {
|
|
||||||
const edges = (box) => ({
|
|
||||||
left: box.left,
|
|
||||||
top: box.top,
|
|
||||||
right: box.left + box.width,
|
|
||||||
bottom: box.top + box.height,
|
|
||||||
});
|
|
||||||
const a = edges(before);
|
|
||||||
const b = edges(after);
|
|
||||||
return pinnedEdges(dir).filter(
|
|
||||||
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function near(actual, expected, tolerance, label) {
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(actual - expected) <= tolerance,
|
|
||||||
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { default: puppeteer } = await import('puppeteer');
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const urlIndex = args.indexOf('--url');
|
|
||||||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
|
||||||
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
headless: true,
|
|
||||||
executablePath:
|
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
|
||||||
(await puppeteer.executablePath()),
|
|
||||||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
|
||||||
});
|
|
||||||
const monitors = [];
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setViewport(VIEWPORT);
|
|
||||||
const errors = [];
|
|
||||||
monitors.push(watchPage(page, { name: 'panel', errors }));
|
|
||||||
await hookRenderErrors(page);
|
|
||||||
|
|
||||||
const boot = async () => {
|
|
||||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
|
|
||||||
timeout: 90_000,
|
|
||||||
});
|
|
||||||
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.querySelector('.first-run-explore')?.click();
|
|
||||||
});
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.getElementById('first-run-launcher')?.remove();
|
|
||||||
for (const node of document.querySelectorAll('[class*=first-run]'))
|
|
||||||
node.remove();
|
|
||||||
});
|
|
||||||
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
|
|
||||||
await nextFrames(page);
|
|
||||||
};
|
|
||||||
const readPanel = () =>
|
|
||||||
page.evaluate((id) => {
|
|
||||||
const panel = document.getElementById(id);
|
|
||||||
const rect = panel.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
left: rect.left,
|
|
||||||
top: rect.top,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
// handlePoint() reads the far edges for s/e handles.
|
|
||||||
right: rect.right,
|
|
||||||
bottom: rect.bottom,
|
|
||||||
floating: panel.classList.contains('panel-floating'),
|
|
||||||
collapsed: panel.classList.contains('collapsed'),
|
|
||||||
inRail: panel.parentElement?.id === 'right-context-rail',
|
|
||||||
allocated: panel.style.getPropertyValue(
|
|
||||||
'--right-panel-allocated-height',
|
|
||||||
),
|
|
||||||
inlineWidth: panel.style.width,
|
|
||||||
inlineHeight: panel.style.height,
|
|
||||||
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
|
|
||||||
};
|
|
||||||
}, PANEL_ID);
|
|
||||||
/**
|
|
||||||
* Best effort: wait for the box to hold still for half a second. The lift
|
|
||||||
* is checked against the box at pointerdown, so a timeout only costs time.
|
|
||||||
*/
|
|
||||||
const settle = async () => {
|
|
||||||
try {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(id) => {
|
|
||||||
const r = document.getElementById(id).getBoundingClientRect();
|
|
||||||
const box = [r.left, r.top, r.width, r.height]
|
|
||||||
.map(Math.round)
|
|
||||||
.join(',');
|
|
||||||
const now = performance.now();
|
|
||||||
if (window.__qaLastBox !== box) {
|
|
||||||
window.__qaLastBox = box;
|
|
||||||
window.__qaSince = now;
|
|
||||||
}
|
|
||||||
return now - window.__qaSince >= 500;
|
|
||||||
},
|
|
||||||
{ polling: 100, timeout: 20_000 },
|
|
||||||
PANEL_ID,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name !== 'TimeoutError') throw error;
|
|
||||||
console.log('note: the CCTV panel was still moving; lifting anyway');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const headerPoint = async () => {
|
|
||||||
const rect = await page.evaluate((id) => {
|
|
||||||
const title = document.querySelector(
|
|
||||||
`#${id} .panel-header .panel-title`,
|
|
||||||
);
|
|
||||||
const r = title.getBoundingClientRect();
|
|
||||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
||||||
}, PANEL_ID);
|
|
||||||
return rect;
|
|
||||||
};
|
|
||||||
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
|
|
||||||
|
|
||||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await page.evaluate((key) => {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
|
|
||||||
}, STORAGE_KEY);
|
|
||||||
await boot();
|
|
||||||
|
|
||||||
await page.evaluate((id) => {
|
|
||||||
const panel = document.getElementById(id);
|
|
||||||
if (panel.classList.contains('collapsed'))
|
|
||||||
document
|
|
||||||
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
|
|
||||||
.click();
|
|
||||||
}, PANEL_ID);
|
|
||||||
await page.waitForFunction(
|
|
||||||
(id) => !document.getElementById(id).classList.contains('collapsed'),
|
|
||||||
{},
|
|
||||||
PANEL_ID,
|
|
||||||
);
|
|
||||||
// The rail animates panel heights, and on a slow software renderer the
|
|
||||||
// panels above CCTV can still be moving after a fixed sleep.
|
|
||||||
await settle();
|
|
||||||
const docked = await readPanel();
|
|
||||||
assert.equal(docked.floating, false);
|
|
||||||
assert.equal(docked.inRail, true);
|
|
||||||
|
|
||||||
// Names whatever above CCTV in the rail shifted it, for a retry note.
|
|
||||||
const railAbove = () =>
|
|
||||||
page.evaluate((id) => {
|
|
||||||
const out = [];
|
|
||||||
for (const node of document.getElementById('right-context-rail')
|
|
||||||
?.children || []) {
|
|
||||||
if (node.id === id) break;
|
|
||||||
out.push(
|
|
||||||
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return out.join(' ');
|
|
||||||
}, PANEL_ID);
|
|
||||||
const aboveAtDock = await railAbove();
|
|
||||||
// On CI a panel above CCTV grows after load and can shift the header out
|
|
||||||
// from under the press; the helper retries only such a provable miss.
|
|
||||||
const attempt = await liftPanelByHeader(page, PANEL_ID, {
|
|
||||||
dx: -200,
|
|
||||||
dy: -100,
|
|
||||||
note: async () =>
|
|
||||||
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
|
|
||||||
});
|
|
||||||
const { pressed } = attempt;
|
|
||||||
const lifted = await readPanel();
|
|
||||||
assert.equal(
|
|
||||||
lifted.floating,
|
|
||||||
true,
|
|
||||||
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
lifted.collapsed,
|
|
||||||
false,
|
|
||||||
'the drag-ending click must not collapse',
|
|
||||||
);
|
|
||||||
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
|
|
||||||
near(
|
|
||||||
lifted.left,
|
|
||||||
Math.max(6, pressed.left - 200),
|
|
||||||
2,
|
|
||||||
`lifted left (${moved})`,
|
|
||||||
);
|
|
||||||
near(
|
|
||||||
lifted.top,
|
|
||||||
Math.max(6, pressed.top - 100),
|
|
||||||
2,
|
|
||||||
`lifted top (${moved})`,
|
|
||||||
);
|
|
||||||
near(lifted.width, pressed.width, 2, 'lifted width');
|
|
||||||
// The rail lets go of the lifted panel on its next layout pass.
|
|
||||||
await page
|
|
||||||
.waitForFunction(
|
|
||||||
(id) =>
|
|
||||||
document
|
|
||||||
.getElementById(id)
|
|
||||||
.style.getPropertyValue('--right-panel-allocated-height') === '',
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
PANEL_ID,
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
const afterLayout = await readPanel();
|
|
||||||
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
|
|
||||||
const railExcludes = await page.evaluate((id) => {
|
|
||||||
const rail = document.getElementById('right-context-rail');
|
|
||||||
// Same rule as the rail: hidden panels take no room.
|
|
||||||
const counted = [...rail.children].filter((panel) =>
|
|
||||||
panel.matches(
|
|
||||||
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
expandedCount: rail.dataset.expandedCount,
|
|
||||||
counted: counted.length,
|
|
||||||
includesPanel: counted.some((panel) => panel.id === id),
|
|
||||||
};
|
|
||||||
}, PANEL_ID);
|
|
||||||
assert.equal(railExcludes.includesPanel, false);
|
|
||||||
if (railExcludes.expandedCount !== undefined)
|
|
||||||
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
|
|
||||||
const storedAfterLift = JSON.parse(afterLayout.stored);
|
|
||||||
assert.equal(storedAfterLift.floating, true);
|
|
||||||
assert.equal(
|
|
||||||
'height' in storedAfterLift,
|
|
||||||
false,
|
|
||||||
'a drag alone must not freeze the measured height',
|
|
||||||
);
|
|
||||||
console.log('PASS: header drag lifts CCTV out of the rail');
|
|
||||||
|
|
||||||
// Park near the top: the voice dock would cover the south corners once
|
|
||||||
// the loop below has grown the window.
|
|
||||||
{
|
|
||||||
const header = await headerPoint();
|
|
||||||
await drag(header, 0, 120 - header.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
const limits = {
|
|
||||||
minWidth: MIN_SIZE.width,
|
|
||||||
minHeight: MIN_SIZE.height,
|
|
||||||
viewportWidth: VIEWPORT.width,
|
|
||||||
viewportHeight: VIEWPORT.height,
|
|
||||||
};
|
|
||||||
for (const dir of RESIZE_DIRECTIONS) {
|
|
||||||
const before = await readPanel();
|
|
||||||
const { dx, dy } = growthDelta(dir, 40);
|
|
||||||
const point = handlePoint(before, dir);
|
|
||||||
const hit = await page.evaluate(({ x, y }) => {
|
|
||||||
const node = document.elementFromPoint(x, y);
|
|
||||||
if (!node) return 'nothing';
|
|
||||||
const dirAttr = node.dataset?.dir
|
|
||||||
? `[data-dir=${node.dataset.dir}]`
|
|
||||||
: '';
|
|
||||||
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
|
|
||||||
}, point);
|
|
||||||
await drag(point, dx, dy);
|
|
||||||
const after = await readPanel();
|
|
||||||
const expected = resizeBox(before, dir, dx, dy, limits);
|
|
||||||
for (const key of ['left', 'top', 'width', 'height'])
|
|
||||||
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
|
|
||||||
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
|
|
||||||
}
|
|
||||||
console.log('PASS: eight resize handles keep the opposite edge pinned');
|
|
||||||
|
|
||||||
let before = await readPanel();
|
|
||||||
await drag(handlePoint(before, 'se'), -2000, -2000);
|
|
||||||
let after = await readPanel();
|
|
||||||
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
|
|
||||||
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
|
|
||||||
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
|
|
||||||
before = after;
|
|
||||||
await drag(handlePoint(before, 'nw'), 2000, 2000);
|
|
||||||
after = await readPanel();
|
|
||||||
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
|
|
||||||
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
|
|
||||||
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
|
|
||||||
console.log('PASS: minimum size holds from both corners');
|
|
||||||
|
|
||||||
const saved = await readPanel();
|
|
||||||
const record = JSON.parse(saved.stored);
|
|
||||||
assert.equal(record.floating, true);
|
|
||||||
await boot();
|
|
||||||
const restored = await readPanel();
|
|
||||||
assert.equal(restored.floating, true, 'floating survives a reload');
|
|
||||||
for (const key of ['left', 'top', 'width', 'height'])
|
|
||||||
near(restored[key], record[key], 2, `restored ${key}`);
|
|
||||||
console.log('PASS: reload restores position and size');
|
|
||||||
|
|
||||||
// A collapsed window keeps the height chosen for it, through a drag of
|
|
||||||
// its header strip and a reload.
|
|
||||||
const setCollapsed = async (collapsed) => {
|
|
||||||
await page.evaluate(
|
|
||||||
(id, want) => {
|
|
||||||
const panel = document.getElementById(id);
|
|
||||||
if (panel.classList.contains('collapsed') !== want)
|
|
||||||
document
|
|
||||||
.querySelector(
|
|
||||||
`.panel-collapse-btn[data-collapse-target="${id}"]`,
|
|
||||||
)
|
|
||||||
.click();
|
|
||||||
},
|
|
||||||
PANEL_ID,
|
|
||||||
collapsed,
|
|
||||||
);
|
|
||||||
await page.waitForFunction(
|
|
||||||
(id, want) =>
|
|
||||||
document.getElementById(id).classList.contains('collapsed') === want,
|
|
||||||
{},
|
|
||||||
PANEL_ID,
|
|
||||||
collapsed,
|
|
||||||
);
|
|
||||||
await nextFrames(page);
|
|
||||||
};
|
|
||||||
// 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; tried ${tried.join('; ')}`,
|
|
||||||
);
|
|
||||||
await setCollapsed(true);
|
|
||||||
const strip = await readPanel();
|
|
||||||
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
|
|
||||||
await drag(await headerPoint(), 40, 30);
|
|
||||||
const movedStrip = await readPanel();
|
|
||||||
assert.equal(movedStrip.floating, true, 'the strip stays a window');
|
|
||||||
near(
|
|
||||||
JSON.parse(movedStrip.stored).height,
|
|
||||||
chosen.height,
|
|
||||||
2,
|
|
||||||
'stored height while collapsed',
|
|
||||||
);
|
|
||||||
await boot();
|
|
||||||
await setCollapsed(false);
|
|
||||||
near(
|
|
||||||
(await readPanel()).height,
|
|
||||||
chosen.height,
|
|
||||||
2,
|
|
||||||
'height after expanding',
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'PASS: a collapsed window keeps its height through a drag and a reload',
|
|
||||||
);
|
|
||||||
|
|
||||||
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
|
|
||||||
const snapped = await readPanel();
|
|
||||||
assert.equal(
|
|
||||||
snapped.floating,
|
|
||||||
false,
|
|
||||||
`double-click snaps back (${describePress(dock)})`,
|
|
||||||
);
|
|
||||||
assert.equal(snapped.inRail, true);
|
|
||||||
assert.equal(snapped.inlineWidth, '');
|
|
||||||
assert.equal(snapped.inlineHeight, '');
|
|
||||||
assert.equal(snapped.stored, null, 'the storage key is gone');
|
|
||||||
console.log('PASS: header double-click returns the panel to the rail');
|
|
||||||
|
|
||||||
const renderErrors = await readRenderErrors(page);
|
|
||||||
assert.deepEqual(
|
|
||||||
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
|
|
||||||
[],
|
|
||||||
'no page errors and no Cesium render-loop errors',
|
|
||||||
);
|
|
||||||
console.log('PASS: no page errors and no render-loop errors');
|
|
||||||
} catch (error) {
|
|
||||||
const dir = await saveFailureArtifacts({
|
|
||||||
dir: ARTIFACT_DIR,
|
|
||||||
browser,
|
|
||||||
monitors,
|
|
||||||
error,
|
|
||||||
}).catch(() => null);
|
|
||||||
if (dir) console.error(`failure evidence saved in ${dir}/`);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
process.argv[1] &&
|
|
||||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
||||||
) {
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error.stack || error.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
/**
|
|
||||||
* Header press-and-verify for the QA gates that lift a rail panel into a
|
|
||||||
* floating window and dock it again. The rail animates panel heights, so a
|
|
||||||
* point read once can be under another panel by the time the pointer goes
|
|
||||||
* down. Only a provable miss (a recorded press outside the header) is
|
|
||||||
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
|
|
||||||
* panel's own movement cannot explain fails the run instead.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
/** Retries after the first attempt, and the pause before each. */
|
|
||||||
export const PRESS_RETRIES = 2;
|
|
||||||
const RETRY_PAUSE_MS = 1000;
|
|
||||||
|
|
||||||
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
|
|
||||||
export function failOnRetryDefault(
|
|
||||||
env = process.env,
|
|
||||||
argv = process.argv.slice(2),
|
|
||||||
) {
|
|
||||||
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A failed attempt whose press landed outside the header. No recorded press
|
|
||||||
* is a failure, not a miss: a retry would only hide it.
|
|
||||||
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
|
|
||||||
*/
|
|
||||||
export function pressMissed(attempt) {
|
|
||||||
return !attempt.done && attempt.pressed?.inHeader === false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the panel's box changed by more than 2 px between two reads. */
|
|
||||||
export function panelMoved(before, after) {
|
|
||||||
if (!before || !after) return false;
|
|
||||||
return ['left', 'top', 'width', 'height'].some(
|
|
||||||
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The panel's box, rounded, as `left,top widthxheight`. */
|
|
||||||
export function formatBox(box) {
|
|
||||||
if (!box) return 'unknown';
|
|
||||||
const r = (value) => Math.round(value ?? NaN);
|
|
||||||
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function panelBox(page, panelId) {
|
|
||||||
return page.evaluate((id) => {
|
|
||||||
const r = document.getElementById(id).getBoundingClientRect();
|
|
||||||
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
||||||
}, panelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function headerPoint(page, panelId) {
|
|
||||||
return page.evaluate((id) => {
|
|
||||||
const title = document.querySelector(`#${id} .panel-header .panel-title`);
|
|
||||||
const r = title.getBoundingClientRect();
|
|
||||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
||||||
}, panelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Name the element at a point, and whether it is the panel's header. */
|
|
||||||
export function hitAt(page, point, panelId) {
|
|
||||||
return page.evaluate(
|
|
||||||
({ x, y, id }) => {
|
|
||||||
const node = document.elementFromPoint(x, y);
|
|
||||||
if (!node) return { target: 'nothing', inHeader: false };
|
|
||||||
return {
|
|
||||||
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
|
|
||||||
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ ...point, id: panelId },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-resolve the header point until it hits the header or the wait runs out
|
|
||||||
* (the press then records the miss).
|
|
||||||
*/
|
|
||||||
export async function headerTarget(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
{ tries = 20, intervalMs = 500 } = {},
|
|
||||||
) {
|
|
||||||
let point = await headerPoint(page, panelId);
|
|
||||||
let hit = await hitAt(page, point, panelId);
|
|
||||||
for (let i = 0; i < tries && !hit.inHeader; i++) {
|
|
||||||
await delay(intervalMs);
|
|
||||||
point = await headerPoint(page, panelId);
|
|
||||||
hit = await hitAt(page, point, panelId);
|
|
||||||
}
|
|
||||||
const box = await panelBox(page, panelId);
|
|
||||||
return { point, hit, timedOut: !hit.inHeader, box };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best effort: wait until the panel's box has held still for `stillMs`, so
|
|
||||||
* the header does not move between the hit check and the press.
|
|
||||||
*/
|
|
||||||
export async function waitForStill(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
{ stillMs = 300, timeout = 5000 } = {},
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(id, ms) => {
|
|
||||||
const r = document.getElementById(id).getBoundingClientRect();
|
|
||||||
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
|
|
||||||
const now = performance.now();
|
|
||||||
if (window.__qaStillBox !== box) {
|
|
||||||
window.__qaStillBox = box;
|
|
||||||
window.__qaStillSince = now;
|
|
||||||
}
|
|
||||||
return now - window.__qaStillSince >= ms;
|
|
||||||
},
|
|
||||||
{ polling: 100, timeout },
|
|
||||||
panelId,
|
|
||||||
stillMs,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name !== 'TimeoutError') throw error;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record the next pointerdown's target and the panel's box at that moment. */
|
|
||||||
export function recordNextPress(page, panelId) {
|
|
||||||
return page.evaluate((id) => {
|
|
||||||
window.__qaPressBox = null;
|
|
||||||
window.addEventListener(
|
|
||||||
'pointerdown',
|
|
||||||
(event) => {
|
|
||||||
const r = document.getElementById(id).getBoundingClientRect();
|
|
||||||
const target = event.target;
|
|
||||||
window.__qaPressBox = {
|
|
||||||
left: r.left,
|
|
||||||
top: r.top,
|
|
||||||
width: r.width,
|
|
||||||
height: r.height,
|
|
||||||
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
|
|
||||||
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ capture: true, once: true },
|
|
||||||
);
|
|
||||||
}, panelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPress(page) {
|
|
||||||
return page.evaluate(() => window.__qaPressBox ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForFloating(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
floating,
|
|
||||||
{ timeout = 3000 } = {},
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(id, want) =>
|
|
||||||
document.getElementById(id).classList.contains('panel-floating') ===
|
|
||||||
want,
|
|
||||||
{ polling: 50, timeout },
|
|
||||||
panelId,
|
|
||||||
floating,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name !== 'TimeoutError') throw error;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Two paints: by then every layout change an input caused is on screen. */
|
|
||||||
export function nextFrames(page) {
|
|
||||||
return page.evaluate(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) =>
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
|
|
||||||
await page.mouse.move(from.x, from.y);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
|
|
||||||
await page.mouse.move(from.x + dx, from.y + dy, { steps });
|
|
||||||
await page.mouse.up();
|
|
||||||
await nextFrames(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeRetry(
|
|
||||||
panelId,
|
|
||||||
attempt,
|
|
||||||
{ round, retries, extra = '' },
|
|
||||||
) {
|
|
||||||
const { hit, pressed, timedOut, still, box } = attempt;
|
|
||||||
return [
|
|
||||||
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
|
|
||||||
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
|
|
||||||
`stillness ${still === false ? 'timed out' : 'held'}`,
|
|
||||||
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
|
|
||||||
]
|
|
||||||
.concat(extra ? [extra] : [])
|
|
||||||
.join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retry `press` only while it provably misses the header. */
|
|
||||||
async function pressUntilDecided(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
press,
|
|
||||||
{
|
|
||||||
retries,
|
|
||||||
log,
|
|
||||||
note,
|
|
||||||
pauseMs = RETRY_PAUSE_MS,
|
|
||||||
failOnRetry = failOnRetryDefault(),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
for (let round = 0; ; round++) {
|
|
||||||
const still = await waitForStill(page, panelId);
|
|
||||||
await recordNextPress(page, panelId);
|
|
||||||
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
|
|
||||||
const done = await press(point);
|
|
||||||
const attempt = {
|
|
||||||
point,
|
|
||||||
hit,
|
|
||||||
timedOut,
|
|
||||||
still,
|
|
||||||
box,
|
|
||||||
done,
|
|
||||||
pressed: await readPress(page),
|
|
||||||
retries: round,
|
|
||||||
};
|
|
||||||
if (round >= retries || !pressMissed(attempt)) return attempt;
|
|
||||||
const why = describeRetry(panelId, attempt, {
|
|
||||||
round,
|
|
||||||
retries,
|
|
||||||
extra: note ? await note() : '',
|
|
||||||
});
|
|
||||||
if (failOnRetry && !panelMoved(box, attempt.pressed))
|
|
||||||
throw new Error(
|
|
||||||
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
|
|
||||||
);
|
|
||||||
log(`note: ${why}; retrying`);
|
|
||||||
log(`::warning title=Panel press retried::${why}`);
|
|
||||||
await delay(pauseMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lift a docked panel into a floating window with a header drag.
|
|
||||||
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
|
|
||||||
*/
|
|
||||||
export function liftPanelByHeader(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
{
|
|
||||||
dx,
|
|
||||||
dy,
|
|
||||||
retries = PRESS_RETRIES,
|
|
||||||
log = console.log,
|
|
||||||
note,
|
|
||||||
pauseMs,
|
|
||||||
failOnRetry,
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
return pressUntilDecided(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
async (point) => {
|
|
||||||
await dragPointer(page, point, dx, dy);
|
|
||||||
return waitForFloating(page, panelId, true);
|
|
||||||
},
|
|
||||||
{ retries, log, note, pauseMs, failOnRetry },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dock a floating panel with a header double-click; same result as lifting. */
|
|
||||||
export function dockPanelByDoubleClick(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
{
|
|
||||||
retries = PRESS_RETRIES,
|
|
||||||
log = console.log,
|
|
||||||
note,
|
|
||||||
pauseMs,
|
|
||||||
failOnRetry,
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
return pressUntilDecided(
|
|
||||||
page,
|
|
||||||
panelId,
|
|
||||||
async (point) => {
|
|
||||||
await page.mouse.click(point.x, point.y, { clickCount: 1 });
|
|
||||||
await page.mouse.click(point.x, point.y, { clickCount: 2 });
|
|
||||||
return waitForFloating(page, panelId, false);
|
|
||||||
},
|
|
||||||
{ retries, log, note, pauseMs, failOnRetry },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where an attempt pressed, for an assertion message. */
|
|
||||||
export function describePress(attempt) {
|
|
||||||
const { point, hit, pressed } = attempt;
|
|
||||||
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
|
|
||||||
if (!pressed)
|
|
||||||
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
|
|
||||||
const n = attempt.retries || 0;
|
|
||||||
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
|
|
||||||
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
|
|
||||||
}
|
|
||||||
+506
-2774
File diff suppressed because it is too large.
Load diff
@@ -43,11 +43,7 @@ try {
|
|||||||
const fpsReadout = document.querySelector('.frame-rate-readout');
|
const fpsReadout = document.querySelector('.frame-rate-readout');
|
||||||
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
||||||
const watchObserver = (name) => {
|
const watchObserver = (name) => {
|
||||||
const owner =
|
const observer = ui._panelLayout[name];
|
||||||
name === '_draggableResizeObserver'
|
|
||||||
? ui._panelPosition
|
|
||||||
: ui._panelLayout;
|
|
||||||
const observer = owner[name];
|
|
||||||
if (!observer) return false;
|
if (!observer) return false;
|
||||||
const disconnect = observer.disconnect.bind(observer);
|
const disconnect = observer.disconnect.bind(observer);
|
||||||
counts[name] = 0;
|
counts[name] = 0;
|
||||||
@@ -59,7 +55,6 @@ try {
|
|||||||
};
|
};
|
||||||
const observed = [
|
const observed = [
|
||||||
'_commandDockTrayObserver',
|
'_commandDockTrayObserver',
|
||||||
'_draggableResizeObserver',
|
|
||||||
'_leftStackResizeObserver',
|
'_leftStackResizeObserver',
|
||||||
'_rightStackResizeObserver',
|
'_rightStackResizeObserver',
|
||||||
'_leftStackMutationObserver',
|
'_leftStackMutationObserver',
|
||||||
@@ -139,7 +134,6 @@ try {
|
|||||||
const focusBefore = document.activeElement;
|
const focusBefore = document.activeElement;
|
||||||
const stoppedBeforeRestoration =
|
const stoppedBeforeRestoration =
|
||||||
ui._panelLayout.destroyed &&
|
ui._panelLayout.destroyed &&
|
||||||
ui._panelPosition.destroyed &&
|
|
||||||
ui._feedback.destroyed &&
|
ui._feedback.destroyed &&
|
||||||
ui._recording.destroyed &&
|
ui._recording.destroyed &&
|
||||||
cockpit.destroyed &&
|
cockpit.destroyed &&
|
||||||
@@ -194,11 +188,7 @@ try {
|
|||||||
counts[field] === 1 && ui._contextControls[field] === null,
|
counts[field] === 1 && ui._contextControls[field] === null,
|
||||||
),
|
),
|
||||||
observersReleased: observed.every(
|
observersReleased: observed.every(
|
||||||
(name) =>
|
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
|
||||||
counts[name] === 1 &&
|
|
||||||
(name === '_draggableResizeObserver'
|
|
||||||
? ui._panelPosition
|
|
||||||
: ui._panelLayout)[name] === null,
|
|
||||||
),
|
),
|
||||||
subscriptionReleased:
|
subscriptionReleased:
|
||||||
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
|
|
||||||
if (url.pathname === '/sources') {
|
if (url.pathname === '/sources') {
|
||||||
const body = {
|
const body = {
|
||||||
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
|
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
||||||
sources: sources.map((source) => ({
|
sources: sources.map((source) => ({
|
||||||
id: source.id,
|
id: source.id,
|
||||||
pack: source.pack,
|
pack: source.pack,
|
||||||
|
|||||||
@@ -204,16 +204,19 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
|
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
|
||||||
* callers so a post-TTL burst launches ONE refetch, not one per request. */
|
* callers so a post-TTL burst launches ONE refetch, not one per request. */
|
||||||
let _cctvSourceInflight = null;
|
let _cctvSourceInflight = null;
|
||||||
/** @type {Array<{pack: string, available: number, served: number}>} Packs
|
/** @type {WeakMap<Array<object>, Array<{pack: string, available: number, served: number}>>}
|
||||||
* that serve fewer cameras than they offer. */
|
* Packs that serve fewer cameras than they offer, keyed by the source list
|
||||||
let _cctvTrimmedPacks = [];
|
* they describe, so a request served the old list during a background
|
||||||
|
* refresh never pairs it with the new list's trim notes. */
|
||||||
|
const _cctvTrimmedPacks = new WeakMap();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assemble and cache the merged CCTV source list.
|
* Assemble and cache the merged CCTV source list.
|
||||||
*
|
*
|
||||||
* Merges every source pack (live open-data packs, local file, env
|
* Merges every source pack (live open-data packs, local file, env
|
||||||
* variable), deduplicates by ID, shares the catalog cap fairly across
|
* variable), deduplicates by ID, shares the catalog cap fairly across
|
||||||
* packs, and caches for CCTV_SOURCE_CACHE_MS.
|
* packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is
|
||||||
|
* returned while a background refresh replaces it; only a first load waits.
|
||||||
*
|
*
|
||||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||||
*/
|
*/
|
||||||
@@ -228,11 +231,31 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
|
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
|
||||||
// instead of each launching the full multi-provider refetch. The `.finally`
|
// instead of each launching the full multi-provider refetch. The `.finally`
|
||||||
// clears the ref so the next post-TTL cycle starts fresh.
|
// clears the ref so the next post-TTL cycle starts fresh.
|
||||||
if (_cctvSourceInflight) return _cctvSourceInflight;
|
if (!_cctvSourceInflight) {
|
||||||
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
||||||
_cctvSourceInflight = null;
|
_cctvSourceInflight = null;
|
||||||
});
|
});
|
||||||
return _cctvSourceInflight;
|
// A background refresh has no caller waiting on it: report its failure
|
||||||
|
// here, once per refresh rather than once per request it serves. Keep
|
||||||
|
// the old catalog for another TTL, as an empty refresh does, so a refresh
|
||||||
|
// that keeps failing is retried once per TTL, not back to back.
|
||||||
|
if (_cctvSourceCache.length) {
|
||||||
|
_cctvSourceInflight.catch((error) => {
|
||||||
|
_cctvSourceCacheAt = Date.now();
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] background catalog refresh failed; serving',
|
||||||
|
_cctvSourceCache.length,
|
||||||
|
'cameras from the last catalog:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With a catalog in hand, answer with it while the refresh runs: the
|
||||||
|
// refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up
|
||||||
|
// to its 30 s timeout), and every /api/cctv request would otherwise wait
|
||||||
|
// for it. Only the first load, with nothing to serve yet, waits.
|
||||||
|
return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,7 +345,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
}
|
}
|
||||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||||
_cctvSourceCache = capped;
|
_cctvSourceCache = capped;
|
||||||
_cctvTrimmedPacks = trimmedPacks;
|
_cctvTrimmedPacks.set(capped, trimmedPacks);
|
||||||
} else {
|
} else {
|
||||||
// Every source came back empty (all live packs timed out / upstream outage)
|
// Every source came back empty (all live packs timed out / upstream outage)
|
||||||
// but a good catalog is already cached — serve it stale rather than blanking
|
// but a good catalog is already cached — serve it stale rather than blanking
|
||||||
@@ -336,7 +359,10 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
return _cctvSourceCache;
|
return _cctvSourceCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Packs serving fewer cameras than they offer, as of the last refresh. */
|
/** Packs serving fewer cameras than they offer in `sources` (by default the
|
||||||
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
|
* current catalog). Pass the list a request was served to keep the two in
|
||||||
|
* step while a background refresh swaps the catalog. */
|
||||||
|
getCctvSources.trimmedPacks = (sources = _cctvSourceCache) =>
|
||||||
|
_cctvTrimmedPacks.get(sources) ?? [];
|
||||||
return getCctvSources;
|
return getCctvSources;
|
||||||
}
|
}
|
||||||
@@ -209,6 +209,8 @@ export const TARKTEE_IMAGES_URL =
|
|||||||
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
||||||
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
||||||
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
|
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
|
||||||
|
/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */
|
||||||
|
export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||||
export const TARKTEE_ANCHORS = [
|
export const TARKTEE_ANCHORS = [
|
||||||
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
||||||
{ lat: 58.378, lon: 26.729 }, // Tartu
|
{ lat: 58.378, lon: 26.729 }, // Tartu
|
||||||
@@ -338,6 +340,49 @@ export const NORWAY_ANCHORS = [
|
|||||||
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
|
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
|
||||||
|
* sea level (NN2000), used as the Vegvesen cameras' ground prior because the
|
||||||
|
* camera feed's points are 2D. */
|
||||||
|
export const KARTVERKET_HEIGHT_URL =
|
||||||
|
'https://ws.geonorge.no/hoydedata/v1/punkt';
|
||||||
|
/** Points per request. The API takes up to 50, but above ~20 it answers the
|
||||||
|
* extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not
|
||||||
|
* at all; at 20 nearly every camera resolves from the 1 m terrain model. */
|
||||||
|
export const KARTVERKET_MAX_POINTS = 20;
|
||||||
|
/** Requests in flight at once. The whole country is ~36 requests of
|
||||||
|
* 0.6-9.5 s each, so a cold lookup takes ~6-13 s. */
|
||||||
|
export const KARTVERKET_CONCURRENCY = 8;
|
||||||
|
/** Deadline for one complete lookup, shared by every request. A lookup cut
|
||||||
|
* short by it is resumed on the next refresh, without a backoff. */
|
||||||
|
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
|
||||||
|
/** One request that takes longer than this counts as a failing service. */
|
||||||
|
export const KARTVERKET_REQUEST_TIMEOUT_MS = 10 * 1000;
|
||||||
|
/** The Vegvesen lane's budget, measured from the start of the loader (catalog
|
||||||
|
* fetch included), for waiting on heights. Under the 15 s per-provider fetch
|
||||||
|
* timeout so this pack is never the catalog's slowest lane. A lookup still
|
||||||
|
* running after it keeps filling the cache for the next refresh. */
|
||||||
|
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
|
||||||
|
/** The lane only waits when more than this share of its cameras has no
|
||||||
|
* answer yet (a cold cache). A few new cameras or expired misses are looked
|
||||||
|
* up in the background instead of holding the refresh. */
|
||||||
|
export const KARTVERKET_WAIT_MISSING_SHARE = 0.1;
|
||||||
|
/** A point Kartverket has no height for (some ferry quays and bridges) is not
|
||||||
|
* asked about again for this long. */
|
||||||
|
export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
/** After a failing request (408, 429, 5xx, a network error, a request
|
||||||
|
* timeout or an unreadable body), no lookup starts for this long, or for the
|
||||||
|
* Retry-After the service sent, bounded to [1 min, 1 h]. Longer than the
|
||||||
|
* 15-minute catalog refresh (CCTV_SOURCE_CACHE_MS), so a failing service
|
||||||
|
* costs one attempt per backoff, not one per refresh. */
|
||||||
|
export const KARTVERKET_FAILURE_BACKOFF_MS = 30 * 60 * 1000;
|
||||||
|
export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000;
|
||||||
|
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
|
||||||
|
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
|
||||||
|
* camera sits above ~1,500 m. Anything outside is treated as no height. */
|
||||||
|
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
|
/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the
|
||||||
* five boroughs (~980 cameras). Frames are stills synthesized from each
|
* five boroughs (~980 cameras). Frames are stills synthesized from each
|
||||||
* camera UUID on the city's webcam host — never taken from the payload. */
|
* camera UUID on the city's webcam host — never taken from the payload. */
|
||||||
@@ -460,6 +505,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
|||||||
* pending forever — a hung fetch aborts, the loader returns [], and
|
* pending forever — a hung fetch aborts, the loader returns [], and
|
||||||
* serve-stale/other packs take over. */
|
* serve-stale/other packs take over. */
|
||||||
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
|
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
|
||||||
|
/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across
|
||||||
|
* ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian
|
||||||
|
* pack on about a third of loads. Both must arrive for any camera to load. */
|
||||||
|
export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000;
|
||||||
/** Individual CCTV image fetches must settle before the active 10-second
|
/** Individual CCTV image fetches must settle before the active 10-second
|
||||||
* client refresh cadence. A bounded miss can fall through to Street View or
|
* client refresh cadence. A bounded miss can fall through to Street View or
|
||||||
* the synthetic frame instead of leaving the browser preview pending. */
|
* the synthetic frame instead of leaving the browser preview pending. */
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
TARKTEE_IMAGES_URL,
|
TARKTEE_IMAGES_URL,
|
||||||
TARKTEE_IMAGE_ORIGIN,
|
TARKTEE_IMAGE_ORIGIN,
|
||||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||||
|
TARKTEE_MAX_CATALOG_BYTES,
|
||||||
TARKTEE_ANCHORS,
|
TARKTEE_ANCHORS,
|
||||||
DEFAULT_WARENDORF_SOURCE_FILE,
|
DEFAULT_WARENDORF_SOURCE_FILE,
|
||||||
WARENDORF_IMAGE_ORIGINS,
|
WARENDORF_IMAGE_ORIGINS,
|
||||||
@@ -76,6 +77,7 @@ import {
|
|||||||
TORONTO_GROUND_ELEVATION_M,
|
TORONTO_GROUND_ELEVATION_M,
|
||||||
TORONTO_MAX_CATALOG_BYTES,
|
TORONTO_MAX_CATALOG_BYTES,
|
||||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||||
|
TARKTEE_FETCH_TIMEOUT_MS,
|
||||||
DELDOT_CCTV_URL,
|
DELDOT_CCTV_URL,
|
||||||
DEFAULT_DELDOT_MAX_SOURCES,
|
DEFAULT_DELDOT_MAX_SOURCES,
|
||||||
DELDOT_ANCHORS,
|
DELDOT_ANCHORS,
|
||||||
@@ -95,6 +97,19 @@ import {
|
|||||||
DEFAULT_VEGVESEN_MAX_SOURCES,
|
DEFAULT_VEGVESEN_MAX_SOURCES,
|
||||||
VEGVESEN_MAX_CATALOG_BYTES,
|
VEGVESEN_MAX_CATALOG_BYTES,
|
||||||
NORWAY_ANCHORS,
|
NORWAY_ANCHORS,
|
||||||
|
KARTVERKET_HEIGHT_URL,
|
||||||
|
KARTVERKET_MAX_POINTS,
|
||||||
|
KARTVERKET_CONCURRENCY,
|
||||||
|
KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||||
|
KARTVERKET_REQUEST_TIMEOUT_MS,
|
||||||
|
KARTVERKET_WAIT_MISSING_SHARE,
|
||||||
|
KARTVERKET_CATALOG_WAIT_MS,
|
||||||
|
KARTVERKET_NULL_TTL_MS,
|
||||||
|
KARTVERKET_FAILURE_BACKOFF_MS,
|
||||||
|
KARTVERKET_MAX_BACKOFF_MS,
|
||||||
|
KARTVERKET_USER_AGENT,
|
||||||
|
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||||
|
KARTVERKET_HEIGHT_RANGE_M,
|
||||||
NYC_CAMERAS_URL,
|
NYC_CAMERAS_URL,
|
||||||
NYC_IMAGE_URL,
|
NYC_IMAGE_URL,
|
||||||
NYC_CAMERA_ID_RE,
|
NYC_CAMERA_ID_RE,
|
||||||
@@ -1232,28 +1247,73 @@ export function parseTarkteeDatexImages(xml) {
|
|||||||
*/
|
*/
|
||||||
export async function loadTarkteeSourcesFromDatex() {
|
export async function loadTarkteeSourcesFromDatex() {
|
||||||
try {
|
try {
|
||||||
const [locResp, imgResp] = await Promise.all([
|
const responses = await Promise.allSettled([
|
||||||
fetch(TARKTEE_LOCATIONS_URL, {
|
fetch(TARKTEE_LOCATIONS_URL, {
|
||||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||||
}),
|
}),
|
||||||
fetch(TARKTEE_IMAGES_URL, {
|
fetch(TARKTEE_IMAGES_URL, {
|
||||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
const [locResp, imgResp] = responses.map((result) =>
|
||||||
|
result.status === 'fulfilled' ? result.value : undefined,
|
||||||
|
);
|
||||||
|
// Both responses are fetched in parallel; if either is rejected or overflows
|
||||||
|
// its byte cap, both bodies must be released so neither transport leaks.
|
||||||
|
const discardBoth = async () => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
locResp?.body?.cancel(),
|
||||||
|
imgResp?.body?.cancel(),
|
||||||
|
]);
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
const rejected = responses.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected) {
|
||||||
|
await discardBoth();
|
||||||
|
throw rejected.reason;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(locResp.status >= 300 && locResp.status < 400) ||
|
||||||
|
(imgResp.status >= 300 && imgResp.status < 400)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Tarktee DATEX catalog redirected; redirects are not followed',
|
||||||
|
);
|
||||||
|
return discardBoth();
|
||||||
|
}
|
||||||
if (!locResp.ok) {
|
if (!locResp.ok) {
|
||||||
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
||||||
return [];
|
return discardBoth();
|
||||||
}
|
}
|
||||||
if (!imgResp.ok) {
|
if (!imgResp.ok) {
|
||||||
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
||||||
return [];
|
return discardBoth();
|
||||||
|
}
|
||||||
|
let locXml;
|
||||||
|
let imgXml;
|
||||||
|
const readAbort = new AbortController();
|
||||||
|
try {
|
||||||
|
[locXml, imgXml] = await Promise.all([
|
||||||
|
readResponseTextCapped(
|
||||||
|
locResp,
|
||||||
|
TARKTEE_MAX_CATALOG_BYTES,
|
||||||
|
readAbort.signal,
|
||||||
|
),
|
||||||
|
readResponseTextCapped(
|
||||||
|
imgResp,
|
||||||
|
TARKTEE_MAX_CATALOG_BYTES,
|
||||||
|
readAbort.signal,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
readAbort.abort();
|
||||||
|
await discardBoth();
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
const [locXml, imgXml] = await Promise.all([
|
|
||||||
locResp.text(),
|
|
||||||
imgResp.text(),
|
|
||||||
]);
|
|
||||||
const locations = parseTarkteeDatexLocations(locXml);
|
const locations = parseTarkteeDatexLocations(locXml);
|
||||||
const images = parseTarkteeDatexImages(imgXml);
|
const images = parseTarkteeDatexImages(imgXml);
|
||||||
if (!locations.size || !images.size) {
|
if (!locations.size || !images.size) {
|
||||||
@@ -1265,6 +1325,7 @@ export async function loadTarkteeSourcesFromDatex() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cameras = [];
|
const cameras = [];
|
||||||
|
const seen = new Set();
|
||||||
for (const [locationId, loc] of locations.entries()) {
|
for (const [locationId, loc] of locations.entries()) {
|
||||||
const imageUrl = images.get(locationId);
|
const imageUrl = images.get(locationId);
|
||||||
if (!imageUrl) continue;
|
if (!imageUrl) continue;
|
||||||
@@ -1276,6 +1337,8 @@ export async function loadTarkteeSourcesFromDatex() {
|
|||||||
const cameraId = numMatch
|
const cameraId = numMatch
|
||||||
? `ee-tarktee-${numMatch[1]}`
|
? `ee-tarktee-${numMatch[1]}`
|
||||||
: `ee-tarktee-${locationId}`;
|
: `ee-tarktee-${locationId}`;
|
||||||
|
if (seen.has(cameraId)) continue;
|
||||||
|
seen.add(cameraId);
|
||||||
cameras.push({
|
cameras.push({
|
||||||
id: cameraId,
|
id: cameraId,
|
||||||
name: loc.name,
|
name: loc.name,
|
||||||
@@ -2427,14 +2490,11 @@ export function vegvesenCameraToSource(feature) {
|
|||||||
fovDeg: 44,
|
fovDeg: 44,
|
||||||
rangeM: 145,
|
rangeM: 145,
|
||||||
mountHeightM: 8,
|
mountHeightM: 8,
|
||||||
// KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat
|
// The feed's coordinates are 2D. The loader replaces this flat prior with
|
||||||
// prior for the whole country, while cameras run from sea level to
|
// Kartverket's terrain height for each camera (applyVegvesenGroundHeights);
|
||||||
// mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen).
|
// it only survives when that lookup fails, and then a mountain-pass camera
|
||||||
// The point-height prior and the client's one-shot ground snap correct it
|
// can sit below the terrain on a stack without 3D tiles, the same risk the
|
||||||
// where they resolve; on a stack where neither does (no 3D tiles), a pass
|
// Caltrans pack documents.
|
||||||
// camera stays hundreds of metres below the terrain, the same risk the
|
|
||||||
// Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless
|
|
||||||
// point-height API) would remove it.
|
|
||||||
groundElevationM: 150,
|
groundElevationM: 150,
|
||||||
feedType: videoUrl ? 'hls' : 'image',
|
feedType: videoUrl ? 'hls' : 'image',
|
||||||
url: videoUrl || imageUrl,
|
url: videoUrl || imageUrl,
|
||||||
@@ -2454,6 +2514,7 @@ export function vegvesenCameraToSource(feature) {
|
|||||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||||
*/
|
*/
|
||||||
export async function loadVegvesenSourcesFromOpenData() {
|
export async function loadVegvesenSourcesFromOpenData() {
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
|
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
|
||||||
const resp = await fetch(endpoint, {
|
const resp = await fetch(endpoint, {
|
||||||
@@ -2499,6 +2560,12 @@ export async function loadVegvesenSourcesFromOpenData() {
|
|||||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||||
: DEFAULT_VEGVESEN_MAX_SOURCES;
|
: DEFAULT_VEGVESEN_MAX_SOURCES;
|
||||||
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
|
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
|
||||||
|
await applyVegvesenGroundHeights(prioritized, {
|
||||||
|
waitMs: Math.max(
|
||||||
|
0,
|
||||||
|
KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt),
|
||||||
|
),
|
||||||
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||||
);
|
);
|
||||||
@@ -2512,6 +2579,322 @@ export async function loadVegvesenSourcesFromOpenData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kartverket answers by rounded position: `{z, expires}`, where z is metres or
|
||||||
|
* null for "no height there". Heights never expire (camera positions barely
|
||||||
|
* change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog
|
||||||
|
* refresh therefore only asks about new or moved cameras. Bounded well above
|
||||||
|
* the ~900 cameras the pack can load.
|
||||||
|
*/
|
||||||
|
const kartverketHeightCache = new Map();
|
||||||
|
const KARTVERKET_CACHE_LIMIT = 5000;
|
||||||
|
/** The lookup in flight, so overlapping catalog loads never start a second. */
|
||||||
|
let kartverketPending = null;
|
||||||
|
/** No lookup starts before this time (epoch ms) after a transport failure. */
|
||||||
|
let kartverketBackoffUntil = 0;
|
||||||
|
/** Bumped by clearKartverketHeightCache, so a lookup started before a clear
|
||||||
|
* can no longer write to the cache, the backoff or the pending slot. */
|
||||||
|
let kartverketGeneration = 0;
|
||||||
|
|
||||||
|
/** Cache key for one position, at the ~0.1 m precision the request sends. */
|
||||||
|
const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`;
|
||||||
|
|
||||||
|
/** Statuses that say the service, not the request, is failing. */
|
||||||
|
const isKartverketTransportStatus = (status) =>
|
||||||
|
status === 408 || status === 429 || status >= 500;
|
||||||
|
|
||||||
|
/** Forget cached heights, the backoff and any lookup in flight. Tests only. */
|
||||||
|
export function clearKartverketHeightCache() {
|
||||||
|
kartverketGeneration += 1;
|
||||||
|
kartverketHeightCache.clear();
|
||||||
|
kartverketPending = null;
|
||||||
|
kartverketBackoffUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to back off after a failed request: the Retry-After the service
|
||||||
|
* sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h].
|
||||||
|
*
|
||||||
|
* @param {?string} retryAfter - The Retry-After header, if any.
|
||||||
|
* @param {number} now - Epoch ms.
|
||||||
|
* @returns {number} Milliseconds.
|
||||||
|
*/
|
||||||
|
export function kartverketBackoffMs(retryAfter, now = Date.now()) {
|
||||||
|
let ms = KARTVERKET_FAILURE_BACKOFF_MS;
|
||||||
|
const text = String(retryAfter ?? '').trim();
|
||||||
|
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
|
||||||
|
else if (text && Number.isFinite(Date.parse(text))) {
|
||||||
|
ms = Date.parse(text) - now;
|
||||||
|
}
|
||||||
|
return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terrain heights above sea level (NN2000) from Kartverket's keyless
|
||||||
|
* point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is
|
||||||
|
* matched back by the coordinates the API echoes, and only for points that
|
||||||
|
* request asked about, then reported through `into.set(key, z)` as its
|
||||||
|
* request lands, so a caller that stops waiting keeps what has arrived. `z` is
|
||||||
|
* metres, or null when the API has no usable height there. A point a 200
|
||||||
|
* answer leaves out is reported as null too, so it is not asked again on
|
||||||
|
* every refresh.
|
||||||
|
*
|
||||||
|
* Over water `/punkt` returns the depth of the sea floor (`datakilde:
|
||||||
|
* dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either
|
||||||
|
* marker counts as 0 m, the water surface. For the ~14 bridge cameras that is
|
||||||
|
* below the deck, but far closer than the sea floor or a flat prior.
|
||||||
|
*
|
||||||
|
* The first transport failure (a 408, 429 or 5xx, a network error, a request
|
||||||
|
* past KARTVERKET_REQUEST_TIMEOUT_MS, an unreadable or oversized body, or the
|
||||||
|
* shared deadline) stops the lookup: no further requests start, and the
|
||||||
|
* failure is returned so the caller can back off. Points in failed requests
|
||||||
|
* are not reported, so they are asked again later. Any other 4xx rejects that
|
||||||
|
* request's points, not the service: they are reported as null and the lookup
|
||||||
|
* carries on.
|
||||||
|
*
|
||||||
|
* @param {Array<{lon:number, lat:number}>} points
|
||||||
|
* @param {{into?: {set: Function}, timeoutMs?: number, requestTimeoutMs?: number}} [options]
|
||||||
|
* @returns {Promise<{heights: Map<string, ?number>, failure: ?{status: ?number, retryAfter: ?string, deadline: boolean}}>}
|
||||||
|
* `heights` is `into` when it is a Map, else a Map of what was reported.
|
||||||
|
* `failure.deadline` is true when only the shared deadline ran out.
|
||||||
|
*/
|
||||||
|
export async function fetchKartverketHeights(
|
||||||
|
points,
|
||||||
|
{
|
||||||
|
into = new Map(),
|
||||||
|
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||||
|
requestTimeoutMs = KARTVERKET_REQUEST_TIMEOUT_MS,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const heights = into instanceof Map ? into : new Map();
|
||||||
|
const report = (key, z) => {
|
||||||
|
if (heights !== into) heights.set(key, z);
|
||||||
|
into.set(key, z);
|
||||||
|
};
|
||||||
|
const unique = Array.from(
|
||||||
|
new Map(
|
||||||
|
(points || [])
|
||||||
|
.filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon))
|
||||||
|
.map((p) => [kartverketKey(p.lon, p.lat), p]),
|
||||||
|
).values(),
|
||||||
|
);
|
||||||
|
let failure = null;
|
||||||
|
if (!unique.length) return { heights, failure };
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) {
|
||||||
|
batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS));
|
||||||
|
}
|
||||||
|
const deadline = AbortSignal.timeout(timeoutMs);
|
||||||
|
const fetchBatch = async (batch) => {
|
||||||
|
const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat)));
|
||||||
|
const url = new URL(KARTVERKET_HEIGHT_URL);
|
||||||
|
url.searchParams.set(
|
||||||
|
'punkter',
|
||||||
|
JSON.stringify(
|
||||||
|
batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
url.searchParams.set('koordsys', '4258');
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': KARTVERKET_USER_AGENT,
|
||||||
|
},
|
||||||
|
redirect: 'error',
|
||||||
|
signal: AbortSignal.any([
|
||||||
|
deadline,
|
||||||
|
AbortSignal.timeout(requestTimeoutMs),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
try {
|
||||||
|
await resp.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
if (!isKartverketTransportStatus(resp.status)) {
|
||||||
|
console.warn(
|
||||||
|
`[CCTV] Kartverket rejected ${asked.size} points: HTTP ${resp.status}`,
|
||||||
|
);
|
||||||
|
for (const key of asked) report(key, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const error = new Error(`HTTP ${resp.status}`);
|
||||||
|
error.status = resp.status;
|
||||||
|
error.retryAfter = resp.headers.get('retry-after');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const payload = await readResponseJsonCapped(
|
||||||
|
resp,
|
||||||
|
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||||
|
);
|
||||||
|
for (const point of Array.isArray(payload?.punkter)
|
||||||
|
? payload.punkter
|
||||||
|
: []) {
|
||||||
|
const x = toFiniteNumber(point?.x);
|
||||||
|
const y = toFiniteNumber(point?.y);
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||||
|
const key = kartverketKey(x, y);
|
||||||
|
// Only the points this request asked about: an odd answer can neither
|
||||||
|
// grow the cache nor overwrite another camera.
|
||||||
|
if (!asked.has(key)) continue;
|
||||||
|
asked.delete(key);
|
||||||
|
// `z: null` means no height there; Number(null) would read 0.
|
||||||
|
const raw = point?.z;
|
||||||
|
let z =
|
||||||
|
raw === null || raw === undefined || raw === ''
|
||||||
|
? NaN
|
||||||
|
: toFiniteNumber(raw);
|
||||||
|
if (
|
||||||
|
Number.isFinite(z) &&
|
||||||
|
(point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
|
||||||
|
) {
|
||||||
|
z = 0;
|
||||||
|
}
|
||||||
|
const usable =
|
||||||
|
Number.isFinite(z) &&
|
||||||
|
z >= KARTVERKET_HEIGHT_RANGE_M.min &&
|
||||||
|
z <= KARTVERKET_HEIGHT_RANGE_M.max;
|
||||||
|
report(key, usable ? z : null);
|
||||||
|
}
|
||||||
|
// A 200 that leaves a point out has no height for it either.
|
||||||
|
for (const key of asked) report(key, null);
|
||||||
|
};
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (next < batches.length && !failure) {
|
||||||
|
const batch = batches[next++];
|
||||||
|
try {
|
||||||
|
await fetchBatch(batch);
|
||||||
|
} catch (error) {
|
||||||
|
if (!failure) {
|
||||||
|
failure = {
|
||||||
|
status: Number.isFinite(error?.status) ? error.status : null,
|
||||||
|
retryAfter: error?.retryAfter ?? null,
|
||||||
|
deadline: deadline.aborted,
|
||||||
|
};
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Kartverket height lookup failed:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(
|
||||||
|
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
|
||||||
|
worker,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { heights, failure };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give each Vegvesen camera its own terrain height as the ground prior, in
|
||||||
|
* place. Waits at most `waitMs` for positions it has no answer for, and only
|
||||||
|
* when more than KARTVERKET_WAIT_MISSING_SHARE of the cameras are missing
|
||||||
|
* (a cold cache); a few new cameras or expired misses are looked up in the
|
||||||
|
* background. A lookup still running when the wait ends keeps filling the
|
||||||
|
* cache for the next catalog refresh. Cameras without a height keep the flat
|
||||||
|
* prior. After a transport failure no lookup starts until the backoff ends,
|
||||||
|
* which outlasts the 15-minute catalog refresh, so a failing Kartverket costs
|
||||||
|
* one attempt per backoff, not one per refresh. A lookup cut short only by its
|
||||||
|
* shared deadline is resumed on the next refresh without a backoff. Cameras
|
||||||
|
* without finite coordinates are skipped. Never throws.
|
||||||
|
* CCTV_VEGVESEN_HEIGHTS=0 turns it off.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} cameras - Sources from vegvesenCameraToSource().
|
||||||
|
* @param {{waitMs?: number, now?: () => number, timeoutMs?: number}} [options]
|
||||||
|
* `timeoutMs` is the lookup's shared deadline.
|
||||||
|
* @returns {Promise<number>} How many cameras got a terrain height.
|
||||||
|
*/
|
||||||
|
export async function applyVegvesenGroundHeights(
|
||||||
|
cameras,
|
||||||
|
{
|
||||||
|
waitMs = KARTVERKET_CATALOG_WAIT_MS,
|
||||||
|
now = Date.now,
|
||||||
|
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0;
|
||||||
|
const list = (Array.isArray(cameras) ? cameras : []).filter(
|
||||||
|
(c) => Number.isFinite(c?.lon) && Number.isFinite(c?.lat),
|
||||||
|
);
|
||||||
|
const startedAt = now();
|
||||||
|
const missing = list.filter((c) => {
|
||||||
|
const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat));
|
||||||
|
return !entry || entry.expires <= startedAt;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
missing.length &&
|
||||||
|
!kartverketPending &&
|
||||||
|
startedAt >= kartverketBackoffUntil
|
||||||
|
) {
|
||||||
|
if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) {
|
||||||
|
kartverketHeightCache.clear();
|
||||||
|
}
|
||||||
|
const generation = kartverketGeneration;
|
||||||
|
const current = () => generation === kartverketGeneration;
|
||||||
|
const sink = {
|
||||||
|
set: (key, z) => {
|
||||||
|
if (!current()) return;
|
||||||
|
kartverketHeightCache.set(key, {
|
||||||
|
z,
|
||||||
|
expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const pending = fetchKartverketHeights(missing, { into: sink, timeoutMs })
|
||||||
|
.then(({ failure }) => {
|
||||||
|
if (failure && !failure.deadline && current()) {
|
||||||
|
kartverketBackoffUntil =
|
||||||
|
now() + kartverketBackoffMs(failure.retryAfter, now());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (current()) {
|
||||||
|
kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Kartverket height lookup error:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (kartverketPending === pending) kartverketPending = null;
|
||||||
|
});
|
||||||
|
kartverketPending = pending;
|
||||||
|
}
|
||||||
|
const cold = missing.length > list.length * KARTVERKET_WAIT_MISSING_SHARE;
|
||||||
|
if (cold && kartverketPending && waitMs > 0) {
|
||||||
|
let timer;
|
||||||
|
await Promise.race([
|
||||||
|
kartverketPending,
|
||||||
|
new Promise((resolve) => {
|
||||||
|
timer = setTimeout(resolve, waitMs);
|
||||||
|
timer.unref?.();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
let applied = 0;
|
||||||
|
for (const camera of list) {
|
||||||
|
const z = kartverketHeightCache.get(
|
||||||
|
kartverketKey(camera.lon, camera.lat),
|
||||||
|
)?.z;
|
||||||
|
if (!Number.isFinite(z)) continue;
|
||||||
|
camera.groundElevationM = Math.round(z * 10) / 10;
|
||||||
|
applied += 1;
|
||||||
|
}
|
||||||
|
if (list.length) {
|
||||||
|
console.log(
|
||||||
|
`[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
const NYC_BOROUGHS = new Set([
|
const NYC_BOROUGHS = new Set([
|
||||||
'Manhattan',
|
'Manhattan',
|
||||||
'Brooklyn',
|
'Brooklyn',
|
||||||
|
|||||||
@@ -14,16 +14,34 @@ async function readRequestBodyCapped(req, maxBytes) {
|
|||||||
return Buffer.concat(chunks);
|
return Buffer.concat(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRequestBody(req, maxBytes = 1024 * 1024) {
|
/** Optional cancellation discards buffered data while the request drains. */
|
||||||
|
function readRequestBody(req, maxBytes = 1024 * 1024, signal) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let bodyTooLarge = false;
|
let bodyTooLarge = false;
|
||||||
|
let settled = false;
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
req.on('data', (chunk) => {
|
const stopReading = () => {
|
||||||
|
if (!signal) return;
|
||||||
|
req.off('data', data);
|
||||||
|
req.off('end', end);
|
||||||
|
signal.removeEventListener('abort', abort);
|
||||||
|
};
|
||||||
|
const fail = (error) => {
|
||||||
|
if (signal) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
chunks.length = 0;
|
||||||
|
signal.removeEventListener('abort', abort);
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const abort = () => fail(signal.reason);
|
||||||
|
const data = (chunk) => {
|
||||||
// Keep draining after the cap without retaining more bytes. Destroying
|
// Keep draining after the cap without retaining more bytes. Destroying
|
||||||
// the request also destroys its socket, so the handler cannot deliver
|
// the request also destroys its socket, so the handler cannot deliver
|
||||||
// the fixed error response it constructs for BODY_TOO_LARGE.
|
// the fixed error response it constructs for BODY_TOO_LARGE.
|
||||||
if (bodyTooLarge) return;
|
if (bodyTooLarge || settled) return;
|
||||||
total += chunk.length;
|
total += chunk.length;
|
||||||
if (total > maxBytes) {
|
if (total > maxBytes) {
|
||||||
// Same marker readRequestBodyCapped sets, so a caller can tell an
|
// Same marker readRequestBodyCapped sets, so a caller can tell an
|
||||||
@@ -32,15 +50,42 @@ function readRequestBody(req, maxBytes = 1024 * 1024) {
|
|||||||
chunks.length = 0;
|
chunks.length = 0;
|
||||||
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
||||||
error.code = 'BODY_TOO_LARGE';
|
error.code = 'BODY_TOO_LARGE';
|
||||||
reject(error);
|
fail(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
});
|
};
|
||||||
req.on('end', () => {
|
const end = () => {
|
||||||
|
if (settled) {
|
||||||
|
stopReading();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
||||||
});
|
if (signal) {
|
||||||
req.on('error', reject);
|
settled = true;
|
||||||
|
chunks.length = 0;
|
||||||
|
stopReading();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
fail(
|
||||||
|
new DOMException('Request closed before its body ended', 'AbortError'),
|
||||||
|
);
|
||||||
|
stopReading();
|
||||||
|
req.off('error', fail);
|
||||||
|
req.off('close', close);
|
||||||
|
};
|
||||||
|
if (signal) {
|
||||||
|
// Abort/overflow release retained bytes but keep draining. Retain an
|
||||||
|
// error handler until close, including a late error after end/abort.
|
||||||
|
req.on('close', close);
|
||||||
|
signal.addEventListener('abort', abort, { once: true });
|
||||||
|
if (signal.aborted) abort();
|
||||||
|
}
|
||||||
|
req.on('data', data);
|
||||||
|
req.on('end', end);
|
||||||
|
req.on('error', fail);
|
||||||
|
if (signal && req.destroyed) close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter, getEventListeners } from 'node:events';
|
||||||
|
import { readRequestBody } from './request.js';
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const req = new EventEmitter();
|
||||||
|
req.pause = () => assert.fail('The reader must keep draining');
|
||||||
|
req.destroy = () =>
|
||||||
|
assert.fail('The reader must preserve the response socket');
|
||||||
|
const owner = new AbortController();
|
||||||
|
return { req, owner };
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(req, owner) {
|
||||||
|
assert.doesNotThrow(() => req.emit('error', new Error('late stream error')));
|
||||||
|
req.emit('end');
|
||||||
|
assert.doesNotThrow(() => req.emit('error', new Error('error before close')));
|
||||||
|
req.emit('close');
|
||||||
|
for (const event of ['data', 'end', 'error', 'close'])
|
||||||
|
assert.equal(req.listenerCount(event), 0, event);
|
||||||
|
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('default and cancellable readers preserve byte caps and UTF-8 across chunks', async () => {
|
||||||
|
for (const useSignal of [false, true]) {
|
||||||
|
for (const text of ['', 'hello', 'é']) {
|
||||||
|
const { req, owner } = fixture();
|
||||||
|
const bytes = Buffer.from(text);
|
||||||
|
const body = readRequestBody(
|
||||||
|
req,
|
||||||
|
bytes.length,
|
||||||
|
useSignal ? owner.signal : undefined,
|
||||||
|
);
|
||||||
|
for (const byte of bytes) req.emit('data', Buffer.from([byte]));
|
||||||
|
req.emit('end');
|
||||||
|
assert.equal(await body, text);
|
||||||
|
if (useSignal) finish(req, owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default overflow rejects before end and drains without pausing or destroying', async () => {
|
||||||
|
const { req } = fixture();
|
||||||
|
const rejection = assert.rejects(readRequestBody(req, 2), {
|
||||||
|
code: 'BODY_TOO_LARGE',
|
||||||
|
});
|
||||||
|
req.emit('data', Buffer.from('é'));
|
||||||
|
req.emit('data', Buffer.from('!'));
|
||||||
|
await rejection;
|
||||||
|
req.emit('data', Buffer.alloc(4096));
|
||||||
|
assert.doesNotThrow(() => req.emit('error', new Error('late')));
|
||||||
|
req.emit('end');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial abort settles before end, discards late data and guards errors until close', async () => {
|
||||||
|
const { req, owner } = fixture();
|
||||||
|
const reason = new Error('request deadline');
|
||||||
|
const rejection = assert.rejects(
|
||||||
|
readRequestBody(req, 1024, owner.signal),
|
||||||
|
(error) => error === reason,
|
||||||
|
);
|
||||||
|
req.emit('data', Buffer.from('partial'));
|
||||||
|
owner.abort(reason);
|
||||||
|
await rejection;
|
||||||
|
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||||
|
req.emit('data', Buffer.alloc(4096));
|
||||||
|
finish(req, owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('already-aborted and already-closed requests settle and release their listeners', async () => {
|
||||||
|
for (const closed of [false, true]) {
|
||||||
|
const { req, owner } = fixture();
|
||||||
|
if (closed) req.destroyed = true;
|
||||||
|
else owner.abort();
|
||||||
|
await assert.rejects(readRequestBody(req, 1024, owner.signal), {
|
||||||
|
name: 'AbortError',
|
||||||
|
});
|
||||||
|
if (!closed) finish(req, owner);
|
||||||
|
for (const event of ['data', 'end', 'error', 'close'])
|
||||||
|
assert.equal(req.listenerCount(event), 0);
|
||||||
|
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overflow keeps its first failure through later abort and stream errors', async () => {
|
||||||
|
const { req, owner } = fixture();
|
||||||
|
const rejection = assert.rejects(readRequestBody(req, 2, owner.signal), {
|
||||||
|
code: 'BODY_TOO_LARGE',
|
||||||
|
});
|
||||||
|
req.emit('data', Buffer.from('é!'));
|
||||||
|
await rejection;
|
||||||
|
owner.abort(new Error('later deadline'));
|
||||||
|
req.emit('data', Buffer.alloc(4096));
|
||||||
|
finish(req, owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('early close and request error reject once; successful end wins over later abort', async () => {
|
||||||
|
for (const event of ['close', 'error', 'end']) {
|
||||||
|
const { req, owner } = fixture();
|
||||||
|
const reason = new Error('request failed');
|
||||||
|
const body = readRequestBody(req, 32, owner.signal);
|
||||||
|
const observed =
|
||||||
|
event === 'end'
|
||||||
|
? body.then((value) => assert.equal(value, 'ok'))
|
||||||
|
: assert.rejects(
|
||||||
|
body,
|
||||||
|
event === 'close'
|
||||||
|
? { name: 'AbortError' }
|
||||||
|
: (error) => error === reason,
|
||||||
|
);
|
||||||
|
req.emit('data', Buffer.from('ok'));
|
||||||
|
req.emit(event, reason);
|
||||||
|
owner.abort(new Error('later abort'));
|
||||||
|
await observed;
|
||||||
|
if (event !== 'close') finish(req, owner);
|
||||||
|
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a prior data observer can abort synchronously without retaining or resolving that chunk', async () => {
|
||||||
|
const { req, owner } = fixture();
|
||||||
|
const reason = new Error('observer stopped request');
|
||||||
|
const external = () => owner.abort(reason);
|
||||||
|
req.on('data', external);
|
||||||
|
const rejection = assert.rejects(
|
||||||
|
readRequestBody(req, 1024, owner.signal),
|
||||||
|
(error) => error === reason,
|
||||||
|
);
|
||||||
|
req.emit('data', Buffer.from('must be discarded'));
|
||||||
|
await rejection;
|
||||||
|
req.emit('end');
|
||||||
|
req.emit('close');
|
||||||
|
assert.deepEqual(req.listeners('data'), [external]);
|
||||||
|
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||||
|
req.off('data', external);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createGeminiTokenHandler } from './gemini/realtime.js';
|
||||||
|
|
||||||
|
/** Mint constrained Live API credentials; the permanent key stays on Node. */
|
||||||
|
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
|
||||||
|
function install(middlewares) {
|
||||||
|
middlewares.use(
|
||||||
|
'/api/gemini/token',
|
||||||
|
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: 'gemini-live-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
install(server.middlewares);
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
install(server.middlewares);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { geminiLiveProxy };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { realtimeInstructions } from '../openai/instructions.js';
|
||||||
|
import { GEV_REALTIME_TOOLS } from '../openai/tools.js';
|
||||||
|
|
||||||
|
export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live';
|
||||||
|
export const GEMINI_TOKEN_ENDPOINT =
|
||||||
|
'https://generativelanguage.googleapis.com/v1beta/auth_tokens';
|
||||||
|
export const GEMINI_LIVE_WEBSOCKET_URL =
|
||||||
|
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained';
|
||||||
|
|
||||||
|
export function normalizeGeminiModel(value) {
|
||||||
|
const model = String(value ?? GEMINI_LIVE_MODEL_DEFAULT)
|
||||||
|
.trim()
|
||||||
|
.replace(/^models\//, '');
|
||||||
|
return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REST BidiGenerateContentSetup fields, excluding its model resource name. */
|
||||||
|
export function createGeminiLiveConfig(
|
||||||
|
annotationGuidance,
|
||||||
|
inputMode = 'open-mic',
|
||||||
|
tools = GEV_REALTIME_TOOLS,
|
||||||
|
) {
|
||||||
|
if (!['open-mic', 'push-to-talk'].includes(inputMode))
|
||||||
|
throw new Error('Invalid Gemini input mode');
|
||||||
|
const config = {
|
||||||
|
generationConfig: { responseModalities: ['AUDIO'] },
|
||||||
|
systemInstruction: {
|
||||||
|
parts: [{ text: realtimeInstructions(annotationGuidance) }],
|
||||||
|
},
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
functionDeclarations: tools.map(
|
||||||
|
({ name, description, parameters }) => ({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
// GEV's canonical schemas are JSON Schema, not Google's Schema.
|
||||||
|
parametersJsonSchema: structuredClone(parameters),
|
||||||
|
// App actions must finish before the model confirms their result.
|
||||||
|
behavior: 'BLOCKING',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inputAudioTranscription: {},
|
||||||
|
outputAudioTranscription: {},
|
||||||
|
contextWindowCompression: { slidingWindow: {} },
|
||||||
|
};
|
||||||
|
if (inputMode === 'push-to-talk')
|
||||||
|
config.realtimeInputConfig = {
|
||||||
|
automaticActivityDetection: { disabled: true },
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
|
||||||
|
import { clientKey, makeCostRateLimiter } from '../common/rate-limit.js';
|
||||||
|
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
|
||||||
|
import {
|
||||||
|
GEMINI_TOKEN_ENDPOINT,
|
||||||
|
GEMINI_LIVE_WEBSOCKET_URL,
|
||||||
|
GEMINI_LIVE_MODEL_DEFAULT,
|
||||||
|
createGeminiLiveConfig,
|
||||||
|
normalizeGeminiModel,
|
||||||
|
} from './config.js';
|
||||||
|
import {
|
||||||
|
isGeminiRequestOriginAllowed,
|
||||||
|
readGeminiTokenRequest,
|
||||||
|
} from './request.js';
|
||||||
|
|
||||||
|
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
|
||||||
|
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
|
||||||
|
|
||||||
|
export function createGeminiTokenHandler({
|
||||||
|
annotationGuidance,
|
||||||
|
tools,
|
||||||
|
fetchImpl = (...args) => fetch(...args),
|
||||||
|
resolveApiKey = () => process.env.GEMINI_API_KEY,
|
||||||
|
resolveModel = () =>
|
||||||
|
process.env.GEMINI_LIVE_MODEL || GEMINI_LIVE_MODEL_DEFAULT,
|
||||||
|
resolveHost = () => process.env.HOST,
|
||||||
|
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
|
||||||
|
resolveRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN ?? '30',
|
||||||
|
now = Date.now,
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
} = {}) {
|
||||||
|
let limiter;
|
||||||
|
return async (req, res) => {
|
||||||
|
if (req.aborted || res.destroyed || res.writableEnded) return;
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
const reply = (status, errorOrPayload) => {
|
||||||
|
if (res.destroyed || res.writableEnded) return;
|
||||||
|
res.statusCode = status;
|
||||||
|
if (status >= 400) res.setHeader('Connection', 'close');
|
||||||
|
res.end(
|
||||||
|
JSON.stringify(
|
||||||
|
typeof errorOrPayload === 'string'
|
||||||
|
? { error: errorOrPayload }
|
||||||
|
: errorOrPayload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.setHeader('Allow', 'POST');
|
||||||
|
return reply(405, 'Method not allowed');
|
||||||
|
}
|
||||||
|
let apiKey;
|
||||||
|
let model;
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
|
||||||
|
)
|
||||||
|
return reply(403, 'Gemini voice requires a same-origin app request');
|
||||||
|
apiKey = String(resolveApiKey() || '').trim();
|
||||||
|
if (!apiKey)
|
||||||
|
return reply(503, 'Add a Gemini API key in Provider Settings');
|
||||||
|
model = normalizeGeminiModel(resolveModel());
|
||||||
|
if (!model)
|
||||||
|
return reply(503, 'The Gemini Live model configuration is invalid');
|
||||||
|
if (limiter === undefined)
|
||||||
|
limiter = makeCostRateLimiter(resolveRateLimit(), 30);
|
||||||
|
if (limiter && !limiter(clientKey(req))) {
|
||||||
|
res.setHeader('Retry-After', '60');
|
||||||
|
return reply(
|
||||||
|
429,
|
||||||
|
'Gemini voice is starting too often. Try again shortly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connect does not observe the handler's returned promise. A synchronous
|
||||||
|
// configuration resolver must fail with a bounded, fixed response.
|
||||||
|
return reply(503, MINT_FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
let disconnected = false;
|
||||||
|
const disconnect = () => {
|
||||||
|
disconnected = true;
|
||||||
|
lifetime.abort(new DOMException('Request closed', 'AbortError'));
|
||||||
|
};
|
||||||
|
const responseClosed = () => {
|
||||||
|
if (!res.writableEnded) disconnect();
|
||||||
|
};
|
||||||
|
req.once('aborted', disconnect);
|
||||||
|
res.once('close', responseClosed);
|
||||||
|
const deadline = setTimeout(() => {
|
||||||
|
lifetime.abort(
|
||||||
|
new DOMException('Token request timed out', 'TimeoutError'),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
deadline.unref?.();
|
||||||
|
let acquiredResponse;
|
||||||
|
try {
|
||||||
|
const inputMode = await readGeminiTokenRequest(req, lifetime.signal);
|
||||||
|
lifetime.signal.throwIfAborted();
|
||||||
|
const config = createGeminiLiveConfig(
|
||||||
|
annotationGuidance,
|
||||||
|
inputMode,
|
||||||
|
tools,
|
||||||
|
);
|
||||||
|
const issuedAt = now();
|
||||||
|
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
|
||||||
|
// input shape, converted before transport. With no fieldMask the complete
|
||||||
|
// setup is locked to this server-owned configuration.
|
||||||
|
const request = {
|
||||||
|
uses: 1,
|
||||||
|
expireTime: new Date(issuedAt + 30 * 60_000).toISOString(),
|
||||||
|
newSessionExpireTime: new Date(issuedAt + 60_000).toISOString(),
|
||||||
|
bidiGenerateContentSetup: { model: `models/${model}`, ...config },
|
||||||
|
};
|
||||||
|
const acquisition = Promise.resolve(
|
||||||
|
fetchImpl(GEMINI_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
redirect: 'error',
|
||||||
|
signal: lifetime.signal,
|
||||||
|
headers: {
|
||||||
|
'x-goog-api-key': apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// A fetch implementation can ignore abort. Bound this caller's wait and
|
||||||
|
// discard a response that arrives after timeout/disconnection.
|
||||||
|
void acquisition.then(
|
||||||
|
(late) => {
|
||||||
|
acquiredResponse = late;
|
||||||
|
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
const response = await waitForSignal(acquisition, lifetime.signal);
|
||||||
|
lifetime.signal.throwIfAborted();
|
||||||
|
if (
|
||||||
|
response.redirected ||
|
||||||
|
(response.url && response.url !== GEMINI_TOKEN_ENDPOINT)
|
||||||
|
) {
|
||||||
|
void response.body?.cancel().catch(() => {});
|
||||||
|
throw new Error('Unexpected token response destination');
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
// Never forward upstream bodies, headers or exception messages.
|
||||||
|
void response.body?.cancel().catch(() => {});
|
||||||
|
if (response.status === 429) {
|
||||||
|
res.setHeader('Retry-After', '60');
|
||||||
|
return reply(429, 'Gemini quota is unavailable. Try again later.');
|
||||||
|
}
|
||||||
|
if (response.status === 401 || response.status === 403)
|
||||||
|
return reply(
|
||||||
|
503,
|
||||||
|
'Gemini access was refused. Check the API key and model access.',
|
||||||
|
);
|
||||||
|
if (response.status === 400 || response.status === 404)
|
||||||
|
return reply(
|
||||||
|
502,
|
||||||
|
'Gemini Live is unavailable for the configured model.',
|
||||||
|
);
|
||||||
|
return reply(502, MINT_FAILURE);
|
||||||
|
}
|
||||||
|
const data = await readResponseJsonCapped(
|
||||||
|
response,
|
||||||
|
UPSTREAM_RESPONSE_BYTES,
|
||||||
|
lifetime.signal,
|
||||||
|
);
|
||||||
|
lifetime.signal.throwIfAborted();
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
Array.isArray(data) ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
typeof data.name !== 'string' ||
|
||||||
|
data.name.length > 8192 ||
|
||||||
|
!/^auth_tokens\/[A-Za-z0-9._~+/=-]+$/.test(data.name) ||
|
||||||
|
data.name.includes(apiKey)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid ephemeral token');
|
||||||
|
reply(200, {
|
||||||
|
token: data.name,
|
||||||
|
model,
|
||||||
|
config,
|
||||||
|
websocketUrl: GEMINI_LIVE_WEBSOCKET_URL,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (disconnected || req.aborted) return;
|
||||||
|
if (error?.code === 'BODY_TOO_LARGE')
|
||||||
|
return reply(413, 'Token request is too large');
|
||||||
|
if (error?.code === 'BAD_CONTENT_TYPE')
|
||||||
|
return reply(415, 'Use an empty JSON token request');
|
||||||
|
if (error?.code === 'BAD_BODY')
|
||||||
|
return reply(400, 'Use an empty JSON token request');
|
||||||
|
reply(lifetime.signal.aborted ? 504 : 502, MINT_FAILURE);
|
||||||
|
} finally {
|
||||||
|
// Abort can happen after acquisition resolves but before this handler
|
||||||
|
// resumes and passes ownership to the body reader.
|
||||||
|
if (lifetime.signal.aborted)
|
||||||
|
void acquiredResponse?.body?.cancel().catch(() => {});
|
||||||
|
clearTimeout(deadline);
|
||||||
|
req.off('aborted', disconnect);
|
||||||
|
res.off('close', responseClosed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { readRequestBody } from '../common/request.js';
|
||||||
|
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
|
||||||
|
import {
|
||||||
|
isAllowedHost,
|
||||||
|
resolveAllowedHosts,
|
||||||
|
} from '../../../build/allowedHosts.js';
|
||||||
|
|
||||||
|
/** Use the app's Host and request policies, with a required browser Origin. */
|
||||||
|
export function isGeminiRequestOriginAllowed(
|
||||||
|
req,
|
||||||
|
configuredHost = '',
|
||||||
|
configuredAllowedHosts,
|
||||||
|
) {
|
||||||
|
const host = req.headers?.host;
|
||||||
|
const origin = req.headers?.origin;
|
||||||
|
if (
|
||||||
|
typeof host !== 'string' ||
|
||||||
|
typeof origin !== 'string' ||
|
||||||
|
!host ||
|
||||||
|
!origin ||
|
||||||
|
/[\s/?#@\\]/.test(host)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const protocol = req.socket?.encrypted ? 'https:' : 'http:';
|
||||||
|
if (
|
||||||
|
!admitSameSiteRequest({
|
||||||
|
hostHeader: host,
|
||||||
|
protocol,
|
||||||
|
origin,
|
||||||
|
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||||
|
proxyHeaders: req.headers || {},
|
||||||
|
}).ok
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
// The shared gate compares a present Origin exactly with this authority;
|
||||||
|
// this route also requires the Origin and an allowed Host.
|
||||||
|
const boundHost = String(configuredHost || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return isAllowedHost(host, resolveAllowedHosts(configuredAllowedHosts), [
|
||||||
|
boundHost,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admit only the existing input gesture; callers cannot supply model/setup. */
|
||||||
|
export async function readGeminiTokenRequest(req, signal, maxBytes = 1024) {
|
||||||
|
const declared = Number(req.headers?.['content-length']);
|
||||||
|
const fail = (code) =>
|
||||||
|
Object.assign(new Error('Invalid token request'), { code });
|
||||||
|
const body = await readRequestBody(
|
||||||
|
req,
|
||||||
|
maxBytes,
|
||||||
|
Number.isFinite(declared) && declared > maxBytes
|
||||||
|
? AbortSignal.abort(fail('BODY_TOO_LARGE'))
|
||||||
|
: signal,
|
||||||
|
);
|
||||||
|
if (!body.trim()) return 'open-mic';
|
||||||
|
if (
|
||||||
|
!/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type'] || '')
|
||||||
|
)
|
||||||
|
throw fail('BAD_CONTENT_TYPE');
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
throw fail('BAD_BODY');
|
||||||
|
}
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
||||||
|
throw fail('BAD_BODY');
|
||||||
|
const keys = Object.keys(parsed);
|
||||||
|
if (!keys.length) return 'open-mic';
|
||||||
|
if (
|
||||||
|
keys.length === 1 &&
|
||||||
|
keys[0] === 'inputMode' &&
|
||||||
|
parsed.inputMode === 'push-to-talk'
|
||||||
|
)
|
||||||
|
return 'push-to-talk';
|
||||||
|
throw fail('BAD_BODY');
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
|||||||
import { aisLiveProxy } from './vessels/ais-live.js';
|
import { aisLiveProxy } from './vessels/ais-live.js';
|
||||||
import { trackBackfillProxies } from './aircraft/tracks.js';
|
import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||||
import { openAiRealtimeProxy } from './openai.js';
|
import { openAiRealtimeProxy } from './openai.js';
|
||||||
|
import { geminiLiveProxy } from './gemini.js';
|
||||||
import { googlePlacesContextProxy } from './places.js';
|
import { googlePlacesContextProxy } from './places.js';
|
||||||
import { mapillaryProxy } from './mapillary.js';
|
import { mapillaryProxy } from './mapillary.js';
|
||||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||||
@@ -30,9 +31,9 @@ import { windProxy } from './wind.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct the local provider plugins in their established order.
|
* Construct the local provider plugins in their established order.
|
||||||
* `realtime` configures the voice session token endpoint.
|
* `realtime` configures OpenAI; `gemini` explicitly opts into Gemini Live.
|
||||||
*/
|
*/
|
||||||
function localProviderPlugins({ realtime } = {}) {
|
function localProviderPlugins({ realtime, gemini } = {}) {
|
||||||
return [
|
return [
|
||||||
openSkyProxy(),
|
openSkyProxy(),
|
||||||
celestrakProxy(),
|
celestrakProxy(),
|
||||||
@@ -55,6 +56,7 @@ function localProviderPlugins({ realtime } = {}) {
|
|||||||
aisLiveProxy(),
|
aisLiveProxy(),
|
||||||
trackBackfillProxies(),
|
trackBackfillProxies(),
|
||||||
openAiRealtimeProxy({ realtime }),
|
openAiRealtimeProxy({ realtime }),
|
||||||
|
...(gemini ? [geminiLiveProxy({ realtime: gemini })] : []),
|
||||||
googlePlacesContextProxy(),
|
googlePlacesContextProxy(),
|
||||||
windProxy(),
|
windProxy(),
|
||||||
weatherProxy(),
|
weatherProxy(),
|
||||||
|
|||||||
+101
-14
@@ -1,19 +1,106 @@
|
|||||||
import { installMapillaryRoutes } from './mapillary/routes.js';
|
import { sameSiteGated } from './common/same-site.js';
|
||||||
|
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||||
|
import {
|
||||||
|
fetchTile,
|
||||||
|
mapillaryToken,
|
||||||
|
parseTilePath,
|
||||||
|
retryAfterWait,
|
||||||
|
TileUpstreamError,
|
||||||
|
} from './mapillary/tiles.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: cached Mapillary coverage tiles (token added server-side) and a
|
* Tile requests per client IP per minute, cache hits included: over 60 new
|
||||||
* status route that says whether a token is configured.
|
* 9-tile views a minute, more than a person flying the camera reaches.
|
||||||
*/
|
*/
|
||||||
function mapillaryProxy() {
|
export const TILE_ROUTE_MAX_PER_MIN = 600;
|
||||||
return {
|
|
||||||
name: 'mapillary-proxy',
|
function sendJson(res, status, payload) {
|
||||||
configureServer(server) {
|
res.statusCode = status;
|
||||||
installMapillaryRoutes(server.middlewares);
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
},
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
configurePreviewServer(server) {
|
res.end(JSON.stringify(payload));
|
||||||
installMapillaryRoutes(server.middlewares);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { mapillaryProxy };
|
/** GET /api/mapillary/status — whether a token is configured, never its value. */
|
||||||
|
function handleStatus(req, res) {
|
||||||
|
if (req.method !== 'GET')
|
||||||
|
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||||
|
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — trimmed protobuf tile. */
|
||||||
|
async function handleTile(req, res, allow) {
|
||||||
|
if (req.method !== 'GET')
|
||||||
|
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||||
|
if (!allow(clientKey(req))) {
|
||||||
|
res.setHeader('Retry-After', '5');
|
||||||
|
return sendJson(res, 429, {
|
||||||
|
error: 'Too many tile requests',
|
||||||
|
retryAfter: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const address = parseTilePath((req.url || '').split('?')[0]);
|
||||||
|
if (!address)
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: 'Tile path must be /coverage/{z}/{x}/{y} with z 11–14',
|
||||||
|
});
|
||||||
|
if (!mapillaryToken())
|
||||||
|
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
|
||||||
|
// A client that leaves (a tile the camera moved past) cancels its fetch.
|
||||||
|
const left = new AbortController();
|
||||||
|
res.on?.('close', () => {
|
||||||
|
if (!res.writableEnded) left.abort();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { bytes, source } = await fetchTile(address, { signal: left.signal });
|
||||||
|
res.statusCode = bytes.length ? 200 : 204;
|
||||||
|
res.setHeader('Content-Type', 'application/x-protobuf');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
res.setHeader('X-Gev-Cache', source);
|
||||||
|
res.end(bytes.length ? bytes : undefined);
|
||||||
|
} catch (error) {
|
||||||
|
if (left.signal.aborted) return;
|
||||||
|
const status = error instanceof TileUpstreamError ? error.status : 0;
|
||||||
|
// A rejected token is a key problem the panel can name, not a fault.
|
||||||
|
if (status === 401 || status === 403)
|
||||||
|
return sendJson(res, 403, {
|
||||||
|
error: 'Mapillary rejected the access token',
|
||||||
|
keyRejected: true,
|
||||||
|
});
|
||||||
|
if (status === 429) {
|
||||||
|
const retryAfter = retryAfterWait(error.retryAfter);
|
||||||
|
res.setHeader('Retry-After', String(retryAfter));
|
||||||
|
return sendJson(res, 429, {
|
||||||
|
error: 'Mapillary is rate-limiting tile requests',
|
||||||
|
retryAfter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// An upstream 400 must not read as a malformed request to this proxy.
|
||||||
|
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin: Mapillary coverage tiles with the token added server-side, and
|
||||||
|
* a status route. Both refuse cross-site requests so another page cannot
|
||||||
|
* spend the token.
|
||||||
|
*/
|
||||||
|
export function mapillaryProxy() {
|
||||||
|
const install = (middlewares) => {
|
||||||
|
const allow = makeRateLimiter({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: TILE_ROUTE_MAX_PER_MIN,
|
||||||
|
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
|
||||||
|
});
|
||||||
|
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
|
||||||
|
middlewares.use(
|
||||||
|
'/api/mapillary/tiles',
|
||||||
|
sameSiteGated((req, res) => handleTile(req, res, allow)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: 'mapillary-proxy',
|
||||||
|
configureServer: (server) => install(server.middlewares),
|
||||||
|
configurePreviewServer: (server) => install(server.middlewares),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */
|
|
||||||
export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
|
|
||||||
|
|
||||||
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
|
|
||||||
export const TILE_LAYERS = Object.freeze({
|
|
||||||
// Region-sized z6–10 tiles are refused; the app never asks for them. The
|
|
||||||
// z14 `image` layer is ~98% of a tile and unused (positions come from the
|
|
||||||
// graph API), so it is dropped in transit.
|
|
||||||
coverage: Object.freeze({
|
|
||||||
upstream: 'mly1_public',
|
|
||||||
zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]),
|
|
||||||
dropLayers: Object.freeze(['image']),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Disk cache root, alongside the other providers' caches. */
|
|
||||||
export const MAPILLARY_CACHE_DIR = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
'.gev-cache',
|
|
||||||
'mapillary',
|
|
||||||
);
|
|
||||||
export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Longer than the 15-minute upstream Cache-Control: tiles change only when new
|
|
||||||
* imagery is processed, and a day avoids re-downloading a city per camera move.
|
|
||||||
*/
|
|
||||||
export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tile write starts a background sweep at most this often: expired files go
|
|
||||||
* first, then the oldest until the cache fits in TILE_DISK_MAX_BYTES.
|
|
||||||
*/
|
|
||||||
export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000;
|
|
||||||
export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024;
|
|
||||||
/** Per-file cost on top of its size, so empty tiles still count. */
|
|
||||||
export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096;
|
|
||||||
|
|
||||||
/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */
|
|
||||||
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
|
|
||||||
|
|
||||||
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
|
|
||||||
/** Per-entry cost on top of its bytes, so empty tiles still count. */
|
|
||||||
export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024;
|
|
||||||
export const TILE_FETCH_TIMEOUT_MS = 60_000;
|
|
||||||
/**
|
|
||||||
* Upstream fetches at once; further misses queue. A view needs at most 25
|
|
||||||
* tiles, and the cap stops a burst of misses opening thousands of requests.
|
|
||||||
*/
|
|
||||||
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
|
||||||
/**
|
|
||||||
* Tile requests per client IP per minute, cache hits included. 600 is 24 new
|
|
||||||
* 25-tile views a minute, more than a person flying the camera reaches, but it
|
|
||||||
* stops one page draining the shared token into a Mapillary rate limit.
|
|
||||||
*/
|
|
||||||
export const TILE_ROUTE_MAX_PER_MIN = 600;
|
|
||||||
/**
|
|
||||||
* How long a 401/403 answers tile misses before asking Mapillary again. A
|
|
||||||
* changed token is tried at once.
|
|
||||||
*/
|
|
||||||
export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000;
|
|
||||||
/** After a 429 without a usable Retry-After, hold tile misses this long. */
|
|
||||||
export const TILE_RATE_LIMIT_HOLD_MS = 60_000;
|
|
||||||
/** The longest Retry-After honoured, so a bad header cannot stall coverage. */
|
|
||||||
export const TILE_RATE_LIMIT_MAX_HOLD_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
|
||||||
export function mapillaryToken() {
|
|
||||||
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { sameSiteGated } from '../common/same-site.js';
|
|
||||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
|
||||||
import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js';
|
|
||||||
import { mapillaryToken, TILE_ROUTE_MAX_PER_MIN } from './constants.js';
|
|
||||||
|
|
||||||
function sendJson(res, status, payload) {
|
|
||||||
res.statusCode = status;
|
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/mapillary/status — whether a token is configured, never its value. */
|
|
||||||
function handleStatus(req, res) {
|
|
||||||
if (req.method !== 'GET')
|
|
||||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
|
||||||
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — cached protobuf tile. */
|
|
||||||
async function handleTile(req, res, allow) {
|
|
||||||
if (req.method !== 'GET')
|
|
||||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
|
||||||
if (!allow(clientKey(req))) {
|
|
||||||
res.setHeader('Retry-After', '5');
|
|
||||||
return sendJson(res, 429, {
|
|
||||||
error: 'Too many tile requests',
|
|
||||||
retryAfter: 5,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const match = /^\/(coverage)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(
|
|
||||||
(req.url || '').split('?')[0],
|
|
||||||
);
|
|
||||||
if (!match)
|
|
||||||
return sendJson(res, 400, {
|
|
||||||
error: 'Tile path must be /coverage/{z}/{x}/{y}',
|
|
||||||
});
|
|
||||||
if (!mapillaryToken())
|
|
||||||
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
|
|
||||||
const abandoned = new AbortController();
|
|
||||||
req.on?.('aborted', () => abandoned.abort());
|
|
||||||
res.on?.('close', () => abandoned.abort());
|
|
||||||
try {
|
|
||||||
const { bytes, source } = await fetchTile(
|
|
||||||
{ layer: match[1], z: match[2], x: match[3], y: match[4] },
|
|
||||||
{ signal: abandoned.signal },
|
|
||||||
);
|
|
||||||
if (res.writableEnded) return;
|
|
||||||
res.statusCode = bytes.length ? 200 : 204;
|
|
||||||
res.setHeader('Content-Type', 'application/x-protobuf');
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
||||||
res.setHeader('X-Gev-Cache', source);
|
|
||||||
res.end(bytes.length ? bytes : undefined);
|
|
||||||
} catch (error) {
|
|
||||||
if (res.writableEnded || abandoned.signal.aborted) return;
|
|
||||||
if (error instanceof TileRequestError)
|
|
||||||
return sendJson(res, error.status, { error: error.message });
|
|
||||||
if (error instanceof TileUpstreamError) {
|
|
||||||
// A rejected token is a key problem the panel can name, not a fault.
|
|
||||||
if (error.keyRejected)
|
|
||||||
return sendJson(res, 403, {
|
|
||||||
error: 'Mapillary rejected the access token',
|
|
||||||
keyRejected: true,
|
|
||||||
});
|
|
||||||
if (error.status === 429) {
|
|
||||||
const retryAfter = error.retryAfterSec || 60;
|
|
||||||
res.setHeader('Retry-After', String(retryAfter));
|
|
||||||
return sendJson(res, 429, {
|
|
||||||
error: 'Mapillary is rate-limiting tile requests',
|
|
||||||
retryAfter,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// An upstream 400 must not read as a malformed request to this proxy.
|
|
||||||
return sendJson(res, 502, { error: error.message });
|
|
||||||
}
|
|
||||||
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach the Mapillary routes. Both refuse cross-site requests so another page
|
|
||||||
* cannot spend the token or provoke a 429 that holds every tile miss.
|
|
||||||
*/
|
|
||||||
export function installMapillaryRoutes(middlewares) {
|
|
||||||
const allow = makeRateLimiter({
|
|
||||||
windowMs: 60_000,
|
|
||||||
max: TILE_ROUTE_MAX_PER_MIN,
|
|
||||||
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
|
|
||||||
});
|
|
||||||
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
|
|
||||||
middlewares.use(
|
|
||||||
'/api/mapillary/tiles',
|
|
||||||
sameSiteGated((req, res) => handleTile(req, res, allow)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+216
-469
@@ -1,518 +1,265 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { promises as fsp } from 'node:fs';
|
|
||||||
import { readResponseBytesCapped } from '../common/http.js';
|
import { readResponseBytesCapped } from '../common/http.js';
|
||||||
import { stripTileLayers } from './trim.js';
|
import { stripTileLayers } from './trim.js';
|
||||||
import {
|
|
||||||
MAPILLARY_TILE_HOST,
|
|
||||||
TILE_LAYERS,
|
|
||||||
TILE_DISK_DIR,
|
|
||||||
TILE_DISK_TTL_MS,
|
|
||||||
TILE_DISK_SWEEP_INTERVAL_MS,
|
|
||||||
TILE_DISK_MAX_BYTES,
|
|
||||||
TILE_DISK_FILE_OVERHEAD_BYTES,
|
|
||||||
TILE_MAX_BYTES,
|
|
||||||
TILE_MEMORY_BUDGET_BYTES,
|
|
||||||
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
|
|
||||||
TILE_FETCH_TIMEOUT_MS,
|
|
||||||
TILE_UPSTREAM_CONCURRENCY,
|
|
||||||
TILE_KEY_REJECTED_HOLD_MS,
|
|
||||||
TILE_RATE_LIMIT_HOLD_MS,
|
|
||||||
TILE_RATE_LIMIT_MAX_HOLD_MS,
|
|
||||||
mapillaryToken,
|
|
||||||
} from './constants.js';
|
|
||||||
|
|
||||||
/** Log without the stack; the token is never in scope here. */
|
/** Mapillary vector tiles, /{layer}/2/{z}/{x}/{y}: the only host the token goes to. */
|
||||||
function warn(what, error) {
|
const TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
|
||||||
console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error);
|
/** Only the street zooms the app draws are proxied. */
|
||||||
|
const MIN_ZOOM = 11;
|
||||||
|
const MAX_ZOOM = 14;
|
||||||
|
/**
|
||||||
|
* The z14 `image` layer is ~98% of a tile (12 MB over a dense city) and unused
|
||||||
|
* (positions come from the Graph API), so it is dropped in transit.
|
||||||
|
*/
|
||||||
|
const DROP_LAYERS = ['image'];
|
||||||
|
/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */
|
||||||
|
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
|
||||||
|
const FETCH_TIMEOUT_MS = 60_000;
|
||||||
|
/** Upstream fetches at once; more wait their turn. */
|
||||||
|
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
||||||
|
/** Tiles change only when new imagery is processed. */
|
||||||
|
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
|
||||||
|
export const TILE_MEMORY_MAX_TILES = 120;
|
||||||
|
/** A trimmed tile is ~80 KB; a body this big failed to trim and is not kept. */
|
||||||
|
export const TILE_MEMORY_MAX_TILE_BYTES = 4 * 1024 * 1024;
|
||||||
|
/** The longest upstream Retry-After honoured, in seconds. */
|
||||||
|
export const MAX_RETRY_AFTER_SEC = 600;
|
||||||
|
|
||||||
|
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
||||||
|
export function mapillaryToken() {
|
||||||
|
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thrown for a request this proxy refuses before contacting Mapillary. */
|
/** Mapillary answered with an error status (or a size this proxy refuses). */
|
||||||
export class TileRequestError extends Error {
|
|
||||||
constructor(message, status = 400) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'TileRequestError';
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mapillary answered with an error status; 401/403 set `keyRejected`. */
|
|
||||||
export class TileUpstreamError extends Error {
|
export class TileUpstreamError extends Error {
|
||||||
constructor(status, message, { retryAfterSec = null } = {}) {
|
constructor(status, message, retryAfter = null) {
|
||||||
super(message || `Mapillary tiles HTTP ${status}`);
|
super(message || `Mapillary tiles HTTP ${status}`);
|
||||||
this.name = 'TileUpstreamError';
|
this.name = 'TileUpstreamError';
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.keyRejected = status === 401 || status === 403;
|
/** The upstream Retry-After header, as sent. */
|
||||||
this.retryAfterSec = retryAfterSec;
|
this.retryAfter = retryAfter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */
|
||||||
* A rejected token or rate limit that answers every tile miss until it expires
|
export function retryAfterSeconds(header, now = Date.now()) {
|
||||||
* (or the token changes). Cached tiles are still served meanwhile.
|
|
||||||
* @type {{status: number, until: number, token: string}|null}
|
|
||||||
*/
|
|
||||||
let _upstreamHold = null;
|
|
||||||
|
|
||||||
/** Milliseconds to wait from a Retry-After header (seconds or an HTTP date). */
|
|
||||||
function retryAfterMs(header, now = Date.now()) {
|
|
||||||
const text = String(header ?? '').trim();
|
const text = String(header ?? '').trim();
|
||||||
let ms = NaN;
|
if (!text) return null;
|
||||||
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
|
const seconds = /^\d+$/.test(text)
|
||||||
else if (text) ms = Date.parse(text) - now;
|
? Number(text)
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return TILE_RATE_LIMIT_HOLD_MS;
|
: (Date.parse(text) - now) / 1000;
|
||||||
return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS);
|
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function heldRefusal(token) {
|
/** The wait a 429 asks for, bounded; a missing or malformed header means a minute. */
|
||||||
const hold = _upstreamHold;
|
export function retryAfterWait(header, now = Date.now()) {
|
||||||
if (!hold) return null;
|
return Math.min(retryAfterSeconds(header, now) ?? 60, MAX_RETRY_AFTER_SEC);
|
||||||
const left = hold.until - Date.now();
|
}
|
||||||
if (left <= 0 || hold.token !== token) {
|
|
||||||
_upstreamHold = null;
|
/**
|
||||||
|
* While Mapillary rate-limits, tile misses are refused here until the wait is
|
||||||
|
* over, so the rest of a view (and the fetches queued behind the first 429)
|
||||||
|
* do not go on asking. Scoped to the token that was refused.
|
||||||
|
* @type {{until: number, token: string}|null}
|
||||||
|
*/
|
||||||
|
let refusal = null;
|
||||||
|
|
||||||
|
/** The refusal still in force for `token`, as the error to answer with. */
|
||||||
|
function refusedNow(token) {
|
||||||
|
if (!refusal) return null;
|
||||||
|
const left = refusal.until - Date.now();
|
||||||
|
if (left <= 0 || refusal.token !== token) {
|
||||||
|
refusal = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new TileUpstreamError(hold.status, undefined, {
|
return new TileUpstreamError(429, undefined, String(Math.ceil(left / 1000)));
|
||||||
retryAfterSec: Math.ceil(left / 1000),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a tile address against the layer's zoom ranges.
|
* Parse `/coverage/{z}/{x}/{y}`; null unless z is 11–14 and x/y are on the grid.
|
||||||
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
|
* @returns {{z: number, x: number, y: number, key: string}|null}
|
||||||
*/
|
*/
|
||||||
export function normalizeTileAddress({ layer, z, x, y }) {
|
export function parseTilePath(pathname) {
|
||||||
const spec = Object.hasOwn(TILE_LAYERS, layer) ? TILE_LAYERS[layer] : null;
|
const match = /^\/coverage\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(pathname);
|
||||||
if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`);
|
if (!match) return null;
|
||||||
const zi = Number(z);
|
const [z, x, y] = match.slice(1).map(Number);
|
||||||
const xi = Number(x);
|
if (z < MIN_ZOOM || z > MAX_ZOOM || x >= 2 ** z || y >= 2 ** z) return null;
|
||||||
const yi = Number(y);
|
return { z, x, y, key: `${z}/${x}/${y}` };
|
||||||
if (
|
|
||||||
![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) ||
|
|
||||||
!spec.zoomRanges.some(([min, max]) => zi >= min && zi <= max)
|
|
||||||
)
|
|
||||||
throw new TileRequestError(
|
|
||||||
`Tile zoom for ${layer} must be ${spec.zoomRanges
|
|
||||||
.map(([min, max]) => `${min}–${max}`)
|
|
||||||
.join(' or ')}`,
|
|
||||||
);
|
|
||||||
const n = 2 ** zi;
|
|
||||||
if (xi >= n || yi >= n)
|
|
||||||
throw new TileRequestError('Tile address out of range');
|
|
||||||
return {
|
|
||||||
layer,
|
|
||||||
upstream: spec.upstream,
|
|
||||||
dropLayers: spec.dropLayers || [],
|
|
||||||
z: zi,
|
|
||||||
x: xi,
|
|
||||||
y: yi,
|
|
||||||
key: `${layer}/${zi}/${xi}/${yi}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
|
function trim(bytes) {
|
||||||
const _memory = new Map();
|
|
||||||
let _memoryBytes = 0;
|
|
||||||
const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
|
|
||||||
/**
|
|
||||||
* Upstream fetches shared by every request for the same tile; the last waiter
|
|
||||||
* to leave cancels it (see `joinFlight`).
|
|
||||||
* @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>}
|
|
||||||
*/
|
|
||||||
const _inFlight = new Map();
|
|
||||||
|
|
||||||
function memoryGet(key) {
|
|
||||||
const hit = _memory.get(key);
|
|
||||||
if (!hit) return null;
|
|
||||||
// Same life as the disk copy, so a long-running server never serves stale
|
|
||||||
// coverage.
|
|
||||||
if (Date.now() - hit.at > TILE_DISK_TTL_MS) {
|
|
||||||
_memory.delete(key);
|
|
||||||
_memoryBytes -= memoryCost(hit.bytes);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
_memory.delete(key);
|
|
||||||
_memory.set(key, hit);
|
|
||||||
return hit.bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `at` is when Mapillary served the bytes (a disk tile's mtime). */
|
|
||||||
function memoryPut(key, bytes, at = Date.now()) {
|
|
||||||
if (memoryCost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return;
|
|
||||||
const existing = _memory.get(key);
|
|
||||||
if (existing) _memoryBytes -= memoryCost(existing.bytes);
|
|
||||||
_memory.set(key, { bytes, at });
|
|
||||||
_memoryBytes += memoryCost(bytes);
|
|
||||||
while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) {
|
|
||||||
const [oldest, entry] = _memory.entries().next().value;
|
|
||||||
_memory.delete(oldest);
|
|
||||||
_memoryBytes -= memoryCost(entry.bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Disk cache root; tests point it at a temporary directory. */
|
|
||||||
let _diskDir = TILE_DISK_DIR;
|
|
||||||
|
|
||||||
function diskPath({ layer, z, x, y }, root = _diskDir) {
|
|
||||||
return path.join(root, layer, String(z), `${x}-${y}.pbf`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readDisk(address) {
|
|
||||||
const file = diskPath(address);
|
|
||||||
try {
|
try {
|
||||||
const stat = await fsp.stat(file);
|
return stripTileLayers(bytes, DROP_LAYERS);
|
||||||
if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null;
|
|
||||||
return { bytes: await fsp.readFile(file), at: stat.mtimeMs };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unsettled background writes and sweeps; requests never wait on them. */
|
|
||||||
const _background = new Set();
|
|
||||||
|
|
||||||
function track(task) {
|
|
||||||
const settled = task
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => _background.delete(settled));
|
|
||||||
_background.add(settled);
|
|
||||||
return settled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {Map<string, number>} cache file to its newest write's sequence */
|
|
||||||
const _diskWrites = new Map();
|
|
||||||
let _diskWriteSeq = 0;
|
|
||||||
|
|
||||||
function diskWritePending(address) {
|
|
||||||
return _diskWrites.has(diskPath(address));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a tile in the background. `at` keeps a rewrite's original fetch time
|
|
||||||
* so it does not extend the tile's life. An overtaken write steps aside.
|
|
||||||
*/
|
|
||||||
function writeDisk(address, bytes, at = null) {
|
|
||||||
// Sweep this write's root even if the cache dir has moved since.
|
|
||||||
const root = _diskDir;
|
|
||||||
const file = diskPath(address, root);
|
|
||||||
const seq = ++_diskWriteSeq;
|
|
||||||
const tmp = `${file}.${process.pid}-${seq}.tmp`;
|
|
||||||
const newest = () => _diskWrites.get(file) === seq;
|
|
||||||
_diskWrites.set(file, seq);
|
|
||||||
return track(
|
|
||||||
fsp
|
|
||||||
.mkdir(path.dirname(file), { recursive: true })
|
|
||||||
.then(() => fsp.writeFile(tmp, bytes))
|
|
||||||
.then(() => at != null && fsp.utimes(tmp, new Date(), new Date(at)))
|
|
||||||
.then(() => {
|
|
||||||
if (!newest()) return fsp.rm(tmp, { force: true });
|
|
||||||
return fsp.rename(tmp, file).then(() => scheduleSweep(root));
|
|
||||||
})
|
|
||||||
.catch(async (error) => {
|
|
||||||
await fsp.rm(tmp, { force: true }).catch(() => {});
|
|
||||||
warn('tile cache write failed', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (newest()) _diskWrites.delete(file);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _lastSweepAt = 0;
|
|
||||||
let _sweeping = null;
|
|
||||||
let _sweepWarned = false;
|
|
||||||
|
|
||||||
function scheduleSweep(root) {
|
|
||||||
if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS)
|
|
||||||
return;
|
|
||||||
_lastSweepAt = Date.now();
|
|
||||||
const sweep = sweepTileDisk({ root })
|
|
||||||
.catch((error) => {
|
|
||||||
if (_sweepWarned) return;
|
|
||||||
_sweepWarned = true;
|
|
||||||
warn('tile cache sweep failed', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (_sweeping === tracked) _sweeping = null;
|
|
||||||
});
|
|
||||||
const tracked = track(sweep);
|
|
||||||
_sweeping = tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every file under `dir` with size and mtime; a missing dir is empty. */
|
|
||||||
async function listCacheFiles(dir) {
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code === 'ENOENT') return [];
|
console.warn('[Mapillary Proxy] tile trim failed:', error?.message);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const files = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
const file = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) files.push(...(await listCacheFiles(file)));
|
|
||||||
else if (entry.isFile()) {
|
|
||||||
const stat = await fsp.stat(file).catch(() => null);
|
|
||||||
if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete expired tiles, then the oldest until the rest fit in `maxBytes`.
|
|
||||||
* @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept
|
|
||||||
*/
|
|
||||||
export async function sweepTileDisk({
|
|
||||||
root = _diskDir,
|
|
||||||
maxBytes = TILE_DISK_MAX_BYTES,
|
|
||||||
} = {}) {
|
|
||||||
const now = Date.now();
|
|
||||||
const files = (await listCacheFiles(root)).sort((a, b) => a.at - b.at);
|
|
||||||
const cost = (size) => size + TILE_DISK_FILE_OVERHEAD_BYTES;
|
|
||||||
let bytes = files.reduce((sum, { size }) => sum + cost(size), 0);
|
|
||||||
let removed = 0;
|
|
||||||
for (const { file, size, at } of files) {
|
|
||||||
if (now - at <= TILE_DISK_TTL_MS && bytes <= maxBytes) break;
|
|
||||||
await fsp.rm(file, { force: true });
|
|
||||||
bytes -= cost(size);
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
return { removed, bytes };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The one origin the tile token may be sent to, at every redirect hop. */
|
|
||||||
const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin;
|
|
||||||
export const TILE_MAX_REDIRECTS = 3;
|
|
||||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follow redirects by hand so every hop stays HTTPS on the tile origin and the
|
|
||||||
* token in the query never reaches another host. A bad redirect is a 502.
|
|
||||||
*/
|
|
||||||
async function fetchPinned(url, init) {
|
|
||||||
let next = url;
|
|
||||||
for (let hop = 0; ; hop++) {
|
|
||||||
const response = await fetch(next, { ...init, redirect: 'manual' });
|
|
||||||
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
||||||
await response.body?.cancel().catch(() => {});
|
|
||||||
if (hop >= TILE_MAX_REDIRECTS)
|
|
||||||
throw new TileUpstreamError(502, 'Mapillary tile redirected too often');
|
|
||||||
let target = null;
|
|
||||||
try {
|
|
||||||
target = new URL(response.headers.get('location') ?? '', next);
|
|
||||||
} catch {
|
|
||||||
/* unparseable Location */
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!response.headers.get('location') ||
|
|
||||||
target?.protocol !== 'https:' ||
|
|
||||||
target.origin !== TILE_ORIGIN ||
|
|
||||||
target.username ||
|
|
||||||
target.password
|
|
||||||
)
|
|
||||||
throw new TileUpstreamError(
|
|
||||||
502,
|
|
||||||
'Mapillary tile redirect left the tile origin',
|
|
||||||
);
|
|
||||||
next = target.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUpstream(address, signal) {
|
|
||||||
const token = mapillaryToken();
|
|
||||||
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
|
|
||||||
const held = heldRefusal(token);
|
|
||||||
if (held) throw held;
|
|
||||||
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
|
|
||||||
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
|
|
||||||
const oversize = new AbortController();
|
|
||||||
const response = await fetchPinned(url, {
|
|
||||||
signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)),
|
|
||||||
headers: { Accept: 'application/x-protobuf' },
|
|
||||||
});
|
|
||||||
if (response.status === 404 || response.status === 204) {
|
|
||||||
await response.body?.cancel().catch(() => {});
|
|
||||||
return Buffer.alloc(0);
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
await response.body?.cancel().catch(() => {});
|
|
||||||
const error = new TileUpstreamError(response.status);
|
|
||||||
if (error.keyRejected)
|
|
||||||
_upstreamHold = {
|
|
||||||
status: response.status,
|
|
||||||
until: Date.now() + TILE_KEY_REJECTED_HOLD_MS,
|
|
||||||
token,
|
|
||||||
};
|
|
||||||
else if (response.status === 429) {
|
|
||||||
const wait = retryAfterMs(response.headers.get('retry-after'));
|
|
||||||
_upstreamHold = { status: 429, until: Date.now() + wait, token };
|
|
||||||
error.retryAfterSec = Math.ceil(wait / 1000);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Chunked or compressed bodies have no Content-Length: the cap applies as
|
|
||||||
// the body streams, and an oversized one also aborts the request.
|
|
||||||
try {
|
|
||||||
const bytes = await readResponseBytesCapped(
|
|
||||||
response,
|
|
||||||
TILE_MAX_BYTES,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
|
|
||||||
oversize.abort();
|
|
||||||
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {{active: number, queue: Array<() => void>}} upstream slots */
|
|
||||||
let _slots = { active: 0, queue: [] };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve with a release function once a slot is free. An abort while
|
|
||||||
* queued leaves the queue.
|
|
||||||
* @returns {Promise<() => void>}
|
|
||||||
*/
|
|
||||||
function acquireUpstreamSlot(signal) {
|
|
||||||
const slots = _slots;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (signal.aborted) return reject(signal.reason);
|
|
||||||
const onAbort = () => {
|
|
||||||
const index = slots.queue.indexOf(grant);
|
|
||||||
if (index !== -1) slots.queue.splice(index, 1);
|
|
||||||
reject(signal.reason);
|
|
||||||
};
|
|
||||||
function grant() {
|
|
||||||
signal.removeEventListener('abort', onAbort);
|
|
||||||
slots.active++;
|
|
||||||
let released = false;
|
|
||||||
resolve(() => {
|
|
||||||
if (released) return;
|
|
||||||
released = true;
|
|
||||||
slots.active--;
|
|
||||||
slots.queue.shift()?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (slots.active < TILE_UPSTREAM_CONCURRENCY) return grant();
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
|
||||||
slots.queue.push(grant);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function trim(address, bytes) {
|
|
||||||
if (!address.dropLayers.length || !bytes.length) return bytes;
|
|
||||||
try {
|
|
||||||
return stripTileLayers(bytes, address.dropLayers);
|
|
||||||
} catch (error) {
|
|
||||||
warn('tile trim failed, serving raw', error);
|
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** One tile from Mapillary, trimmed; empty bytes mean no coverage. */
|
||||||
* Fetch one tile from memory, disk, a shared flight, then Mapillary. Empty
|
async function fetchUpstream({ z, x, y }, signal) {
|
||||||
* bytes mean no coverage.
|
const token = mapillaryToken();
|
||||||
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
|
const refused = refusedNow(token);
|
||||||
* @param {{signal?: AbortSignal}} [options]
|
if (refused) throw refused;
|
||||||
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
|
const response = await fetch(
|
||||||
*/
|
`${TILE_HOST}/mly1_public/2/${z}/${x}/${y}?access_token=${encodeURIComponent(token)}`,
|
||||||
export async function fetchTile(request, { signal } = {}) {
|
{
|
||||||
const address = normalizeTileAddress(request);
|
signal: AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]),
|
||||||
const memory = memoryGet(address.key);
|
// The token is in the query: a redirect is never followed (a 3xx is a 502).
|
||||||
if (memory) return { bytes: memory, source: 'memory', address };
|
redirect: 'manual',
|
||||||
const disk = await readDisk(address);
|
headers: { Accept: 'application/x-protobuf' },
|
||||||
if (disk) {
|
},
|
||||||
// Older cache files may be untrimmed: rewrite once, keeping the fetch time.
|
);
|
||||||
const bytes = trim(address, disk.bytes);
|
if (!response.ok || response.status === 204) {
|
||||||
if (bytes !== disk.bytes && !diskWritePending(address))
|
await response.body?.cancel().catch(() => {});
|
||||||
writeDisk(address, bytes, disk.at);
|
if (response.status === 204 || response.status === 404)
|
||||||
memoryPut(address.key, bytes, disk.at);
|
return Buffer.alloc(0);
|
||||||
return { bytes, source: 'disk', address };
|
const retryAfter = response.headers.get('retry-after');
|
||||||
|
if (response.status === 429)
|
||||||
|
refusal = {
|
||||||
|
token,
|
||||||
|
until: Date.now() + retryAfterWait(retryAfter) * 1000,
|
||||||
|
};
|
||||||
|
throw new TileUpstreamError(response.status, undefined, retryAfter);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bytes = await readResponseBytesCapped(response, TILE_MAX_BYTES);
|
||||||
|
return trim(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length));
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
|
||||||
|
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
|
||||||
}
|
}
|
||||||
// A caller gone while the disk was read must not start a fetch nobody joins.
|
|
||||||
signal?.throwIfAborted();
|
|
||||||
let flight = _inFlight.get(address.key);
|
|
||||||
const joined = Boolean(flight) && !flight.controller.signal.aborted;
|
|
||||||
if (!joined) flight = startFlight(address);
|
|
||||||
const bytes = await joinFlight(flight, signal);
|
|
||||||
return { bytes, source: joined ? 'inflight' : 'upstream', address };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start the shared upstream fetch; it holds a slot until the body is read. */
|
let upstreamActive = 0;
|
||||||
|
/** @type {Array<() => void>} fetches waiting for a slot, oldest first */
|
||||||
|
const upstreamWaiting = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `task` in an upstream slot, waiting for one unless `signal` aborts
|
||||||
|
* first. A freed slot passes straight to the oldest waiter, still counted, so
|
||||||
|
* a request arriving meanwhile cannot take it first.
|
||||||
|
*/
|
||||||
|
async function withUpstreamSlot(signal, task) {
|
||||||
|
if (upstreamActive < TILE_UPSTREAM_CONCURRENCY) upstreamActive++;
|
||||||
|
else
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const wake = () => {
|
||||||
|
signal.removeEventListener('abort', leave);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const leave = () => {
|
||||||
|
const at = upstreamWaiting.indexOf(wake);
|
||||||
|
if (at !== -1) upstreamWaiting.splice(at, 1);
|
||||||
|
reject(signal.reason);
|
||||||
|
};
|
||||||
|
upstreamWaiting.push(wake);
|
||||||
|
signal.addEventListener('abort', leave, { once: true });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Handed a slot after leaving: it goes on to the next waiter.
|
||||||
|
signal.throwIfAborted();
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
const next = upstreamWaiting.shift();
|
||||||
|
if (next) next();
|
||||||
|
else upstreamActive--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `promise`, or a rejection as soon as `signal` aborts. */
|
||||||
|
function unlessAborted(promise, signal) {
|
||||||
|
if (!signal) return promise;
|
||||||
|
signal.throwIfAborted();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const leave = () => reject(signal.reason);
|
||||||
|
signal.addEventListener('abort', leave, { once: true });
|
||||||
|
promise
|
||||||
|
.then(resolve, reject)
|
||||||
|
.finally(() => signal.removeEventListener('abort', leave));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Map<string, {bytes: Buffer, at: number}>} least recently used first */
|
||||||
|
const memory = new Map();
|
||||||
|
/**
|
||||||
|
* Upstream fetches shared per tile, with the requests still waiting on each.
|
||||||
|
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
|
||||||
|
*/
|
||||||
|
const inFlight = new Map();
|
||||||
|
|
||||||
|
function remember(key, bytes, at = Date.now()) {
|
||||||
|
if (bytes.length > TILE_MEMORY_MAX_TILE_BYTES) return;
|
||||||
|
memory.delete(key);
|
||||||
|
memory.set(key, { bytes, at });
|
||||||
|
for (const oldest of memory.keys()) {
|
||||||
|
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
|
||||||
|
memory.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recall(key) {
|
||||||
|
const hit = memory.get(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (Date.now() - hit.at > TILE_TTL_MS) {
|
||||||
|
memory.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
remember(key, hit.bytes, hit.at); // most recently used again
|
||||||
|
return hit.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the upstream fetch for a tile, shared until its last request leaves. */
|
||||||
function startFlight(address) {
|
function startFlight(address) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const flight = { controller, waiters: 0, promise: null };
|
const flight = { controller, waiters: 0 };
|
||||||
flight.promise = acquireUpstreamSlot(controller.signal)
|
flight.promise = withUpstreamSlot(controller.signal, () =>
|
||||||
.then((release) =>
|
fetchUpstream(address, controller.signal),
|
||||||
fetchUpstream(address, controller.signal).finally(release),
|
)
|
||||||
)
|
|
||||||
.then((raw) => trim(address, raw))
|
|
||||||
.then((bytes) => {
|
.then((bytes) => {
|
||||||
memoryPut(address.key, bytes);
|
remember(address.key, bytes);
|
||||||
writeDisk(address, bytes);
|
|
||||||
return bytes;
|
return bytes;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (_inFlight.get(address.key) === flight) _inFlight.delete(address.key);
|
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
|
||||||
});
|
});
|
||||||
// Every waiter may have left before it settles; nobody awaits it then.
|
// Every request may have left; the rejection is then nobody's.
|
||||||
flight.promise.catch(() => {});
|
flight.promise.catch(() => {});
|
||||||
_inFlight.set(address.key, flight);
|
inFlight.set(address.key, flight);
|
||||||
return flight;
|
return flight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait on a shared flight. A caller's abort rejects only that caller; the
|
* One tile from memory, a shared in-flight fetch, or Mapillary. `signal`
|
||||||
* fetch is cancelled when the last waiter leaves.
|
* aborts when the request's client leaves; the fetch stops once all have.
|
||||||
|
* @param {{z: number, x: number, y: number, key: string}} address from parseTilePath
|
||||||
|
* @param {{signal?: AbortSignal}} [options]
|
||||||
|
* @returns {Promise<{bytes: Buffer, source: 'memory'|'inflight'|'upstream'}>}
|
||||||
*/
|
*/
|
||||||
function joinFlight(flight, signal) {
|
export async function fetchTile(address, { signal } = {}) {
|
||||||
signal?.throwIfAborted();
|
const cached = recall(address.key);
|
||||||
|
if (cached) return { bytes: cached, source: 'memory' };
|
||||||
|
let flight = inFlight.get(address.key);
|
||||||
|
const shared = Boolean(flight);
|
||||||
|
flight ||= startFlight(address);
|
||||||
flight.waiters++;
|
flight.waiters++;
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
let done = false;
|
const bytes = await unlessAborted(flight.promise, signal);
|
||||||
const leave = () => {
|
return { bytes, source: shared ? 'inflight' : 'upstream' };
|
||||||
if (done) return false;
|
} finally {
|
||||||
done = true;
|
if (--flight.waiters === 0) {
|
||||||
signal?.removeEventListener('abort', onAbort);
|
// A later request for the tile must not join a cancelled fetch.
|
||||||
flight.waiters--;
|
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
|
||||||
return true;
|
flight.controller.abort();
|
||||||
};
|
}
|
||||||
const onAbort = () => {
|
}
|
||||||
if (!leave()) return;
|
|
||||||
if (flight.waiters === 0) flight.controller.abort();
|
|
||||||
reject(signal.reason);
|
|
||||||
};
|
|
||||||
signal?.addEventListener('abort', onAbort, { once: true });
|
|
||||||
flight.promise.then(
|
|
||||||
(bytes) => leave() && resolve(bytes),
|
|
||||||
(error) => leave() && reject(error),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam: forget every cached tile held in memory. */
|
/** Test seam: forget every cached and in-flight tile. */
|
||||||
export function _resetTileMemoryForTest() {
|
export function _resetTileCacheForTest() {
|
||||||
_memory.clear();
|
memory.clear();
|
||||||
_memoryBytes = 0;
|
inFlight.clear();
|
||||||
_inFlight.clear();
|
refusal = null;
|
||||||
_upstreamHold = null;
|
upstreamActive = 0;
|
||||||
_slots = { active: 0, queue: [] };
|
upstreamWaiting.length = 0;
|
||||||
}
|
|
||||||
|
|
||||||
/** Test seam: how many tiles memory holds and the bytes charged for them. */
|
|
||||||
export function _tileMemoryForTest() {
|
|
||||||
return { entries: _memory.size, bytes: _memoryBytes };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test seam: wait for every background write and the sweeps they start. */
|
|
||||||
export async function _settleTileWritesForTest() {
|
|
||||||
while (_background.size) await Promise.allSettled([..._background]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test seam: keep the disk cache in `dir` (null restores the default). */
|
|
||||||
export function _setTileCacheDirForTest(dir) {
|
|
||||||
_diskDir = dir || TILE_DISK_DIR;
|
|
||||||
_lastSweepAt = 0;
|
|
||||||
}
|
}
|
||||||
@@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) {
|
|||||||
}, null);
|
}, null);
|
||||||
return dropped ? Buffer.from(writer.finish()) : bytes;
|
return dropped ? Buffer.from(writer.finish()) : bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Names of the layers inside a vector tile, in order. */
|
|
||||||
export function listTileLayers(bytes) {
|
|
||||||
const names = [];
|
|
||||||
if (!bytes?.length) return names;
|
|
||||||
new PbfReader(bytes).readFields((tag, _result, pbf) => {
|
|
||||||
if (tag !== TILE_LAYER_FIELD) return;
|
|
||||||
const end = pbf.readVarint() + pbf.pos;
|
|
||||||
names.push(layerName(bytes.subarray(pbf.pos, end)));
|
|
||||||
pbf.pos = end;
|
|
||||||
}, null);
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
@@ -16,9 +16,10 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
for (const [key, value] of Object.entries(loaded)) {
|
for (const [key, value] of Object.entries(loaded)) {
|
||||||
if (process.env[key] === undefined) process.env[key] = value;
|
if (process.env[key] === undefined) process.env[key] = value;
|
||||||
}
|
}
|
||||||
|
const tools = standaloneVoiceTools();
|
||||||
return createBrowserViteConfig({
|
return createBrowserViteConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
|
...localProviderPlugins({ realtime: { tools }, gemini: { tools } }),
|
||||||
localMcpPlugin(),
|
localMcpPlugin(),
|
||||||
apiNotFoundPlugin(),
|
apiNotFoundPlugin(),
|
||||||
unwatchedRuntimeDirsPlugin(),
|
unwatchedRuntimeDirsPlugin(),
|
||||||
|
|||||||
@@ -32,21 +32,11 @@ function defaultProviders(sources) {
|
|||||||
* including when the list is empty.
|
* including when the list is empty.
|
||||||
*/
|
*/
|
||||||
export function createApplicationStreetLevel({
|
export function createApplicationStreetLevel({
|
||||||
surface,
|
|
||||||
sources = {},
|
sources = {},
|
||||||
providers = defaultProviders(sources),
|
providers = defaultProviders(sources),
|
||||||
}) {
|
}) {
|
||||||
return createStreetLevelLayer({
|
return createStreetLevelLayer({
|
||||||
providers,
|
providers,
|
||||||
services: {
|
services: { sprites, picking, input, scenePick, render },
|
||||||
sprites,
|
|
||||||
picking,
|
|
||||||
input,
|
|
||||||
scenePick,
|
|
||||||
render,
|
|
||||||
ground: surface?.groundFloor ?? null,
|
|
||||||
meshFloor: surface?.meshFloor ?? null,
|
|
||||||
terrain: surface?.terrain ?? null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,11 @@ import {
|
|||||||
CCTV_FOCUS_REQUEST_EVENT,
|
CCTV_FOCUS_REQUEST_EVENT,
|
||||||
activateCctvCameraFromWorldClick,
|
activateCctvCameraFromWorldClick,
|
||||||
} from '../cctvFocusRequest.js';
|
} from '../cctvFocusRequest.js';
|
||||||
|
import {
|
||||||
|
ACTIVE_CAMERA_COLOR,
|
||||||
|
IDLE_CAMERA_COLOR,
|
||||||
|
LIVE_CAMERA_COLOR,
|
||||||
|
} from '../layers/cctv/policy.js';
|
||||||
|
|
||||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||||
path.join(
|
path.join(
|
||||||
@@ -360,6 +365,37 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
|
||||||
|
const record = (id, feedType) => ({
|
||||||
|
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
|
||||||
|
billboard: {},
|
||||||
|
coverageEntities: [],
|
||||||
|
projection: null,
|
||||||
|
});
|
||||||
|
const idleVideo = record('idle-hls', 'hls');
|
||||||
|
const idleStill = record('idle-image', 'image');
|
||||||
|
const activeVideo = record('active-hls', 'hls');
|
||||||
|
const viewer = { entities: { add: (entity) => entity } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Disabled keeps the active video camera from opening a real projection;
|
||||||
|
// icon colors are assigned either way.
|
||||||
|
_setCctvCoverageStateForTest({
|
||||||
|
viewer,
|
||||||
|
records: [idleVideo, idleStill, activeVideo],
|
||||||
|
activeCameraId: activeVideo.camera.id,
|
||||||
|
enabled: false,
|
||||||
|
coverageMode: 'off',
|
||||||
|
});
|
||||||
|
refreshCoverageStyles();
|
||||||
|
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
|
||||||
|
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
|
||||||
|
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
|
||||||
|
} finally {
|
||||||
|
_setCctvCoverageStateForTest({ enabled: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
|
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
|
||||||
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
|
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '../../server/providers/cctv/cap.js';
|
} from '../../server/providers/cctv/cap.js';
|
||||||
import {
|
import {
|
||||||
CCTV_MAX_SOURCES_CEILING,
|
CCTV_MAX_SOURCES_CEILING,
|
||||||
|
CCTV_SOURCE_CACHE_MS,
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
DEFAULT_CCTV_MAX_SOURCES,
|
||||||
} from '../../server/providers/cctv/constants.js';
|
} from '../../server/providers/cctv/constants.js';
|
||||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||||
@@ -160,3 +161,224 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
|
|||||||
assert.equal(kept.available, 5);
|
assert.equal(kept.available, 5);
|
||||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
|
||||||
|
const envSources = (prefix, count = 3) =>
|
||||||
|
JSON.stringify(
|
||||||
|
Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: `${prefix}-${i}`,
|
||||||
|
name: `${prefix} ${i}`,
|
||||||
|
lat: 30 + i * 0.01,
|
||||||
|
lon: -97,
|
||||||
|
url: `https://example.test/${prefix}-${i}.jpg`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Let a background refresh that needs no timers run to completion. */
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
|
||||||
|
const restoreEnv = (saved) => {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in saved)) delete process.env[key];
|
||||||
|
}
|
||||||
|
Object.assign(process.env, saved);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
|
||||||
|
const saved = { ...process.env };
|
||||||
|
try {
|
||||||
|
// Live packs run alongside the env pack, so one slow upstream can hold
|
||||||
|
// the refresh open.
|
||||||
|
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||||
|
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||||
|
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||||
|
let now = 1_000_000;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
const hung = [];
|
||||||
|
let hang = false;
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
hang
|
||||||
|
? new Promise((_, reject) => {
|
||||||
|
hung.push(() => reject(new Error('upstream gave up')));
|
||||||
|
})
|
||||||
|
: Promise.reject(new Error('offline')),
|
||||||
|
);
|
||||||
|
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||||
|
assert.deepEqual(
|
||||||
|
(await getSources()).map((s) => s.id),
|
||||||
|
['old-0', 'old-1', 'old-2'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past the TTL, with every live pack's upstream hanging.
|
||||||
|
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||||
|
hang = true;
|
||||||
|
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||||
|
let timer;
|
||||||
|
const served = await Promise.race([
|
||||||
|
getSources(),
|
||||||
|
new Promise((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve('waited'), 200);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
clearTimeout(timer);
|
||||||
|
assert.notEqual(
|
||||||
|
served,
|
||||||
|
'waited',
|
||||||
|
'a stale request waited for the background refresh',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
served.map((s) => s.id),
|
||||||
|
['old-0', 'old-1', 'old-2'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Requests during the refresh share it rather than starting another.
|
||||||
|
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
await pause();
|
||||||
|
const inFlight = hung.length;
|
||||||
|
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
|
||||||
|
await getSources();
|
||||||
|
await getSources();
|
||||||
|
await pause();
|
||||||
|
assert.equal(hung.length, inFlight, 'only one refresh ran');
|
||||||
|
|
||||||
|
// When the slow pack gives up, the refreshed catalog replaces the old one.
|
||||||
|
hang = false;
|
||||||
|
for (const giveUp of hung) giveUp();
|
||||||
|
for (let i = 0; i < 50; i += 1) {
|
||||||
|
const ids = (await getSources()).map((s) => s.id);
|
||||||
|
if (ids[0] === 'new-0') break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
(await getSources()).map((s) => s.id),
|
||||||
|
['new-0', 'new-1', 'new-2'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restoreEnv(saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
|
||||||
|
const saved = { ...process.env };
|
||||||
|
const unhandled = [];
|
||||||
|
const onUnhandled = (reason) => unhandled.push(reason);
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
try {
|
||||||
|
process.env.CCTV_PREFER_AUSTIN = '0';
|
||||||
|
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||||
|
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
|
||||||
|
process.env.CCTV_MAX_SOURCES = '8';
|
||||||
|
let now = 1_000_000;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
// Nine cameras over a cap of eight make every refresh warn; throwing from
|
||||||
|
// that warning is how this test makes a refresh reject.
|
||||||
|
let failRefresh = false;
|
||||||
|
let failedRefreshes = 0;
|
||||||
|
const warnings = [];
|
||||||
|
t.mock.method(console, 'warn', (...args) => {
|
||||||
|
const text = args.join(' ');
|
||||||
|
if (failRefresh && text.includes('exceeds cap')) {
|
||||||
|
failedRefreshes += 1;
|
||||||
|
throw new Error('refresh broke');
|
||||||
|
}
|
||||||
|
warnings.push(text);
|
||||||
|
});
|
||||||
|
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||||
|
const old = await getSources();
|
||||||
|
assert.equal(old.length, 8);
|
||||||
|
|
||||||
|
// Past the TTL the refresh throws: the old catalog is still served.
|
||||||
|
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||||
|
failRefresh = true;
|
||||||
|
assert.equal(await getSources(), old);
|
||||||
|
await settle();
|
||||||
|
assert.equal(failedRefreshes, 1);
|
||||||
|
assert.equal(
|
||||||
|
warnings.filter((w) => w.includes('background catalog refresh failed'))
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The failure is not retried on every request until the next TTL.
|
||||||
|
assert.equal(await getSources(), old);
|
||||||
|
assert.equal(await getSources(), old);
|
||||||
|
await settle();
|
||||||
|
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
|
||||||
|
|
||||||
|
// After it, a refresh that works replaces the catalog. With no live packs
|
||||||
|
// the refresh finishes before the request that started it returns.
|
||||||
|
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||||
|
failRefresh = false;
|
||||||
|
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||||
|
await getSources();
|
||||||
|
await settle();
|
||||||
|
const fresh = await getSources();
|
||||||
|
assert.deepEqual(
|
||||||
|
fresh.map((s) => s.id),
|
||||||
|
['new-0', 'new-1', 'new-2'],
|
||||||
|
);
|
||||||
|
// Trim notes stay with the list they describe, so a request served the
|
||||||
|
// old list during the refresh never reports the new list's notes.
|
||||||
|
assert.deepEqual(
|
||||||
|
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
|
||||||
|
[['env', 9, 8]],
|
||||||
|
);
|
||||||
|
assert.deepEqual(getSources.trimmedPacks(fresh), []);
|
||||||
|
assert.deepEqual(getSources.trimmedPacks(), []);
|
||||||
|
assert.deepEqual(unhandled, []);
|
||||||
|
} finally {
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
restoreEnv(saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
|
||||||
|
const saved = { ...process.env };
|
||||||
|
try {
|
||||||
|
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||||
|
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||||
|
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||||
|
let now = 1_000_000;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
const warnings = [];
|
||||||
|
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||||
|
let fetches = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', () => {
|
||||||
|
fetches += 1;
|
||||||
|
return Promise.reject(new Error('offline'));
|
||||||
|
});
|
||||||
|
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||||
|
const old = await getSources();
|
||||||
|
|
||||||
|
// Past the TTL every pack comes back empty, as in an upstream outage.
|
||||||
|
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||||
|
process.env.CCTV_SOURCES_JSON = '[]';
|
||||||
|
assert.equal(await getSources(), old);
|
||||||
|
for (let i = 0; i < 50; i += 1) {
|
||||||
|
if (warnings.some((w) => w.includes('source refresh returned empty')))
|
||||||
|
break;
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
warnings.some((w) => w.includes('serving 3 stale cameras')),
|
||||||
|
'the empty refresh kept the old catalog',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Until the next TTL, requests are served without touching any upstream.
|
||||||
|
const fetchesAfterRefresh = fetches;
|
||||||
|
assert.equal(await getSources(), old);
|
||||||
|
assert.equal(await getSources(), old);
|
||||||
|
await settle();
|
||||||
|
assert.equal(
|
||||||
|
fetches,
|
||||||
|
fetchesAfterRefresh,
|
||||||
|
'an empty refresh reran at once',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restoreEnv(saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
TARKTEE_IMAGES_URL,
|
||||||
|
TARKTEE_LOCATIONS_URL,
|
||||||
|
TARKTEE_MAX_CATALOG_BYTES,
|
||||||
|
} from '../../server/providers/cctv/constants.js';
|
||||||
import {
|
import {
|
||||||
loadTallinnSourcesFromCatalog,
|
loadTallinnSourcesFromCatalog,
|
||||||
|
loadTarkteeSourcesFromDatex,
|
||||||
parseTarkteeDatexImages,
|
parseTarkteeDatexImages,
|
||||||
parseTarkteeDatexLocations,
|
parseTarkteeDatexLocations,
|
||||||
} from '../../server/providers/cctv/sources.js';
|
} from '../../server/providers/cctv/sources.js';
|
||||||
|
|
||||||
|
/** Silence the loaders' progress and failure logging. */
|
||||||
|
function quiet(t) {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||||
<payloadPublication>
|
<payloadPublication>
|
||||||
@@ -84,3 +96,193 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
|||||||
const images = parseTarkteeDatexImages(xml);
|
const images = parseTarkteeDatexImages(xml);
|
||||||
assert.equal(images.size, 0);
|
assert.equal(images.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
|
||||||
|
quiet(t);
|
||||||
|
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||||
|
<payloadPublication>
|
||||||
|
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
|
||||||
|
<predefinedLocation id="loc-1" version="0">
|
||||||
|
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
|
||||||
|
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
|
||||||
|
</predefinedLocation>
|
||||||
|
<predefinedLocation id="loc-2" version="0">
|
||||||
|
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
|
||||||
|
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
|
||||||
|
</predefinedLocation>
|
||||||
|
</predefinedLocationContainer>
|
||||||
|
</payloadPublication>
|
||||||
|
</d2LogicalModel>`;
|
||||||
|
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||||
|
<payloadPublication>
|
||||||
|
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||||
|
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||||
|
</payloadPublication>
|
||||||
|
</d2LogicalModel>`;
|
||||||
|
|
||||||
|
const seen = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||||
|
seen.push([String(url), init?.redirect]);
|
||||||
|
const body =
|
||||||
|
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/xml' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cameras = await loadTarkteeSourcesFromDatex();
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
[TARKTEE_LOCATIONS_URL, 'manual'],
|
||||||
|
[TARKTEE_IMAGES_URL, 'manual'],
|
||||||
|
]);
|
||||||
|
assert.equal(cameras.length, 1);
|
||||||
|
assert.equal(cameras[0].id, 'ee-tarktee-42');
|
||||||
|
assert.equal(cameras[0].name, 'Tallinn Ringtee');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
|
||||||
|
quiet(t);
|
||||||
|
const cancelled = { locations: false, images: false };
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (isLocations) cancelled.locations = true;
|
||||||
|
else cancelled.images = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body, {
|
||||||
|
status: isLocations ? 302 : 200,
|
||||||
|
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||||
|
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
|
||||||
|
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
|
||||||
|
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
|
||||||
|
for (const delayedSibling of [false, true]) {
|
||||||
|
await t.test(
|
||||||
|
`${failedUrl}, delayed sibling: ${delayedSibling}`,
|
||||||
|
async (t) => {
|
||||||
|
quiet(t);
|
||||||
|
let cancelled = false;
|
||||||
|
const response = new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
if (String(url) === failedUrl) throw new TypeError('fetch failed');
|
||||||
|
if (delayedSibling) await new Promise(setImmediate);
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||||
|
assert.equal(
|
||||||
|
cancelled,
|
||||||
|
true,
|
||||||
|
'fulfilled sibling body is cancelled',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await response.body.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
|
||||||
|
quiet(t);
|
||||||
|
let siblingCancelled = false;
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||||
|
if (isLocations) {
|
||||||
|
return new Response('', { status: 503 });
|
||||||
|
}
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
siblingCancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body, { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||||
|
assert.equal(
|
||||||
|
siblingCancelled,
|
||||||
|
true,
|
||||||
|
'sibling 200 stream is cancelled on 503',
|
||||||
|
);
|
||||||
|
|
||||||
|
let overflowSiblingCancelled = false;
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||||
|
if (isLocations) {
|
||||||
|
const padded =
|
||||||
|
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
|
||||||
|
return new Response(padded, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Length': String(padded.length) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
overflowSiblingCancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body, { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||||
|
assert.equal(
|
||||||
|
overflowSiblingCancelled,
|
||||||
|
true,
|
||||||
|
'sibling stream is cancelled when the other feed exceeds the byte cap',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||||
|
const timeouts = [];
|
||||||
|
const timeout = AbortSignal.timeout;
|
||||||
|
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||||
|
timeouts.push(ms);
|
||||||
|
return timeout.call(AbortSignal, ms);
|
||||||
|
});
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
'fetch',
|
||||||
|
async (url) =>
|
||||||
|
new Response(
|
||||||
|
String(url).includes('roadCameraLocations')
|
||||||
|
? SAMPLE_LOCATIONS_XML
|
||||||
|
: SAMPLE_IMAGES_XML,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cameras = await loadTarkteeSourcesFromDatex();
|
||||||
|
assert.deepEqual(timeouts, [30_000, 30_000]);
|
||||||
|
assert.equal(cameras.length, 1);
|
||||||
|
});
|
||||||
@@ -2,11 +2,24 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyVegvesenGroundHeights,
|
||||||
|
clearKartverketHeightCache,
|
||||||
|
fetchKartverketHeights,
|
||||||
|
kartverketBackoffMs,
|
||||||
loadVegvesenSourcesFromOpenData,
|
loadVegvesenSourcesFromOpenData,
|
||||||
vegvesenCameraToSource,
|
vegvesenCameraToSource,
|
||||||
} from '../../server/providers/cctv/sources.js';
|
} from '../../server/providers/cctv/sources.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_VEGVESEN_CCTV_URL,
|
DEFAULT_VEGVESEN_CCTV_URL,
|
||||||
|
KARTVERKET_HEIGHT_URL,
|
||||||
|
KARTVERKET_CATALOG_WAIT_MS,
|
||||||
|
KARTVERKET_CONCURRENCY,
|
||||||
|
KARTVERKET_FAILURE_BACKOFF_MS,
|
||||||
|
KARTVERKET_MAX_POINTS,
|
||||||
|
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||||
|
KARTVERKET_NULL_TTL_MS,
|
||||||
|
KARTVERKET_USER_AGENT,
|
||||||
|
CCTV_SOURCE_CACHE_MS,
|
||||||
VEGVESEN_IMAGE_ORIGIN,
|
VEGVESEN_IMAGE_ORIGIN,
|
||||||
} from '../../server/providers/cctv/constants.js';
|
} from '../../server/providers/cctv/constants.js';
|
||||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||||
@@ -104,6 +117,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||||
|
clearKartverketHeightCache();
|
||||||
|
t.after(clearKartverketHeightCache);
|
||||||
t.mock.method(console, 'log', () => {});
|
t.mock.method(console, 'log', () => {});
|
||||||
t.mock.method(console, 'warn', () => {});
|
t.mock.method(console, 'warn', () => {});
|
||||||
const requested = [];
|
const requested = [];
|
||||||
@@ -127,7 +142,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
// The catalog first; the rest is the Kartverket height lookup.
|
||||||
|
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
|
||||||
|
// Two unique positions: one Kartverket request.
|
||||||
|
assert.equal(requested.length, 2);
|
||||||
|
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
|
||||||
// Nearest-to-anchor first: Oslo leads.
|
// Nearest-to-anchor first: Oslo leads.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
cameras.map((c) => c.id),
|
cameras.map((c) => c.id),
|
||||||
@@ -200,3 +219,604 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Answer a Kartverket request the way the live API does: echo every point,
|
||||||
|
* with the height `heightFor(x, y)` returns (null = outside coverage). */
|
||||||
|
const kartverketResponse = (url, heightFor) => {
|
||||||
|
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||||
|
return Response.json({
|
||||||
|
koordsys: 4258,
|
||||||
|
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cameras at distinct Norwegian positions. */
|
||||||
|
const cameraAt = (i) => ({
|
||||||
|
id: `no-vegvesen-${i}`,
|
||||||
|
lat: 60 + i * 0.001,
|
||||||
|
lon: 7 + i * 0.001,
|
||||||
|
groundElevationM: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
const withHeightsEnv = (t) => {
|
||||||
|
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||||
|
delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||||
|
clearKartverketHeightCache();
|
||||||
|
t.after(() => {
|
||||||
|
clearKartverketHeightCache();
|
||||||
|
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||||
|
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
|
||||||
|
});
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentPoints = (url) =>
|
||||||
|
JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||||
|
|
||||||
|
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
const requested = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||||
|
requested.push(String(url));
|
||||||
|
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
|
||||||
|
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
|
||||||
|
// Redirects are refused, so a request can never leave the fixed host.
|
||||||
|
assert.equal(init.redirect, 'error');
|
||||||
|
// Reply in reverse order: matching must not depend on list position.
|
||||||
|
const response = kartverketResponse(url, (x) =>
|
||||||
|
x > 7.04 ? null : Math.round((x - 7) * 10000),
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
body.punkter.reverse();
|
||||||
|
return Response.json(body);
|
||||||
|
});
|
||||||
|
const { heights, failure } = await fetchKartverketHeights(
|
||||||
|
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
|
||||||
|
);
|
||||||
|
assert.equal(failure, null);
|
||||||
|
// One pass, no retries: 20 + 20 + 10.
|
||||||
|
assert.deepEqual(
|
||||||
|
requested.map((url) => sentPoints(url).length),
|
||||||
|
[KARTVERKET_MAX_POINTS, 20, 10],
|
||||||
|
);
|
||||||
|
for (const url of requested) {
|
||||||
|
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
|
||||||
|
}
|
||||||
|
assert.equal(heights.size, 50);
|
||||||
|
assert.equal(heights.get('7.010000,60.010000'), 100);
|
||||||
|
// Past x = 7.04 the API has no height: recorded as null, never as 0.
|
||||||
|
assert.equal(heights.get('7.049000,60.049000'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the points a request asked about are recorded', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
const points = sentPoints(url);
|
||||||
|
return Response.json({
|
||||||
|
koordsys: 4258,
|
||||||
|
punkter: [
|
||||||
|
...points.map(([x, y]) => ({ x, y, z: 10 })),
|
||||||
|
{ x: 7.5, y: 60.5, z: 999 }, // never asked
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
|
||||||
|
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('either water marker counts as the surface', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
const [a, b, c] = sentPoints(url);
|
||||||
|
return Response.json({
|
||||||
|
koordsys: 4258,
|
||||||
|
punkter: [
|
||||||
|
{
|
||||||
|
x: a[0],
|
||||||
|
y: a[1],
|
||||||
|
z: -547.1,
|
||||||
|
datakilde: 'dybdekurver',
|
||||||
|
terreng: null,
|
||||||
|
},
|
||||||
|
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
|
||||||
|
{
|
||||||
|
x: c[0],
|
||||||
|
y: c[1],
|
||||||
|
z: -0.2,
|
||||||
|
datakilde: 'dtm1',
|
||||||
|
terreng: 'ÅpentOmråde',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const { heights } = await fetchKartverketHeights([
|
||||||
|
cameraAt(0),
|
||||||
|
cameraAt(1),
|
||||||
|
cameraAt(2),
|
||||||
|
]);
|
||||||
|
assert.equal(heights.get('7.000000,60.000000'), 0);
|
||||||
|
assert.equal(heights.get('7.001000,60.001000'), 0);
|
||||||
|
// A quay just below the datum on land data stays as measured.
|
||||||
|
assert.equal(heights.get('7.002000,60.002000'), -0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
const requested = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
requested.push(String(url));
|
||||||
|
const zs = [-20, -20.5, 2500, 2500.5];
|
||||||
|
const points = sentPoints(url);
|
||||||
|
return Response.json({
|
||||||
|
koordsys: 4258,
|
||||||
|
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const { heights } = await fetchKartverketHeights([
|
||||||
|
cameraAt(0),
|
||||||
|
cameraAt(1),
|
||||||
|
cameraAt(2),
|
||||||
|
cameraAt(3),
|
||||||
|
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
|
||||||
|
]);
|
||||||
|
assert.equal(requested.length, 1);
|
||||||
|
assert.equal(sentPoints(requested[0]).length, 4);
|
||||||
|
assert.deepEqual(
|
||||||
|
[0, 1, 2, 3].map((i) =>
|
||||||
|
heights.get(
|
||||||
|
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[-20, null, 2500, null],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no more than eight requests are in flight', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let inFlight = 0;
|
||||||
|
let peak = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
inFlight += 1;
|
||||||
|
peak = Math.max(peak, inFlight);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
inFlight -= 1;
|
||||||
|
return kartverketResponse(url, () => 1);
|
||||||
|
});
|
||||||
|
const { heights } = await fetchKartverketHeights(
|
||||||
|
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||||
|
);
|
||||||
|
assert.equal(heights.size, 400);
|
||||||
|
assert.equal(peak, KARTVERKET_CONCURRENCY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let calls = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
calls += 1;
|
||||||
|
return new Response('busy', {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': '120' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const { heights, failure } = await fetchKartverketHeights(
|
||||||
|
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||||
|
);
|
||||||
|
// The first wave is already in flight when the first failure lands; nothing
|
||||||
|
// after it starts, and no point is resent.
|
||||||
|
assert.equal(calls, KARTVERKET_CONCURRENCY);
|
||||||
|
assert.equal(heights.size, 0);
|
||||||
|
assert.deepEqual(failure, {
|
||||||
|
status: 429,
|
||||||
|
retryAfter: '120',
|
||||||
|
deadline: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A fetch that never answers, and rejects only when its signal aborts. */
|
||||||
|
const hangingFetch = (url, init) => {
|
||||||
|
assert.ok(init.signal instanceof AbortSignal);
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
init.signal.addEventListener('abort', () => reject(init.signal.reason));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('the lookup deadline bounds a hanging service', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||||
|
const started = Date.now();
|
||||||
|
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||||
|
timeoutMs: 30,
|
||||||
|
});
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
|
||||||
|
assert.equal(heights.size, 0);
|
||||||
|
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one hanging request times out on its own, as a failing service', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||||
|
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
requestTimeoutMs: 20,
|
||||||
|
});
|
||||||
|
assert.deepEqual(failure, {
|
||||||
|
status: null,
|
||||||
|
retryAfter: null,
|
||||||
|
deadline: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let calls = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||||
|
calls += 1;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => resolve(kartverketResponse(url, () => 1)),
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
init.signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(init.signal.reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
|
||||||
|
const { heights, failure } = await fetchKartverketHeights(
|
||||||
|
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
|
||||||
|
cameraAt(i),
|
||||||
|
),
|
||||||
|
{ timeoutMs: 100 },
|
||||||
|
);
|
||||||
|
assert.equal(failure?.deadline, true);
|
||||||
|
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
|
||||||
|
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
const answers = {
|
||||||
|
503: () => new Response('down', { status: 503 }),
|
||||||
|
'not json': () =>
|
||||||
|
new Response('<html>maintenance</html>', {
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
}),
|
||||||
|
oversized: () =>
|
||||||
|
Response.json({
|
||||||
|
punkter: [],
|
||||||
|
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
for (const [label, answer] of Object.entries(answers)) {
|
||||||
|
clearKartverketHeightCache();
|
||||||
|
let calls = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
calls += 1;
|
||||||
|
return answer();
|
||||||
|
});
|
||||||
|
const { heights, failure } = await fetchKartverketHeights(
|
||||||
|
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||||
|
);
|
||||||
|
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
|
||||||
|
assert.equal(heights.size, 0, label);
|
||||||
|
assert.equal(failure?.deadline, false, label);
|
||||||
|
assert.equal(failure?.status, label === '503' ? 503 : null, label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||||
|
sentPoints(url)[0][0] === 7
|
||||||
|
? new Response('bad point', { status: 400 })
|
||||||
|
: kartverketResponse(url, () => 42),
|
||||||
|
);
|
||||||
|
const { heights, failure } = await fetchKartverketHeights(
|
||||||
|
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
|
||||||
|
);
|
||||||
|
assert.equal(failure, null);
|
||||||
|
assert.equal(heights.size, 40);
|
||||||
|
assert.equal(heights.get('7.000000,60.000000'), null);
|
||||||
|
assert.equal(heights.get('7.020000,60.020000'), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point a 200 answer leaves out is cached as no height', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
const points = sentPoints(url).slice(1); // drop the first point
|
||||||
|
return Response.json({
|
||||||
|
koordsys: 4258,
|
||||||
|
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const cameras = [cameraAt(0), cameraAt(1)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||||
|
assert.equal(cameras[0].groundElevationM, 150);
|
||||||
|
// The next refresh asks nothing: the left-out point is a cached miss.
|
||||||
|
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
|
||||||
|
const now = Date.parse('2026-10-06T12:00:00Z');
|
||||||
|
assert.equal(kartverketBackoffMs('120', now), 120_000);
|
||||||
|
assert.equal(kartverketBackoffMs('5', now), 60_000);
|
||||||
|
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
|
||||||
|
assert.equal(
|
||||||
|
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||||
|
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heights are cached, and misses are not asked again until they expire', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let clock = 1_000_000;
|
||||||
|
const now = () => clock;
|
||||||
|
const asked = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
asked.push(sentPoints(url).length);
|
||||||
|
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
|
||||||
|
});
|
||||||
|
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
cameras.map((c) => c.groundElevationM),
|
||||||
|
[1043.3, 1043.3, 150],
|
||||||
|
);
|
||||||
|
// The next refresh asks nothing: heights and the miss are both cached.
|
||||||
|
assert.equal(
|
||||||
|
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
|
||||||
|
now,
|
||||||
|
}),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert.deepEqual(asked, [3]);
|
||||||
|
// Once the miss expires, only that point is asked again.
|
||||||
|
clock += KARTVERKET_NULL_TTL_MS + 1;
|
||||||
|
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
|
||||||
|
assert.deepEqual(asked, [3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after a failure no lookup starts until the backoff ends', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let clock = 1_000_000;
|
||||||
|
const now = () => clock;
|
||||||
|
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
});
|
||||||
|
const cameras = [cameraAt(0)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
|
||||||
|
assert.equal(cameras[0].groundElevationM, 150);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
|
||||||
|
// The default backoff outlasts the catalog refresh, so the next refresh
|
||||||
|
// and every one inside the backoff cost nothing.
|
||||||
|
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
|
||||||
|
clock += CCTV_SOURCE_CACHE_MS;
|
||||||
|
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||||
|
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
|
||||||
|
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
|
||||||
|
// After it, the failed point is asked again.
|
||||||
|
clock += 2;
|
||||||
|
fetchMock.mock.mockImplementation(async (url) =>
|
||||||
|
kartverketResponse(url, () => 321),
|
||||||
|
);
|
||||||
|
const later = [cameraAt(0)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
|
||||||
|
assert.equal(later[0].groundElevationM, 321);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 429 backs off for its Retry-After, not the default', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let clock = 1_000_000;
|
||||||
|
const now = () => clock;
|
||||||
|
const retryAfterS = 50 * 60;
|
||||||
|
const fetchMock = t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
'fetch',
|
||||||
|
async () =>
|
||||||
|
new Response('busy', {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(retryAfterS) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
|
||||||
|
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
|
||||||
|
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let clock = 1_000_000;
|
||||||
|
const now = () => clock;
|
||||||
|
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||||
|
// The lookup runs out of time; that is not a failing service.
|
||||||
|
assert.equal(
|
||||||
|
await applyVegvesenGroundHeights([cameraAt(0)], {
|
||||||
|
now,
|
||||||
|
timeoutMs: 10,
|
||||||
|
waitMs: 1000,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||||
|
kartverketResponse(url, () => 64),
|
||||||
|
);
|
||||||
|
clock += CCTV_SOURCE_CACHE_MS;
|
||||||
|
const cameras = [cameraAt(0)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
|
||||||
|
assert.equal(cameras[0].groundElevationM, 64);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlapping refreshes share one lookup', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
await gate;
|
||||||
|
return kartverketResponse(url, () => 3);
|
||||||
|
});
|
||||||
|
await Promise.all([
|
||||||
|
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||||
|
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||||
|
]);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a warm cache does not wait for a few new cameras', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||||
|
kartverketResponse(url, () => 500),
|
||||||
|
);
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
await gate;
|
||||||
|
return kartverketResponse(url, () => 600);
|
||||||
|
});
|
||||||
|
const refresh = [...cameras, cameraAt(20)];
|
||||||
|
const started = Date.now();
|
||||||
|
assert.equal(
|
||||||
|
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
assert.ok(Date.now() - started < 1000);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
// The background lookup fills the cache for the next refresh.
|
||||||
|
release();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
const next = [...cameras, cameraAt(20)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(next), 21);
|
||||||
|
assert.equal(next[20].groundElevationM, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||||
|
kartverketResponse(url, () => 9),
|
||||||
|
);
|
||||||
|
const cameras = [
|
||||||
|
{ id: 'no-coordinates' },
|
||||||
|
null,
|
||||||
|
{ id: 'strings', lat: '60', lon: '7' },
|
||||||
|
cameraAt(0),
|
||||||
|
];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||||
|
assert.equal(cameras[3].groundElevationM, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lookup started before a cache clear cannot write after it', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
await gate;
|
||||||
|
return kartverketResponse(url, () => 111);
|
||||||
|
});
|
||||||
|
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
|
||||||
|
clearKartverketHeightCache();
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||||
|
kartverketResponse(url, () => 222),
|
||||||
|
);
|
||||||
|
const fresh = [cameraAt(0)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
|
||||||
|
assert.equal(fresh[0].groundElevationM, 222);
|
||||||
|
release();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
const later = [cameraAt(0)];
|
||||||
|
await applyVegvesenGroundHeights(later, { waitMs: 0 });
|
||||||
|
assert.equal(later[0].groundElevationM, 222);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||||
|
kartverketResponse(url, () => 5),
|
||||||
|
);
|
||||||
|
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
|
||||||
|
const cameras = [cameraAt(0)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
|
||||||
|
assert.equal(cameras[0].groundElevationM, 150);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
await gate;
|
||||||
|
return kartverketResponse(url, () => 812);
|
||||||
|
});
|
||||||
|
const first = [cameraAt(0)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
|
||||||
|
assert.equal(first[0].groundElevationM, 150);
|
||||||
|
release();
|
||||||
|
// The lookup kept running and filled the cache for the next refresh.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
const next = [cameraAt(0)];
|
||||||
|
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
|
||||||
|
assert.equal(next[0].groundElevationM, 812);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||||
|
return kartverketResponse(url, () => 987.6);
|
||||||
|
}
|
||||||
|
return Response.json({ type: 'FeatureCollection', features: [feature()] });
|
||||||
|
});
|
||||||
|
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||||
|
assert.deepEqual(
|
||||||
|
cameras.map((c) => [c.id, c.groundElevationM]),
|
||||||
|
[['no-vegvesen-3000063_1', 987.6]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the loader budgets the height wait from its own start', async (t) => {
|
||||||
|
withHeightsEnv(t);
|
||||||
|
const realNow = Date.now.bind(Date);
|
||||||
|
let offset = 0;
|
||||||
|
t.mock.method(Date, 'now', () => realNow() + offset);
|
||||||
|
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||||
|
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||||
|
return hangingFetch(url, init);
|
||||||
|
}
|
||||||
|
// The catalog fetch uses up all but 10 ms of the lane's budget.
|
||||||
|
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({ type: 'FeatureCollection', features: [feature()] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const started = performance.now();
|
||||||
|
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||||
|
assert.ok(performance.now() - started < 1000);
|
||||||
|
assert.equal(cameras[0].groundElevationM, 150);
|
||||||
|
});
|
||||||
+20
-1
@@ -294,7 +294,10 @@ export const DATA_CREDITS = [
|
|||||||
html:
|
html:
|
||||||
'Road cameras (Norway): contains data under the ' +
|
'Road cameras (Norway): contains data under the ' +
|
||||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||||
'distributed by Statens vegvesen',
|
'distributed by Statens vegvesen · camera ground heights © ' +
|
||||||
|
'<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>); ' +
|
||||||
|
'modified: heights over water set to 0 m and rounded to 0.1 m',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'nyc-cctv',
|
key: 'nyc-cctv',
|
||||||
@@ -309,6 +312,22 @@ export const DATA_CREDITS = [
|
|||||||
'Traffic cameras (Toronto): contains information licensed under the ' +
|
'Traffic cameras (Toronto): contains information licensed under the ' +
|
||||||
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'mapillary',
|
||||||
|
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'google-street-view',
|
||||||
|
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kartaview',
|
||||||
|
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'panoramax',
|
||||||
|
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'gbfs',
|
key: 'gbfs',
|
||||||
html:
|
html:
|
||||||
|
|||||||
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
|||||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||||
assert.match(credit.html, /observe posted signage/);
|
assert.match(credit.html, /observe posted signage/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||||
|
for (const key of [
|
||||||
|
'mapillary',
|
||||||
|
'google-street-view',
|
||||||
|
'kartaview',
|
||||||
|
'panoramax',
|
||||||
|
])
|
||||||
|
assert.ok(
|
||||||
|
DATA_CREDITS.some((entry) => entry.key === key),
|
||||||
|
`${key} has no credit`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -33,6 +33,18 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
|||||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
|
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Signed distance along the great circle from the origin toward the destination. */
|
||||||
|
function alongTrackKm(position, origin, destination) {
|
||||||
|
const distance = greatCircleKm(origin, position) / EARTH_RADIUS_KM;
|
||||||
|
const direction =
|
||||||
|
bearingRad(origin.lat, origin.lon, position.lat, position.lon) -
|
||||||
|
bearingRad(origin.lat, origin.lon, destination.lat, destination.lon);
|
||||||
|
return (
|
||||||
|
Math.atan2(Math.sin(distance) * Math.cos(direction), Math.cos(distance)) *
|
||||||
|
EARTH_RADIUS_KM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const NEAR_ENDPOINT_KM = 130;
|
const NEAR_ENDPOINT_KM = 130;
|
||||||
const CROSS_TRACK_KM = 200;
|
const CROSS_TRACK_KM = 200;
|
||||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||||
@@ -66,6 +78,18 @@ export function routePlausible({
|
|||||||
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
||||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||||
if (!geomOk && haveO && haveD) {
|
if (!geomOk && haveO && haveD) {
|
||||||
|
const routeKm = greatCircleKm(origin, destination);
|
||||||
|
const alongKm = alongTrackKm(position, origin, destination);
|
||||||
|
// atan2 returns the principal arc in [-πR, πR]. Near an antipodal
|
||||||
|
// destination, its allowed forward extension can cross that branch cut.
|
||||||
|
const segmentAlongKm =
|
||||||
|
alongKm < -CROSS_TRACK_KM
|
||||||
|
? alongKm + 2 * Math.PI * EARTH_RADIUS_KM
|
||||||
|
: alongKm;
|
||||||
|
// Coincident or antipodal endpoints do not define one minor arc. Keep the
|
||||||
|
// existing loose check when the segment cannot be judged reliably.
|
||||||
|
const segmentKnown =
|
||||||
|
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
|
||||||
geomOk =
|
geomOk =
|
||||||
Math.abs(
|
Math.abs(
|
||||||
crossTrackKm(
|
crossTrackKm(
|
||||||
@@ -76,7 +100,10 @@ export function routePlausible({
|
|||||||
destination.lat,
|
destination.lat,
|
||||||
destination.lon,
|
destination.lon,
|
||||||
),
|
),
|
||||||
) < CROSS_TRACK_KM;
|
) < CROSS_TRACK_KM &&
|
||||||
|
(!segmentKnown ||
|
||||||
|
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||||
|
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||||
} else if (!geomOk) {
|
} else if (!geomOk) {
|
||||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { routePlausible } from './routePlausible.js';
|
||||||
|
import { createTracking } from '../layers/flights/tracking.js';
|
||||||
|
|
||||||
|
const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' };
|
||||||
|
const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' };
|
||||||
|
const beyond = { lat: -35.818227816411174, lon: -66.16647396367095 };
|
||||||
|
const behind = { lat: 35.818227816411174, lon: 113.83352603632906 };
|
||||||
|
const antipode = { lat: -SFO.lat, lon: SFO.lon + 180 };
|
||||||
|
const midpoint = { lat: 35.8, lon: -120.4 };
|
||||||
|
|
||||||
|
const plausible = (position, origin = SFO, destination = LAX) =>
|
||||||
|
routePlausible({
|
||||||
|
latDeg: position.lat,
|
||||||
|
lonDeg: position.lon,
|
||||||
|
altitudeM: 10000,
|
||||||
|
verticalRateMps: 0,
|
||||||
|
origin,
|
||||||
|
destination,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Independent spherical oracle: native unit-vector angular distances, without
|
||||||
|
// the production bearing/cross-track/along-track calculations.
|
||||||
|
const unit = ({ lat, lon }) => {
|
||||||
|
const latitude = Cesium.Math.toRadians(lat);
|
||||||
|
const longitude = Cesium.Math.toRadians(lon);
|
||||||
|
return new Cesium.Cartesian3(
|
||||||
|
Math.cos(latitude) * Math.cos(longitude),
|
||||||
|
Math.cos(latitude) * Math.sin(longitude),
|
||||||
|
Math.sin(latitude),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const angle = (a, b) => Cesium.Cartesian3.angleBetween(unit(a), unit(b));
|
||||||
|
const excess = (position, origin, destination) =>
|
||||||
|
angle(origin, position) +
|
||||||
|
angle(position, destination) -
|
||||||
|
angle(origin, destination);
|
||||||
|
|
||||||
|
test('a short airport route cannot describe a plane thousands of kilometres beyond its destination', () => {
|
||||||
|
assert.ok(excess(beyond, SFO, LAX) > 2.9);
|
||||||
|
assert.equal(plausible(beyond), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backwards and antipodal extensions are not the scheduled airport leg', () => {
|
||||||
|
for (const position of [behind, antipode]) {
|
||||||
|
assert.ok(excess(position, SFO, LAX) > 3);
|
||||||
|
assert.equal(plausible(position), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary route offsets and generous endpoint extensions stay plausible', () => {
|
||||||
|
assert.ok(excess(midpoint, SFO, LAX) < 0.001);
|
||||||
|
for (const position of [midpoint, SFO, LAX, { lat: 36.5, lon: -119.5 }]) {
|
||||||
|
assert.equal(plausible(position), true);
|
||||||
|
}
|
||||||
|
// ~167 km past either endpoint is outside the 130 km endpoint circle,
|
||||||
|
// but remains inside the 200 km coarse-route tolerance.
|
||||||
|
const origin = { lat: 0, lon: 0 };
|
||||||
|
const destination = { lat: 0, lon: 10 };
|
||||||
|
assert.equal(plausible({ lat: 0, lon: -1.5 }, origin, destination), true);
|
||||||
|
assert.equal(plausible({ lat: 0, lon: 11.5 }, origin, destination), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the generous endpoint tolerance still excludes a clearly different leg', () => {
|
||||||
|
const origin = { lat: 0, lon: 0 };
|
||||||
|
const destination = { lat: 0, lon: 10 };
|
||||||
|
assert.equal(plausible({ lat: 0, lon: -2.5 }, origin, destination), false);
|
||||||
|
assert.equal(plausible({ lat: 0, lon: 12.5 }, origin, destination), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minor-arc admission works across the antimeridian and near the pole', () => {
|
||||||
|
const dateline = [
|
||||||
|
{ lat: 10, lon: 170 },
|
||||||
|
{ lat: 10, lon: -170 },
|
||||||
|
];
|
||||||
|
assert.equal(plausible({ lat: 10.15, lon: 180 }, ...dateline), true);
|
||||||
|
assert.equal(plausible({ lat: -10.15, lon: 0 }, ...dateline), false);
|
||||||
|
const polar = [
|
||||||
|
{ lat: 80, lon: -60 },
|
||||||
|
{ lat: 80, lon: 60 },
|
||||||
|
];
|
||||||
|
const top = { lat: 84.96163122670251, lon: 0 };
|
||||||
|
assert.ok(excess(top, ...polar) < 1e-8);
|
||||||
|
assert.equal(plausible(top, ...polar), true);
|
||||||
|
assert.equal(plausible({ lat: -top.lat, lon: 180 }, ...polar), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the forward tolerance survives the signed-angle branch cut in both directions', () => {
|
||||||
|
const origin = { lat: 0, lon: 0 };
|
||||||
|
for (const direction of [-1, 1]) {
|
||||||
|
const destination = { lat: 0, lon: direction * 179.9 };
|
||||||
|
const within = { lat: 0, lon: direction * -178.8 };
|
||||||
|
const endpointGapKm = angle(destination, within) * 6371.0088;
|
||||||
|
assert.ok(endpointGapKm > 130 && endpointGapKm < 200);
|
||||||
|
assert.equal(plausible(within, origin, destination), true);
|
||||||
|
assert.equal(
|
||||||
|
plausible({ lat: 0, lon: direction * -178 }, origin, destination),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unjudgeable endpoint geometry and missing coordinates keep the loose contract', () => {
|
||||||
|
assert.equal(
|
||||||
|
plausible({ lat: 0, lon: -90 }, { lat: 0, lon: 0 }, { lat: 0, lon: 180 }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
plausible({ lat: 45, lon: 0 }, { lat: 0, lon: 0 }, { lat: 0, lon: 0 }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(plausible(beyond, SFO, null), true);
|
||||||
|
assert.equal(plausible(beyond, null, null), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => {
|
||||||
|
const collection = new Cesium.BillboardCollection();
|
||||||
|
t.after(() => collection.destroy());
|
||||||
|
const flightState = {
|
||||||
|
records: { data: new Map(), missingPolls: new Map() },
|
||||||
|
feed: { _backoff: false },
|
||||||
|
_billboards: new Map(),
|
||||||
|
_scratchCarto: new Cesium.Cartographic(),
|
||||||
|
};
|
||||||
|
// Only application host services are supplied. Route admission, WGS84
|
||||||
|
// billboard conversion and tracked-label formatting are the actual owners.
|
||||||
|
const services = Object.fromEntries(
|
||||||
|
[
|
||||||
|
'context',
|
||||||
|
'trails',
|
||||||
|
'geoid',
|
||||||
|
'groundFloor',
|
||||||
|
'focus',
|
||||||
|
'militaryRegistry',
|
||||||
|
'readout',
|
||||||
|
'camera',
|
||||||
|
'picking',
|
||||||
|
].map((name) => [name, {}]),
|
||||||
|
);
|
||||||
|
services.aircraftPresentation = { isTr3b: () => false };
|
||||||
|
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||||
|
const tracking = createTracking({ flightState, services, parts });
|
||||||
|
for (const [position, expected] of [
|
||||||
|
[beyond, false],
|
||||||
|
[midpoint, true],
|
||||||
|
]) {
|
||||||
|
const id = 'abc001';
|
||||||
|
const route = { origin: SFO, destination: LAX };
|
||||||
|
flightState.records.data.set(id, {
|
||||||
|
callsign: 'OWNED001',
|
||||||
|
altitude: 10000,
|
||||||
|
verticalRate: 0,
|
||||||
|
route,
|
||||||
|
});
|
||||||
|
flightState._billboards.set(
|
||||||
|
id,
|
||||||
|
collection.add({
|
||||||
|
id,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(
|
||||||
|
position.lon,
|
||||||
|
position.lat,
|
||||||
|
10000,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
tracking._trackedLabelText(id).includes('SFO → LAX'),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert.equal(tracking._routeIsPlausible(id, route), expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { localProviderPlugins } from '../server/providers/local.js';
|
||||||
|
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
|
||||||
|
import {
|
||||||
|
KEY_SETUP_KEYS,
|
||||||
|
keySetupStatus,
|
||||||
|
validateKeySetupUpdates,
|
||||||
|
upsertDotenvValues,
|
||||||
|
} from './keySetupCore.mjs';
|
||||||
|
|
||||||
|
test('local hosts opt into Gemini explicitly and retain the default plugin order', (t) => {
|
||||||
|
t.mock.method(globalThis, 'fetch', () => {
|
||||||
|
throw Error('construction must not fetch');
|
||||||
|
});
|
||||||
|
const defaults = localProviderPlugins().map((plugin) => plugin.name);
|
||||||
|
assert.ok(!defaults.includes('gemini-live-proxy'));
|
||||||
|
const enabled = localProviderPlugins({ gemini: {} }).map(
|
||||||
|
(plugin) => plugin.name,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
enabled.filter((name) => name === 'gemini-live-proxy').length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
enabled.filter((name) => name !== 'gemini-live-proxy'),
|
||||||
|
defaults,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||||
|
test(`opted-in ${hook} locks the standalone catalog into the Gemini token`, async () => {
|
||||||
|
const tools = standaloneVoiceTools();
|
||||||
|
const calls = [];
|
||||||
|
const plugin = localProviderPlugins({
|
||||||
|
gemini: {
|
||||||
|
tools,
|
||||||
|
resolveApiKey: () => 'fixture-key',
|
||||||
|
resolveModel: () => 'gemini-3.8-live',
|
||||||
|
resolveHost: () => '',
|
||||||
|
resolveAllowedHosts: () => '',
|
||||||
|
resolveRateLimit: () => '0',
|
||||||
|
fetchImpl: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return Response.json({ name: 'auth_tokens/fixture' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).find((entry) => entry.name === 'gemini-live-proxy');
|
||||||
|
let handler;
|
||||||
|
plugin[hook]({
|
||||||
|
middlewares: {
|
||||||
|
use(route, value) {
|
||||||
|
assert.equal(route, '/api/gemini/token');
|
||||||
|
handler = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const req = new PassThrough();
|
||||||
|
req.method = 'POST';
|
||||||
|
req.headers = {
|
||||||
|
host: 'localhost:5173',
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
};
|
||||||
|
req.socket = { remoteAddress: '127.0.0.1' };
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.setHeader = () => {};
|
||||||
|
res.end = (body) => {
|
||||||
|
res.body = JSON.parse(body);
|
||||||
|
res.writableEnded = true;
|
||||||
|
};
|
||||||
|
req.end('{}');
|
||||||
|
await handler(req, res);
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
const locked = JSON.parse(calls[0].options.body).bidiGenerateContentSetup
|
||||||
|
.tools[0].functionDeclarations;
|
||||||
|
assert.deepEqual(
|
||||||
|
locked.map(({ name }) => name),
|
||||||
|
tools.map(({ name }) => name),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
locked.map(({ parametersJsonSchema }) => parametersJsonSchema),
|
||||||
|
tools.map(({ parameters }) => parameters),
|
||||||
|
);
|
||||||
|
assert.ok(locked.every(({ behavior }) => behavior === 'BLOCKING'));
|
||||||
|
assert.deepEqual(res.body.config.tools[0].functionDeclarations, locked);
|
||||||
|
assert.ok(!JSON.stringify(res.body).includes('fixture-key'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Gemini key setup uses the existing server-only status and save contract', () => {
|
||||||
|
const entry = KEY_SETUP_KEYS.find(({ id }) => id === 'gemini');
|
||||||
|
assert.deepEqual(entry.envVars, ['GEMINI_API_KEY']);
|
||||||
|
assert.ok(!entry.clientExposed);
|
||||||
|
assert.equal(
|
||||||
|
keySetupStatus({}).keys.find(({ id }) => id === 'gemini').set,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const env = { GEMINI_API_KEY: 'fixture-private-key' };
|
||||||
|
const status = keySetupStatus(env);
|
||||||
|
assert.equal(status.keys.find(({ id }) => id === 'gemini').set, true);
|
||||||
|
assert.ok(!JSON.stringify(status).includes(env.GEMINI_API_KEY));
|
||||||
|
assert.equal(validateKeySetupUpdates(env).ok, true);
|
||||||
|
assert.match(
|
||||||
|
upsertDotenvValues('GEMINI_API_KEY=old\n', env),
|
||||||
|
/^GEMINI_API_KEY=fixture-private-key\n$/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
validateKeySetupUpdates({ VITE_GEMINI_API_KEY: 'fixture-private-key' }).ok,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
+60
-13
@@ -908,9 +908,7 @@
|
|||||||
"Red": "Rojo",
|
"Red": "Rojo",
|
||||||
"Clear": "Borrar",
|
"Clear": "Borrar",
|
||||||
"Remove every mark from the board": "Quitar todas las marcas del tablero",
|
"Remove every mark from the board": "Quitar todas las marcas del tablero",
|
||||||
"Point the camera at the globe for street-level coverage": "Apunta la cámara al globo para ver la cobertura a pie de calle",
|
|
||||||
"No street-level imagery providers configured": "No hay proveedores de imágenes a pie de calle configurados",
|
"No street-level imagery providers configured": "No hay proveedores de imágenes a pie de calle configurados",
|
||||||
"Selected": "Seleccionada",
|
|
||||||
"Live Vessels": "Buques en vivo",
|
"Live Vessels": "Buques en vivo",
|
||||||
"Bike Share": "Bicis compartidas",
|
"Bike Share": "Bicis compartidas",
|
||||||
"Cameras": "Cámaras",
|
"Cameras": "Cámaras",
|
||||||
@@ -920,7 +918,6 @@
|
|||||||
"Active Fires": "Incendios activos",
|
"Active Fires": "Incendios activos",
|
||||||
"Street Level could not start": "No se pudo iniciar A pie de calle",
|
"Street Level could not start": "No se pudo iniciar A pie de calle",
|
||||||
"Street Level toggle failed": "No se pudo activar o desactivar la capa A pie de calle",
|
"Street Level toggle failed": "No se pudo activar o desactivar la capa A pie de calle",
|
||||||
"Street-level image": "Imagen a pie de calle",
|
|
||||||
"SHRINK": "REDUCIR",
|
"SHRINK": "REDUCIR",
|
||||||
"EXPAND": "AMPLIAR",
|
"EXPAND": "AMPLIAR",
|
||||||
"Shrink": "Reducir",
|
"Shrink": "Reducir",
|
||||||
@@ -954,9 +951,6 @@
|
|||||||
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
|
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
|
||||||
"other": "{n} calles a la vista · haz clic en una línea para su foto más cercana"
|
"other": "{n} calles a la vista · haz clic en una línea para su foto más cercana"
|
||||||
},
|
},
|
||||||
"Camera follows view: move the globe camera wherever the street-level view looks": "La cámara sigue la vista: mueve la cámara del globo hacia donde mira la vista a pie de calle",
|
|
||||||
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Seguir con la cámara requiere el mapa Google 3D: elige Google 3D en FUENTE DEL MAPA",
|
|
||||||
"Switch a provider on to draw its coverage.": "Activa un proveedor para dibujar su cobertura.",
|
|
||||||
"Loading this sequence…": "Cargando esta secuencia…",
|
"Loading this sequence…": "Cargando esta secuencia…",
|
||||||
"{n} images in this sequence · Esc clears": {
|
"{n} images in this sequence · Esc clears": {
|
||||||
"one": "{n} imagen en esta secuencia · Esc quita la selección",
|
"one": "{n} imagen en esta secuencia · Esc quita la selección",
|
||||||
@@ -969,14 +963,11 @@
|
|||||||
"other": "{n} secuencias a la vista · haz clic en una línea para ver sus fotos"
|
"other": "{n} secuencias a la vista · haz clic en una línea para ver sus fotos"
|
||||||
},
|
},
|
||||||
"STREET LEVEL": "A PIE DE CALLE",
|
"STREET LEVEL": "A PIE DE CALLE",
|
||||||
"Expand the street-level view (Esc shrinks it)": "Ampliar la vista a pie de calle (Esc la reduce)",
|
|
||||||
"Image fit": "Ajuste de la imagen",
|
"Image fit": "Ajuste de la imagen",
|
||||||
"FIT": "AJUSTAR",
|
"FIT": "AJUSTAR",
|
||||||
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajustar: muestra la foto entera, con barras oscuras donde su forma no coincide con el marco",
|
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajustar: muestra la foto entera, con barras oscuras donde su forma no coincide con el marco",
|
||||||
"FILL": "RELLENAR",
|
"FILL": "RELLENAR",
|
||||||
"Fill: fill the frame edge to edge, cropping the photo": "Rellenar: llena el marco de borde a borde, recortando la foto",
|
"Fill: fill the frame edge to edge, cropping the photo": "Rellenar: llena el marco de borde a borde, recortando la foto",
|
||||||
"Camera follows view": "La cámara sigue la vista",
|
|
||||||
"FOLLOW": "SEGUIR",
|
|
||||||
"Close the image and deselect it on the map": "Cerrar la imagen y quitar la selección en el mapa",
|
"Close the image and deselect it on the map": "Cerrar la imagen y quitar la selección en el mapa",
|
||||||
"Close the street-level image": "Cerrar la imagen a pie de calle",
|
"Close the street-level image": "Cerrar la imagen a pie de calle",
|
||||||
"Street-level viewer": "Visor a pie de calle",
|
"Street-level viewer": "Visor a pie de calle",
|
||||||
@@ -989,7 +980,6 @@
|
|||||||
"ALL": "TODAS",
|
"ALL": "TODAS",
|
||||||
"FLAT": "PLANAS",
|
"FLAT": "PLANAS",
|
||||||
"SINCE": "DESDE",
|
"SINCE": "DESDE",
|
||||||
"Coverage colours": "Colores de cobertura",
|
|
||||||
"That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.",
|
"That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.",
|
||||||
"Could not place the shape: {error}": "No se pudo colocar la forma: {error}",
|
"Could not place the shape: {error}": "No se pudo colocar la forma: {error}",
|
||||||
"{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.",
|
"{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.",
|
||||||
@@ -1506,7 +1496,6 @@
|
|||||||
"DISCONNECT": "DESCONECTAR",
|
"DISCONNECT": "DESCONECTAR",
|
||||||
"CONNECT": "CONECTAR",
|
"CONNECT": "CONECTAR",
|
||||||
"LOCATING…": "UBICANDO…",
|
"LOCATING…": "UBICANDO…",
|
||||||
"Drag to resize · double-click the header to snap back": "Arrastra para cambiar el tamaño · doble clic en el encabezado para restaurarlo",
|
|
||||||
"CH {index} / {total}": "CANAL {index} / {total}",
|
"CH {index} / {total}": "CANAL {index} / {total}",
|
||||||
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
|
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
|
||||||
"Clear reading": "Borrar lectura",
|
"Clear reading": "Borrar lectura",
|
||||||
@@ -2443,7 +2432,6 @@
|
|||||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||||
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
|
|
||||||
"New scene name": "Nombre de la nueva escena",
|
"New scene name": "Nombre de la nueva escena",
|
||||||
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
||||||
"Detailed outline unavailable": "Contorno detallado no disponible",
|
"Detailed outline unavailable": "Contorno detallado no disponible",
|
||||||
@@ -2890,5 +2878,64 @@
|
|||||||
"USB work abandoned after a stalled transfer": "Trabajo USB cancelado tras una transferencia bloqueada",
|
"USB work abandoned after a stalled transfer": "Trabajo USB cancelado tras una transferencia bloqueada",
|
||||||
"SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido",
|
"SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido",
|
||||||
"SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido",
|
"SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido",
|
||||||
"SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido"
|
"SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido",
|
||||||
|
"Zoom in to see street-level coverage": "Acércate para ver la cobertura a pie de calle",
|
||||||
|
"Point the camera at the ground for street-level coverage": "Apunta la cámara al suelo para ver la cobertura a pie de calle",
|
||||||
|
"Sequence images unavailable": "Las imágenes de la secuencia no están disponibles",
|
||||||
|
"Switch Street Level on to draw its coverage.": "Activa A pie de calle para dibujar su cobertura.",
|
||||||
|
"Every provider is switched off: light a chip to draw its coverage.": "Todos los proveedores están apagados: enciende uno para dibujar su cobertura.",
|
||||||
|
"No sequences in view match the filter": "Ninguna secuencia a la vista coincide con el filtro",
|
||||||
|
"No coverage in view": "No hay cobertura a la vista",
|
||||||
|
"Show the street-level view full screen (Esc shrinks it)": "Mostrar la vista a pie de calle a pantalla completa (Esc la reduce)",
|
||||||
|
"Gemini returned an oversized audio chunk": "Gemini devolvió un fragmento de audio demasiado grande",
|
||||||
|
"Gemini returned an unsupported audio format": "Gemini devolvió un formato de audio no compatible",
|
||||||
|
"Gemini returned an invalid audio sample rate": "Gemini devolvió una frecuencia de muestreo de audio no válida",
|
||||||
|
"Gemini returned incomplete PCM audio": "Gemini devolvió audio PCM incompleto",
|
||||||
|
"This browser does not support Gemini microphone audio": "Este navegador no admite audio del micrófono para Gemini",
|
||||||
|
"Microphone audio processing failed": "Falló el procesamiento del audio del micrófono",
|
||||||
|
"Gemini audio playback fell behind. Reconnect to continue.": "La reproducción de audio de Gemini se ha retrasado. Vuelve a conectar para continuar.",
|
||||||
|
"Gemini token response did not include an ephemeral credential": "La respuesta de Gemini no incluyó una credencial temporal",
|
||||||
|
"Invalid Gemini connection endpoint": "El destino de conexión de Gemini no es válido",
|
||||||
|
"Voice command context is too large": "El contexto del comando de voz es demasiado grande",
|
||||||
|
"Gemini reliable transport exceeded its queue limit. Start voice again.": "La cola de envío fiable de Gemini superó su límite. Vuelve a iniciar la voz.",
|
||||||
|
"Gemini reliable transport did not drain. Start voice again.": "La cola de envío fiable de Gemini no se vació. Vuelve a iniciar la voz.",
|
||||||
|
"Gemini token request failed (HTTP {status})": "Falló la solicitud de credencial de Gemini (HTTP {status})",
|
||||||
|
"Gemini token response did not include a valid model": "La respuesta de Gemini no incluyó un modelo válido",
|
||||||
|
"Gemini token response did not include session settings": "La respuesta de Gemini no incluyó la configuración de la sesión",
|
||||||
|
"Gemini session settings do not match the input mode": "La configuración de la sesión de Gemini no coincide con el modo de entrada",
|
||||||
|
"Gemini could not submit this input. Start voice again.": "Gemini no pudo enviar esta entrada. Vuelve a iniciar la voz.",
|
||||||
|
"Connecting to Gemini": "Conectando con Gemini",
|
||||||
|
"Gemini returned an oversized message": "Gemini devolvió un mensaje demasiado grande",
|
||||||
|
"Gemini Live connection failed. Check network access, then start voice again.": "Falló la conexión con Gemini Live. Comprueba el acceso a la red y vuelve a iniciar la voz.",
|
||||||
|
"Gemini Live connection ended. Start voice again for a new conversation.": "La conexión con Gemini Live terminó. Vuelve a iniciar la voz para una nueva conversación.",
|
||||||
|
"Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live no pudo iniciarse. Vuelve a iniciar la voz para solicitar una nueva sesión.",
|
||||||
|
"Start voice again for a fresh token and a new conversation.": "Vuelve a iniciar la voz para obtener una credencial nueva y comenzar otra conversación.",
|
||||||
|
"Start voice again to request a fresh session credential.": "Vuelve a iniciar la voz para solicitar una nueva credencial de sesión.",
|
||||||
|
"Microphone permission was denied. Allow microphone access and try again.": "Se denegó el permiso del micrófono. Permite el acceso al micrófono e inténtalo de nuevo.",
|
||||||
|
"Gemini connection timed out. Try again.": "Se agotó el tiempo de conexión con Gemini. Inténtalo de nuevo.",
|
||||||
|
"Gemini voice could not start": "No se pudo iniciar la voz de Gemini",
|
||||||
|
"Gemini did not finish the interrupted reply. Start voice again.": "Gemini no terminó la respuesta interrumpida. Vuelve a iniciar la voz.",
|
||||||
|
"Gemini session expiring; finishing the current reply": "La sesión de Gemini va a caducar; terminando la respuesta actual",
|
||||||
|
"Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini rechazó la sesión de voz. Vuelve a iniciar la voz para solicitar una nueva sesión.",
|
||||||
|
"Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Vuelve a iniciar la voz para obtener una nueva credencial de sesión. Si se repite, comprueba el modelo de Gemini y el acceso a la API.",
|
||||||
|
"Gemini action history is full. Start voice again for a fresh session.": "El historial de acciones de Gemini está lleno. Vuelve a iniciar la voz para una nueva sesión.",
|
||||||
|
"Gemini requested too many simultaneous actions": "Gemini solicitó demasiadas acciones simultáneas",
|
||||||
|
"Voice text command is too long": "El comando de voz escrito es demasiado largo",
|
||||||
|
"Gemini voice is not connected": "La voz de Gemini no está conectada",
|
||||||
|
"Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "La entrada interrumpida de Gemini superó el límite de búfer o de espera. Vuelve a iniciar la voz.",
|
||||||
|
"Gemini Live connection is expiring. Start voice again for a new conversation.": "La conexión con Gemini Live va a caducar. Vuelve a iniciar la voz para una nueva conversación.",
|
||||||
|
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Las sesiones de voz de Gemini tienen un límite de tiempo. Cuando caduque la sesión, vuelve a iniciar la voz para una nueva conversación.",
|
||||||
|
"Gemini Live voice control": "Control por voz con Gemini Live",
|
||||||
|
"Voice provider": "Proveedor de voz",
|
||||||
|
"Changing provider reconnects voice if it is on.": "Cambiar de proveedor vuelve a conectar la voz si está activada.",
|
||||||
|
"Gemini voice could not connect. Try again.": "No se pudo conectar la voz de Gemini. Inténtalo de nuevo.",
|
||||||
|
"Gemini voice requires a same-origin app request": "La voz de Gemini requiere una solicitud del mismo origen que la aplicación",
|
||||||
|
"Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en los ajustes de proveedores",
|
||||||
|
"The Gemini Live model configuration is invalid": "La configuración del modelo de Gemini Live no es válida",
|
||||||
|
"Gemini voice is starting too often. Try again shortly.": "La voz de Gemini se está iniciando con demasiada frecuencia. Vuelve a intentarlo en breve.",
|
||||||
|
"Gemini quota is unavailable. Try again later.": "La cuota de Gemini no está disponible. Inténtalo más tarde.",
|
||||||
|
"Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.",
|
||||||
|
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
|
||||||
|
"Token request is too large": "La solicitud de credencial es demasiado grande",
|
||||||
|
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío"
|
||||||
}
|
}
|
||||||
+60
-13
@@ -916,9 +916,7 @@
|
|||||||
"Red": "Красный",
|
"Red": "Красный",
|
||||||
"Clear": "Очистить",
|
"Clear": "Очистить",
|
||||||
"Remove every mark from the board": "Убрать все пометки с доски",
|
"Remove every mark from the board": "Убрать все пометки с доски",
|
||||||
"Point the camera at the globe for street-level coverage": "Наведите камеру на глобус, чтобы увидеть покрытие уличных снимков",
|
|
||||||
"No street-level imagery providers configured": "Источники уличных снимков не настроены",
|
"No street-level imagery providers configured": "Источники уличных снимков не настроены",
|
||||||
"Selected": "Выбрано",
|
|
||||||
"Live Vessels": "Суда онлайн",
|
"Live Vessels": "Суда онлайн",
|
||||||
"Bike Share": "Прокат велосипедов",
|
"Bike Share": "Прокат велосипедов",
|
||||||
"Cameras": "Камеры",
|
"Cameras": "Камеры",
|
||||||
@@ -928,7 +926,6 @@
|
|||||||
"Active Fires": "Активные пожары",
|
"Active Fires": "Активные пожары",
|
||||||
"Street Level could not start": "Не удалось запустить «Уличные снимки»",
|
"Street Level could not start": "Не удалось запустить «Уличные снимки»",
|
||||||
"Street Level toggle failed": "Не удалось переключить «Уличные снимки»",
|
"Street Level toggle failed": "Не удалось переключить «Уличные снимки»",
|
||||||
"Street-level image": "Уличный снимок",
|
|
||||||
"SHRINK": "СВЕРНУТЬ",
|
"SHRINK": "СВЕРНУТЬ",
|
||||||
"EXPAND": "РАЗВЕРНУТЬ",
|
"EXPAND": "РАЗВЕРНУТЬ",
|
||||||
"Shrink": "Свернуть",
|
"Shrink": "Свернуть",
|
||||||
@@ -964,9 +961,6 @@
|
|||||||
"many": "{n} улиц в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
"many": "{n} улиц в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||||
"other": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию"
|
"other": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию"
|
||||||
},
|
},
|
||||||
"Camera follows view: move the globe camera wherever the street-level view looks": "Камера следует за видом: камера глобуса поворачивается туда, куда смотрит уличный снимок",
|
|
||||||
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Слежению камеры нужна карта Google 3D: выберите Google 3D в разделе «ИСТОЧНИК КАРТЫ»",
|
|
||||||
"Switch a provider on to draw its coverage.": "Включите источник, чтобы показать его покрытие.",
|
|
||||||
"Loading this sequence…": "Загрузка последовательности…",
|
"Loading this sequence…": "Загрузка последовательности…",
|
||||||
"{n} images in this sequence · Esc clears": {
|
"{n} images in this sequence · Esc clears": {
|
||||||
"one": "{n} снимок в последовательности · Esc — сбросить",
|
"one": "{n} снимок в последовательности · Esc — сбросить",
|
||||||
@@ -981,14 +975,11 @@
|
|||||||
"other": "{n} последовательности в кадре · щёлкните по линии, чтобы открыть снимки"
|
"other": "{n} последовательности в кадре · щёлкните по линии, чтобы открыть снимки"
|
||||||
},
|
},
|
||||||
"STREET LEVEL": "УЛИЧНЫЕ СНИМКИ",
|
"STREET LEVEL": "УЛИЧНЫЕ СНИМКИ",
|
||||||
"Expand the street-level view (Esc shrinks it)": "Развернуть уличный снимок (Esc сворачивает)",
|
|
||||||
"Image fit": "Масштаб снимка",
|
"Image fit": "Масштаб снимка",
|
||||||
"FIT": "ВПИСАТЬ",
|
"FIT": "ВПИСАТЬ",
|
||||||
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Вписать: показать снимок целиком, с тёмными полосами там, где его форма не совпадает с рамкой",
|
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Вписать: показать снимок целиком, с тёмными полосами там, где его форма не совпадает с рамкой",
|
||||||
"FILL": "ЗАПОЛНИТЬ",
|
"FILL": "ЗАПОЛНИТЬ",
|
||||||
"Fill: fill the frame edge to edge, cropping the photo": "Заполнить: заполнить рамку целиком, обрезав края снимка",
|
"Fill: fill the frame edge to edge, cropping the photo": "Заполнить: заполнить рамку целиком, обрезав края снимка",
|
||||||
"Camera follows view": "Камера следует за видом",
|
|
||||||
"FOLLOW": "СЛЕДИТЬ",
|
|
||||||
"Close the image and deselect it on the map": "Закрыть снимок и снять выделение на карте",
|
"Close the image and deselect it on the map": "Закрыть снимок и снять выделение на карте",
|
||||||
"Close the street-level image": "Закрыть уличный снимок",
|
"Close the street-level image": "Закрыть уличный снимок",
|
||||||
"Street-level viewer": "Просмотр уличных снимков",
|
"Street-level viewer": "Просмотр уличных снимков",
|
||||||
@@ -1001,7 +992,6 @@
|
|||||||
"ALL": "ВСЕ",
|
"ALL": "ВСЕ",
|
||||||
"FLAT": "ПЛОСКИЕ",
|
"FLAT": "ПЛОСКИЕ",
|
||||||
"SINCE": "С ДАТЫ",
|
"SINCE": "С ДАТЫ",
|
||||||
"Coverage colours": "Цвета покрытия",
|
|
||||||
"That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.",
|
"That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.",
|
||||||
"Could not place the shape: {error}": "Не удалось разместить фигуру: {error}",
|
"Could not place the shape: {error}": "Не удалось разместить фигуру: {error}",
|
||||||
"{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.",
|
"{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.",
|
||||||
@@ -1531,7 +1521,6 @@
|
|||||||
"DISCONNECT": "ОТКЛЮЧИТЬ",
|
"DISCONNECT": "ОТКЛЮЧИТЬ",
|
||||||
"CONNECT": "ПОДКЛЮЧИТЬ",
|
"CONNECT": "ПОДКЛЮЧИТЬ",
|
||||||
"LOCATING…": "ОПРЕДЕЛЯЮ…",
|
"LOCATING…": "ОПРЕДЕЛЯЮ…",
|
||||||
"Drag to resize · double-click the header to snap back": "Перетащите, чтобы изменить размер · двойной щелчок по заголовку — вернуть на место",
|
|
||||||
"CH {index} / {total}": "КАН. {index} / {total}",
|
"CH {index} / {total}": "КАН. {index} / {total}",
|
||||||
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
|
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
|
||||||
"Clear reading": "Очистить показания",
|
"Clear reading": "Очистить показания",
|
||||||
@@ -2484,7 +2473,6 @@
|
|||||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||||
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
|
|
||||||
"New scene name": "Название новой сцены",
|
"New scene name": "Название новой сцены",
|
||||||
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
||||||
"Detailed outline unavailable": "Подробный контур недоступен",
|
"Detailed outline unavailable": "Подробный контур недоступен",
|
||||||
@@ -2957,5 +2945,64 @@
|
|||||||
"USB work abandoned after a stalled transfer": "Работа с USB прервана: передача данных зависла",
|
"USB work abandoned after a stalled transfer": "Работа с USB прервана: передача данных зависла",
|
||||||
"SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен",
|
"SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен",
|
||||||
"SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен",
|
"SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен",
|
||||||
"SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен"
|
"SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен",
|
||||||
|
"Zoom in to see street-level coverage": "Приблизьтесь, чтобы увидеть покрытие уличных снимков",
|
||||||
|
"Point the camera at the ground for street-level coverage": "Направьте камеру на землю, чтобы увидеть покрытие уличных снимков",
|
||||||
|
"Sequence images unavailable": "Снимки последовательности недоступны",
|
||||||
|
"Switch Street Level on to draw its coverage.": "Включите «Уличные снимки», чтобы показать покрытие.",
|
||||||
|
"Every provider is switched off: light a chip to draw its coverage.": "Все источники выключены: включите один, чтобы показать его покрытие.",
|
||||||
|
"No sequences in view match the filter": "Ни одна последовательность в кадре не подходит под фильтр",
|
||||||
|
"No coverage in view": "В кадре нет покрытия",
|
||||||
|
"Show the street-level view full screen (Esc shrinks it)": "Показать уличный снимок на весь экран (Esc сворачивает)",
|
||||||
|
"Gemini returned an oversized audio chunk": "Gemini вернул слишком большой фрагмент аудио",
|
||||||
|
"Gemini returned an unsupported audio format": "Gemini вернул неподдерживаемый формат аудио",
|
||||||
|
"Gemini returned an invalid audio sample rate": "Gemini вернул недопустимую частоту дискретизации аудио",
|
||||||
|
"Gemini returned incomplete PCM audio": "Gemini вернул неполные аудиоданные PCM",
|
||||||
|
"This browser does not support Gemini microphone audio": "Этот браузер не поддерживает звук с микрофона для Gemini",
|
||||||
|
"Microphone audio processing failed": "Не удалось обработать звук с микрофона",
|
||||||
|
"Gemini audio playback fell behind. Reconnect to continue.": "Воспроизведение звука Gemini отстаёт. Подключитесь заново, чтобы продолжить.",
|
||||||
|
"Gemini token response did not include an ephemeral credential": "В ответе Gemini нет временных учётных данных",
|
||||||
|
"Invalid Gemini connection endpoint": "Недопустимый адрес подключения к Gemini",
|
||||||
|
"Voice command context is too large": "Контекст голосовой команды слишком велик",
|
||||||
|
"Gemini reliable transport exceeded its queue limit. Start voice again.": "Очередь надёжной передачи Gemini превысила лимит. Запустите голосовой режим заново.",
|
||||||
|
"Gemini reliable transport did not drain. Start voice again.": "Очередь надёжной передачи Gemini не освободилась. Запустите голосовой режим заново.",
|
||||||
|
"Gemini token request failed (HTTP {status})": "Не удалось запросить токен Gemini (HTTP {status})",
|
||||||
|
"Gemini token response did not include a valid model": "В ответе Gemini нет допустимой модели",
|
||||||
|
"Gemini token response did not include session settings": "В ответе Gemini нет настроек сеанса",
|
||||||
|
"Gemini session settings do not match the input mode": "Настройки сеанса Gemini не соответствуют режиму ввода",
|
||||||
|
"Gemini could not submit this input. Start voice again.": "Gemini не удалось отправить эти данные. Запустите голосовой режим заново.",
|
||||||
|
"Connecting to Gemini": "Подключение к Gemini",
|
||||||
|
"Gemini returned an oversized message": "Gemini вернул слишком большое сообщение",
|
||||||
|
"Gemini Live connection failed. Check network access, then start voice again.": "Не удалось подключиться к Gemini Live. Проверьте доступ к сети и запустите голосовой режим заново.",
|
||||||
|
"Gemini Live connection ended. Start voice again for a new conversation.": "Соединение с Gemini Live завершено. Запустите голосовой режим заново для нового разговора.",
|
||||||
|
"Gemini Live could not start. Start voice again to request a fresh session.": "Не удалось запустить Gemini Live. Запустите голосовой режим заново, чтобы запросить новый сеанс.",
|
||||||
|
"Start voice again for a fresh token and a new conversation.": "Запустите голосовой режим заново, чтобы получить новый токен и начать новый разговор.",
|
||||||
|
"Start voice again to request a fresh session credential.": "Запустите голосовой режим заново, чтобы запросить новые учётные данные сеанса.",
|
||||||
|
"Microphone permission was denied. Allow microphone access and try again.": "Доступ к микрофону запрещён. Разрешите доступ и повторите попытку.",
|
||||||
|
"Gemini connection timed out. Try again.": "Время ожидания подключения к Gemini истекло. Повторите попытку.",
|
||||||
|
"Gemini voice could not start": "Не удалось запустить голосовой режим Gemini",
|
||||||
|
"Gemini did not finish the interrupted reply. Start voice again.": "Gemini не завершил прерванный ответ. Запустите голосовой режим заново.",
|
||||||
|
"Gemini session expiring; finishing the current reply": "Сеанс Gemini истекает; завершается текущий ответ",
|
||||||
|
"Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini отклонил голосовой сеанс. Запустите голосовой режим заново, чтобы запросить новый сеанс.",
|
||||||
|
"Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Запустите голосовой режим заново для получения новых учётных данных. Если ошибка повторится, проверьте модель Gemini и доступ к API.",
|
||||||
|
"Gemini action history is full. Start voice again for a fresh session.": "История действий Gemini заполнена. Запустите голосовой режим заново для нового сеанса.",
|
||||||
|
"Gemini requested too many simultaneous actions": "Gemini запросил слишком много одновременных действий",
|
||||||
|
"Voice text command is too long": "Текст голосовой команды слишком длинный",
|
||||||
|
"Gemini voice is not connected": "Голосовой режим Gemini не подключён",
|
||||||
|
"Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Прерванный ввод Gemini превысил лимит буфера или ожидания. Запустите голосовой режим заново.",
|
||||||
|
"Gemini Live connection is expiring. Start voice again for a new conversation.": "Соединение с Gemini Live истекает. Запустите голосовой режим заново для нового разговора.",
|
||||||
|
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Время голосовых сеансов Gemini ограничено. Когда сеанс истечёт, запустите голосовой режим заново для нового разговора.",
|
||||||
|
"Gemini Live voice control": "Голосовое управление через Gemini Live",
|
||||||
|
"Voice provider": "Поставщик голосового сервиса",
|
||||||
|
"Changing provider reconnects voice if it is on.": "Смена поставщика переподключает голосовой режим, если он включён.",
|
||||||
|
"Gemini voice could not connect. Try again.": "Не удалось подключить голосовой режим Gemini. Повторите попытку.",
|
||||||
|
"Gemini voice requires a same-origin app request": "Голосовой режим Gemini требует запрос из того же источника, что и приложение",
|
||||||
|
"Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в настройках поставщиков",
|
||||||
|
"The Gemini Live model configuration is invalid": "Недопустимая конфигурация модели Gemini Live",
|
||||||
|
"Gemini voice is starting too often. Try again shortly.": "Голосовой режим Gemini запускается слишком часто. Повторите попытку чуть позже.",
|
||||||
|
"Gemini quota is unavailable. Try again later.": "Квота Gemini недоступна. Повторите попытку позже.",
|
||||||
|
"Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.",
|
||||||
|
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
|
||||||
|
"Token request is too large": "Запрос токена слишком велик",
|
||||||
|
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON"
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ import { hasProxySignals } from './localRequestGate.mjs';
|
|||||||
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
||||||
export const KEY_SETUP_VALUE_LIMIT = 512;
|
export const KEY_SETUP_VALUE_LIMIT = 512;
|
||||||
|
|
||||||
/** Most env vars accepted in one save. The registry defines ten. */
|
/** Most env vars accepted in one save. */
|
||||||
export const KEY_SETUP_UPDATE_LIMIT = 16;
|
export const KEY_SETUP_UPDATE_LIMIT = 16;
|
||||||
|
|
||||||
/** Header line written above keys the panel appends to a .env file. */
|
/** Header line written above keys the panel appends to a .env file. */
|
||||||
@@ -65,6 +65,14 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||||
tier: 'metered',
|
tier: 'metered',
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'gemini',
|
||||||
|
title: 'GEMINI', // i18n-ignore: provider product name
|
||||||
|
unlocks: msg('Gemini Live voice control'),
|
||||||
|
getUrl: 'https://aistudio.google.com/apikey',
|
||||||
|
envVars: Object.freeze(['GEMINI_API_KEY']),
|
||||||
|
tier: 'metered',
|
||||||
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'aisstream',
|
id: 'aisstream',
|
||||||
title: 'AISSTREAM', // i18n-ignore: provider product name
|
title: 'AISSTREAM', // i18n-ignore: provider product name
|
||||||
|
|||||||
+44
-10
@@ -3,6 +3,7 @@ import {
|
|||||||
FRAME_SIGNATURE_H,
|
FRAME_SIGNATURE_H,
|
||||||
PROJECTION_CANVAS_WIDTH,
|
PROJECTION_CANVAS_WIDTH,
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
PROJECTION_TEXTURE_SWAP_MS,
|
PROJECTION_TEXTURE_SWAP_MS,
|
||||||
ACTIVE_FRAME_REFRESH_MS,
|
ACTIVE_FRAME_REFRESH_MS,
|
||||||
FRAME_ENDPOINT,
|
FRAME_ENDPOINT,
|
||||||
@@ -12,6 +13,37 @@ import {
|
|||||||
PLACEHOLDER_REPAINT_MS,
|
PLACEHOLDER_REPAINT_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contain-fits a source frame into the projection canvas: scaled to the
|
||||||
|
* largest size that fits, centered, with the margins left for the matte fill.
|
||||||
|
* Falls back to the full canvas when either dimension is missing or zero.
|
||||||
|
*
|
||||||
|
* @param {number} sourceW - Source width in pixels.
|
||||||
|
* @param {number} sourceH - Source height in pixels.
|
||||||
|
* @returns {{x:number,y:number,w:number,h:number}} Draw rectangle in canvas pixels.
|
||||||
|
*/
|
||||||
|
export function fitToProjection(sourceW, sourceH) {
|
||||||
|
if (!sourceW || !sourceH)
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: PROJECTION_CANVAS_WIDTH,
|
||||||
|
h: PROJECTION_CANVAS_HEIGHT,
|
||||||
|
};
|
||||||
|
const scale = Math.min(
|
||||||
|
PROJECTION_CANVAS_WIDTH / sourceW,
|
||||||
|
PROJECTION_CANVAS_HEIGHT / sourceH,
|
||||||
|
);
|
||||||
|
const w = Math.round(sourceW * scale);
|
||||||
|
const h = Math.round(sourceH * scale);
|
||||||
|
return {
|
||||||
|
x: Math.round((PROJECTION_CANVAS_WIDTH - w) / 2),
|
||||||
|
y: Math.round((PROJECTION_CANVAS_HEIGHT - h) / 2),
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createFrames({ state: layerState, services, parts, source }) {
|
export function createFrames({ state: layerState, services, parts, source }) {
|
||||||
/**
|
/**
|
||||||
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
||||||
@@ -182,7 +214,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
const g = ctx.createLinearGradient(0, 0, w, h);
|
const g = ctx.createLinearGradient(0, 0, w, h);
|
||||||
g.addColorStop(0, '#05111a');
|
g.addColorStop(0, '#05111a');
|
||||||
g.addColorStop(1, '#01070c');
|
g.addColorStop(1, PROJECTION_MATTE_COLOR);
|
||||||
ctx.fillStyle = g;
|
ctx.fillStyle = g;
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
@@ -320,7 +352,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
||||||
// two Cesium needs to rebind, which IS the periodic white flash from the
|
// two Cesium needs to rebind, which IS the periodic white flash from the
|
||||||
// owner field tests (2026-07-04 and 2026-07-30).
|
// owner field tests (2026-07-04 and 2026-07-30).
|
||||||
const signature = projectionFrameSignature(runtime);
|
const fit = fitToProjection(
|
||||||
|
runtime.image.naturalWidth,
|
||||||
|
runtime.image.naturalHeight,
|
||||||
|
);
|
||||||
|
// Pixels are hashed at a fixed size, so the fit carries the aspect.
|
||||||
|
const pixels = projectionFrameSignature(runtime);
|
||||||
|
const signature =
|
||||||
|
pixels === null ? null : `${pixels}:${fit.w}x${fit.h}`;
|
||||||
runtime.drawnImageStamp = runtime.imageStamp;
|
runtime.drawnImageStamp = runtime.imageStamp;
|
||||||
if (signature !== null && signature === runtime.lastFrameSignature) {
|
if (signature !== null && signature === runtime.lastFrameSignature) {
|
||||||
// Identical pixels — leave the canvas, and therefore the bound
|
// Identical pixels — leave the canvas, and therefore the bound
|
||||||
@@ -328,19 +367,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.lastFrameSignature = signature;
|
runtime.lastFrameSignature = signature;
|
||||||
runtime.ctx.clearRect(
|
runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR;
|
||||||
0,
|
runtime.ctx.fillRect(
|
||||||
0,
|
|
||||||
PROJECTION_CANVAS_WIDTH,
|
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
|
||||||
);
|
|
||||||
runtime.ctx.drawImage(
|
|
||||||
runtime.image,
|
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
PROJECTION_CANVAS_WIDTH,
|
PROJECTION_CANVAS_WIDTH,
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
);
|
);
|
||||||
|
runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h);
|
||||||
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
||||||
runtime.lastPlaceholderPaintAt = 0;
|
runtime.lastPlaceholderPaintAt = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// src/layers/cctv/frames.test.mjs
|
||||||
|
// Still-frame fit (#1201): stills draw at their source aspect, centered on the
|
||||||
|
// 1920x1080 projection canvas, instead of being stretched to fill it.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createFrames, fitToProjection } from './frames.js';
|
||||||
|
import {
|
||||||
|
FRAME_SIGNATURE_W,
|
||||||
|
FRAME_SIGNATURE_H,
|
||||||
|
PROJECTION_CANVAS_WIDTH,
|
||||||
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
// The expected rectangles below are written for a 1920x1080 canvas.
|
||||||
|
assert.equal(PROJECTION_CANVAS_WIDTH, 1920);
|
||||||
|
assert.equal(PROJECTION_CANVAS_HEIGHT, 1080);
|
||||||
|
|
||||||
|
const FULL_CANVAS = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: PROJECTION_CANVAS_WIDTH,
|
||||||
|
h: PROJECTION_CANVAS_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a 4:3 still is pillarboxed at full height', () => {
|
||||||
|
assert.deepEqual(fitToProjection(640, 480), {
|
||||||
|
x: 240,
|
||||||
|
y: 0,
|
||||||
|
w: 1440,
|
||||||
|
h: 1080,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a source wider than 16:9 is letterboxed at full width', () => {
|
||||||
|
assert.deepEqual(fitToProjection(3840, 1080), {
|
||||||
|
x: 0,
|
||||||
|
y: 270,
|
||||||
|
w: 1920,
|
||||||
|
h: 540,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 16:9 still fills the canvas', () => {
|
||||||
|
assert.deepEqual(fitToProjection(1920, 1080), FULL_CANVAS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing or zero dimension falls back to the full canvas', () => {
|
||||||
|
assert.deepEqual(fitToProjection(0, 480), FULL_CANVAS);
|
||||||
|
assert.deepEqual(fitToProjection(640, 0), FULL_CANVAS);
|
||||||
|
assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS);
|
||||||
|
assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw path: the fit reaches drawImage, and the unchanged-frame skip keys on the
|
||||||
|
// fitted size as well as the pixels (the 64x36 hash alone drops aspect).
|
||||||
|
function stillHarness() {
|
||||||
|
const calls = [];
|
||||||
|
const ctx = {
|
||||||
|
fillStyle: null,
|
||||||
|
fillRect: (...rect) => calls.push(['fillRect', ctx.fillStyle, ...rect]),
|
||||||
|
clearRect: () => {},
|
||||||
|
drawImage: (_image, ...rect) => calls.push(['drawImage', ...rect]),
|
||||||
|
};
|
||||||
|
// Every frame downsamples to the same pixels, so only the size differs.
|
||||||
|
const pixels = new Uint8ClampedArray(
|
||||||
|
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
|
||||||
|
).fill(7);
|
||||||
|
const runtime = {
|
||||||
|
mode: 'image',
|
||||||
|
ctx,
|
||||||
|
image: { naturalWidth: 0, naturalHeight: 0 },
|
||||||
|
imageReady: true,
|
||||||
|
// In flight, so refreshProjectionImage returns before touching image.src.
|
||||||
|
imageLoading: true,
|
||||||
|
imageStamp: 0,
|
||||||
|
drawnImageStamp: -1,
|
||||||
|
lastFrameSignature: null,
|
||||||
|
canvasStamp: 0,
|
||||||
|
signatureCtx: {
|
||||||
|
clearRect: () => {},
|
||||||
|
drawImage: () => {},
|
||||||
|
getImageData: () => ({ data: pixels }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const record = { camera: { id: 'still' }, projection: runtime };
|
||||||
|
const { drawProjectionFrame } = createFrames({
|
||||||
|
state: { _healthById: new Map(), _activeCameraId: null },
|
||||||
|
services: {},
|
||||||
|
parts: {},
|
||||||
|
source: {},
|
||||||
|
});
|
||||||
|
const load = (width, height) => {
|
||||||
|
runtime.image = { naturalWidth: width, naturalHeight: height };
|
||||||
|
runtime.imageStamp += 1;
|
||||||
|
calls.length = 0;
|
||||||
|
drawProjectionFrame(record);
|
||||||
|
return calls.slice();
|
||||||
|
};
|
||||||
|
return { runtime, load };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a still is drawn into its fitted rectangle over a full matte', () => {
|
||||||
|
const { runtime, load } = stillHarness();
|
||||||
|
assert.deepEqual(load(640, 480), [
|
||||||
|
[
|
||||||
|
'fillRect',
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
PROJECTION_CANVAS_WIDTH,
|
||||||
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
],
|
||||||
|
['drawImage', 240, 0, 1440, 1080],
|
||||||
|
]);
|
||||||
|
assert.equal(runtime.canvasStamp, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same pixels at the same size skip the redraw', () => {
|
||||||
|
const { runtime, load } = stillHarness();
|
||||||
|
load(640, 480);
|
||||||
|
assert.deepEqual(load(640, 480), []);
|
||||||
|
assert.equal(runtime.canvasStamp, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same pixels at a new aspect redraw at the new fit', () => {
|
||||||
|
const { runtime, load } = stillHarness();
|
||||||
|
load(640, 480);
|
||||||
|
assert.deepEqual(load(1920, 1080), [
|
||||||
|
[
|
||||||
|
'fillRect',
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
PROJECTION_CANVAS_WIDTH,
|
||||||
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
],
|
||||||
|
['drawImage', 0, 0, 1920, 1080],
|
||||||
|
]);
|
||||||
|
assert.equal(runtime.canvasStamp, 2);
|
||||||
|
});
|
||||||
@@ -35,6 +35,9 @@ export const PROJECTION_CANVAS_WIDTH = 1920;
|
|||||||
|
|
||||||
export const PROJECTION_CANVAS_HEIGHT = 1080;
|
export const PROJECTION_CANVAS_HEIGHT = 1080;
|
||||||
|
|
||||||
|
/** Matte behind fitted stills; the placeholder gradient's dark end. */
|
||||||
|
export const PROJECTION_MATTE_COLOR = '#01070c';
|
||||||
|
|
||||||
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
|
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
|
||||||
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
|
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
|
||||||
// full-resolution compare would touch.
|
// full-resolution compare would touch.
|
||||||
@@ -410,6 +413,9 @@ export const IDLE_CAMERA_COLOR =
|
|||||||
export const ACTIVE_CAMERA_COLOR =
|
export const ACTIVE_CAMERA_COLOR =
|
||||||
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
|
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
|
||||||
|
|
||||||
|
export const LIVE_CAMERA_COLOR =
|
||||||
|
Cesium.Color.fromCssColorString('#b48cff').withAlpha(0.88);
|
||||||
|
|
||||||
export const IDLE_COVERAGE_COLOR =
|
export const IDLE_COVERAGE_COLOR =
|
||||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24);
|
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isHeadingEstimated } from './headingConfidence.js';
|
|||||||
import {
|
import {
|
||||||
ACTIVE_CAMERA_COLOR,
|
ACTIVE_CAMERA_COLOR,
|
||||||
IDLE_CAMERA_COLOR,
|
IDLE_CAMERA_COLOR,
|
||||||
|
LIVE_CAMERA_COLOR,
|
||||||
ACTIVE_COVERAGE_CENTER,
|
ACTIVE_COVERAGE_CENTER,
|
||||||
IDLE_COVERAGE_CENTER_MUTED,
|
IDLE_COVERAGE_CENTER_MUTED,
|
||||||
ACTIVE_COVERAGE_CENTER_DEPTHFAIL,
|
ACTIVE_COVERAGE_CENTER_DEPTHFAIL,
|
||||||
@@ -18,6 +19,15 @@ export function createRendering({
|
|||||||
parts,
|
parts,
|
||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
|
/** Icon color: active wins, then live-capable video feeds, then idle stills. */
|
||||||
|
function cameraColorFor(record) {
|
||||||
|
if (record.camera.id === layerState._activeCameraId)
|
||||||
|
return ACTIVE_CAMERA_COLOR;
|
||||||
|
if (parts.model.isVideoFeedType(record.camera.feedType))
|
||||||
|
return LIVE_CAMERA_COLOR;
|
||||||
|
return IDLE_CAMERA_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
focusNowMs,
|
focusNowMs,
|
||||||
getFocusTarget,
|
getFocusTarget,
|
||||||
@@ -58,10 +68,7 @@ export function createRendering({
|
|||||||
),
|
),
|
||||||
cameraDistanceFor: (position) =>
|
cameraDistanceFor: (position) =>
|
||||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||||
baseColorFor: (record) =>
|
baseColorFor: cameraColorFor,
|
||||||
record.camera.id === layerState._activeCameraId
|
|
||||||
? ACTIVE_CAMERA_COLOR
|
|
||||||
: IDLE_CAMERA_COLOR,
|
|
||||||
});
|
});
|
||||||
layerState._activeFocusStyleCount = result.activeCount;
|
layerState._activeFocusStyleCount = result.activeCount;
|
||||||
}
|
}
|
||||||
@@ -208,9 +215,7 @@ export function createRendering({
|
|||||||
for (const record of layerState._records) {
|
for (const record of layerState._records) {
|
||||||
const isActive = record.camera.id === activeId;
|
const isActive = record.camera.id === activeId;
|
||||||
if (record.billboard) {
|
if (record.billboard) {
|
||||||
record.billboard.color = isActive
|
record.billboard.color = cameraColorFor(record);
|
||||||
? ACTIVE_CAMERA_COLOR
|
|
||||||
: IDLE_CAMERA_COLOR;
|
|
||||||
record.billboard.scale = isActive ? 1.25 : 1.0;
|
record.billboard.scale = isActive ? 1.25 : 1.0;
|
||||||
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
||||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// src/layers/cctv/rendering.test.mjs
|
||||||
|
// Icon colors under focus de-emphasis (#1200): the focus pass fades each icon
|
||||||
|
// from the same base color the coverage refresh assigns, so a video-feed camera
|
||||||
|
// stays violet while it dims instead of falling back to the idle cyan.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
import {
|
||||||
|
ACTIVE_CAMERA_COLOR,
|
||||||
|
IDLE_CAMERA_COLOR,
|
||||||
|
LIVE_CAMERA_COLOR,
|
||||||
|
} from './policy.js';
|
||||||
|
import { isVideoFeedType } from '../../sources/cctvTypes.js';
|
||||||
|
|
||||||
|
const FOCUS_FACTOR = 0.5;
|
||||||
|
|
||||||
|
function assertFadedFrom(actual, base, label) {
|
||||||
|
assert.ok(actual, `${label}: color written`);
|
||||||
|
assert.deepEqual(
|
||||||
|
[actual.red, actual.green, actual.blue],
|
||||||
|
[base.red, base.green, base.blue],
|
||||||
|
`${label}: hue`,
|
||||||
|
);
|
||||||
|
assert.equal(actual.alpha, base.alpha * FOCUS_FACTOR, `${label}: alpha`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('focus de-emphasis fades each icon from its own color', () => {
|
||||||
|
const record = (id, feedType) => ({
|
||||||
|
camera: { id, feedType },
|
||||||
|
// No position, so the pass skips screen projection; the focus stub below
|
||||||
|
// decides the factor on its own.
|
||||||
|
billboard: { show: true },
|
||||||
|
});
|
||||||
|
const idleVideo = record('idle-hls', 'hls');
|
||||||
|
const idleStill = record('idle-image', 'image');
|
||||||
|
const activeVideo = record('active-hls', 'hls');
|
||||||
|
const state = {
|
||||||
|
_enabled: true,
|
||||||
|
_viewer: { scene: {}, camera: {} },
|
||||||
|
_records: [idleVideo, idleStill, activeVideo],
|
||||||
|
_activeCameraId: activeVideo.camera.id,
|
||||||
|
_activeFocusStyleCount: 0,
|
||||||
|
_lastFocusStyleAt: -Infinity,
|
||||||
|
};
|
||||||
|
const focus = {
|
||||||
|
focusNowMs: (nowMs) => nowMs,
|
||||||
|
getFocusTarget: () => ({}),
|
||||||
|
focusPassIsNeeded: () => true,
|
||||||
|
advanceSpriteFocus: () => ({
|
||||||
|
factor: FOCUS_FACTOR,
|
||||||
|
active: true,
|
||||||
|
transitioning: false,
|
||||||
|
}),
|
||||||
|
focusAlphaNeedsWrite: () => true,
|
||||||
|
};
|
||||||
|
const { refreshCctvFocusStyles } = createRendering({
|
||||||
|
state,
|
||||||
|
services: { focus },
|
||||||
|
parts: { model: { isVideoFeedType } },
|
||||||
|
source: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshCctvFocusStyles(1000);
|
||||||
|
|
||||||
|
assertFadedFrom(idleVideo.billboard.color, LIVE_CAMERA_COLOR, 'idle video');
|
||||||
|
assertFadedFrom(idleStill.billboard.color, IDLE_CAMERA_COLOR, 'idle still');
|
||||||
|
assertFadedFrom(
|
||||||
|
activeVideo.billboard.color,
|
||||||
|
ACTIVE_CAMERA_COLOR,
|
||||||
|
'active video',
|
||||||
|
);
|
||||||
|
assert.equal(state._activeFocusStyleCount, 3);
|
||||||
|
});
|
||||||
@@ -139,6 +139,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
});
|
});
|
||||||
layerState._points.set(noradId, point);
|
layerState._points.set(noradId, point);
|
||||||
layerState._denseIds.push(noradId);
|
layerState._denseIds.push(noradId);
|
||||||
|
layerState._denseStale ||= Boolean(res.stale);
|
||||||
added++;
|
added++;
|
||||||
}
|
}
|
||||||
// Yield to the event loop between chunks.
|
// Yield to the event loop between chunks.
|
||||||
@@ -226,6 +227,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
layerState._catalog.delete(noradId);
|
layerState._catalog.delete(noradId);
|
||||||
}
|
}
|
||||||
layerState._denseIds = [];
|
layerState._denseIds = [];
|
||||||
|
layerState._denseStale = false;
|
||||||
layerState._denseCursor = 0;
|
layerState._denseCursor = 0;
|
||||||
layerState._count = layerState._points.size;
|
layerState._count = layerState._points.size;
|
||||||
layerState._catalogRevision++;
|
layerState._catalogRevision++;
|
||||||
|
|||||||
Vendored
+5
-2
@@ -545,16 +545,19 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStats() {
|
getStats() {
|
||||||
|
const stale = Boolean(layerState._coreStale || layerState._denseStale);
|
||||||
return {
|
return {
|
||||||
count: layerState._count,
|
count: layerState._count,
|
||||||
lastUpdate: layerState._lastUpdate,
|
lastUpdate: layerState._lastUpdate,
|
||||||
stale: false,
|
stale,
|
||||||
status:
|
status:
|
||||||
layerState._lastError === 'CelesTrak unreachable'
|
layerState._lastError === 'CelesTrak unreachable'
|
||||||
? 'unavailable'
|
? 'unavailable'
|
||||||
: layerState._lastError
|
: layerState._lastError
|
||||||
? 'degraded'
|
? 'degraded'
|
||||||
: 'nominal',
|
: stale
|
||||||
|
? 'stale'
|
||||||
|
: 'nominal',
|
||||||
error: layerState._lastError,
|
error: layerState._lastError,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export function createIngestion({
|
|||||||
if (!res.ok) return { ...groupDef, entries: [], ok: false };
|
if (!res.ok) return { ...groupDef, entries: [], ok: false };
|
||||||
const entries = parts.orbits.parseTLE(res.text);
|
const entries = parts.orbits.parseTLE(res.text);
|
||||||
updateSignal.throwIfAborted();
|
updateSignal.throwIfAborted();
|
||||||
return { ...groupDef, entries, ok: entries.length > 0 };
|
return {
|
||||||
|
...groupDef,
|
||||||
|
entries,
|
||||||
|
ok: entries.length > 0,
|
||||||
|
stale: Boolean(res.stale),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (updateSignal.aborted || error?.name === 'AbortError')
|
if (updateSignal.aborted || error?.name === 'AbortError')
|
||||||
throw error;
|
throw error;
|
||||||
@@ -91,6 +96,7 @@ export function createIngestion({
|
|||||||
// catalog or those labels stay stale for the life of the session.
|
// catalog or those labels stay stale for the life of the session.
|
||||||
layerState._detectionObjects.clear();
|
layerState._detectionObjects.clear();
|
||||||
layerState._denseIds = [];
|
layerState._denseIds = [];
|
||||||
|
layerState._denseStale = false;
|
||||||
layerState._denseCursor = 0;
|
layerState._denseCursor = 0;
|
||||||
layerState._denseLoadController?.abort();
|
layerState._denseLoadController?.abort();
|
||||||
layerState._denseLoadController = null;
|
layerState._denseLoadController = null;
|
||||||
@@ -102,11 +108,13 @@ export function createIngestion({
|
|||||||
// Process all TLE entries in CATALOG_GROUPS order
|
// Process all TLE entries in CATALOG_GROUPS order
|
||||||
const allEntries = [];
|
const allEntries = [];
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
for (const e of r.entries) allEntries.push({ ...e, group: r.tag });
|
for (const e of r.entries)
|
||||||
|
allEntries.push({ ...e, group: r.tag, stale: r.stale });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate by NORAD ID — first (most specific) group tag wins
|
// Deduplicate by NORAD ID — first (most specific) group tag wins
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
let coreStale = false;
|
||||||
|
|
||||||
for (const entry of allEntries) {
|
for (const entry of allEntries) {
|
||||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||||
@@ -123,6 +131,8 @@ export function createIngestion({
|
|||||||
group: entry.group,
|
group: entry.group,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
coreStale ||= entry.stale;
|
||||||
|
|
||||||
// Propagate initial position
|
// Propagate initial position
|
||||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||||
if (!pos) continue;
|
if (!pos) continue;
|
||||||
@@ -157,6 +167,7 @@ export function createIngestion({
|
|||||||
parts.labels._syncIssOverlay();
|
parts.labels._syncIssOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
layerState._coreStale = coreStale;
|
||||||
layerState._count = layerState._points.size;
|
layerState._count = layerState._points.size;
|
||||||
layerState._catalogRevision++;
|
layerState._catalogRevision++;
|
||||||
layerState._lastUpdate = Date.now();
|
layerState._lastUpdate = Date.now();
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function createLifecycle({
|
|||||||
layerState._orbitPaths = new Map();
|
layerState._orbitPaths = new Map();
|
||||||
layerState._count = 0;
|
layerState._count = 0;
|
||||||
layerState._lastUpdate = null;
|
layerState._lastUpdate = null;
|
||||||
|
layerState._coreStale = false;
|
||||||
|
layerState._denseStale = false;
|
||||||
layerState._trackedNorad = null;
|
layerState._trackedNorad = null;
|
||||||
parts.tracking._cancelPendingTrackingRestore();
|
parts.tracking._cancelPendingTrackingRestore();
|
||||||
layerState._trackedEntity = null;
|
layerState._trackedEntity = null;
|
||||||
@@ -162,6 +164,8 @@ export function createLifecycle({
|
|||||||
layerState._rowControlsListener = null;
|
layerState._rowControlsListener = null;
|
||||||
layerState._count = 0;
|
layerState._count = 0;
|
||||||
layerState._lastUpdate = null;
|
layerState._lastUpdate = null;
|
||||||
|
layerState._coreStale = false;
|
||||||
|
layerState._denseStale = false;
|
||||||
layerState._lastError = null;
|
layerState._lastError = null;
|
||||||
layerState._lastFocusUpdate = 0;
|
layerState._lastFocusUpdate = 0;
|
||||||
layerState._activeFocusCount = 0;
|
layerState._activeFocusCount = 0;
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { EventEmitter, once } from 'node:events';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createSatelliteSource } from './source.js';
|
||||||
|
import { createState } from './state.js';
|
||||||
|
import { createControls } from './controls.js';
|
||||||
|
import { createOrbits } from './orbits.js';
|
||||||
|
import { createLabels } from './labels.js';
|
||||||
|
import { createTracking } from './tracking.js';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
import { createCatalog } from './catalog.js';
|
||||||
|
import { createLifecycle } from './lifecycle.js';
|
||||||
|
import { createInteraction } from './interaction.js';
|
||||||
|
import { createIngestion } from './ingestion.js';
|
||||||
|
import { layerFeedState } from '../../data/feedState.js';
|
||||||
|
|
||||||
|
const GROUPS = [
|
||||||
|
'stations',
|
||||||
|
'visual',
|
||||||
|
'gps-ops',
|
||||||
|
'glo-ops',
|
||||||
|
'galileo',
|
||||||
|
'geo',
|
||||||
|
'starlink',
|
||||||
|
];
|
||||||
|
|
||||||
|
function tle(id, invalid = false) {
|
||||||
|
// Owned synthetic catalog IDs over one valid element set; never live requests.
|
||||||
|
const motion = invalid ? ' 0.00000000' : '15.48899044';
|
||||||
|
return `OWNED ORBIT FIXTURE ${id}\n1 ${id}U 98067A 26289.78971908 .00007803 00000+0 15012-3 0 9995\n2 ${id} 51.6326 58.3278 0006748 271.9059 88.1156 ${motion}590607`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(t) {
|
||||||
|
const policy = {
|
||||||
|
stale: new Set(),
|
||||||
|
invalid: new Set(),
|
||||||
|
ids: {},
|
||||||
|
status: 200,
|
||||||
|
hold: false,
|
||||||
|
};
|
||||||
|
const held = new EventEmitter();
|
||||||
|
let heldCount = 0;
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const group = req.url.split('/').at(-1);
|
||||||
|
if (policy.hold) {
|
||||||
|
held.emit('request', ++heldCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(policy.status, {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'X-TLE-Cache': policy.stale.has(group) ? 'STALE-ERROR' : 'MISS',
|
||||||
|
});
|
||||||
|
res.end(
|
||||||
|
tle(
|
||||||
|
policy.ids[group] ?? 25545 + GROUPS.indexOf(group),
|
||||||
|
policy.invalid.has(group),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const source = createSatelliteSource({
|
||||||
|
fetchImpl: (path, options) =>
|
||||||
|
fetch(`http://127.0.0.1:${server.address().port}${path}`, options),
|
||||||
|
});
|
||||||
|
// Application host services and native EventTargets; actual Cesium collections,
|
||||||
|
// input handler, events, SGP4, ingestion and lifecycle run without a WebGL scene.
|
||||||
|
const noop = () => {};
|
||||||
|
const services = {
|
||||||
|
overlays: {
|
||||||
|
setOverlayEntries: noop,
|
||||||
|
setOverlaySourceVisible: noop,
|
||||||
|
clearOverlaySource: noop,
|
||||||
|
},
|
||||||
|
layerState: { isExplicitLayerStateOrigin: () => false },
|
||||||
|
focus: {
|
||||||
|
clearFocusTarget: noop,
|
||||||
|
publishFocusTargetFromCachedPosition: noop,
|
||||||
|
getFocusTarget: () => null,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
clearTrackedSubjectContext: noop,
|
||||||
|
getContextStore: () => ({ entities: new Map() }),
|
||||||
|
refreshTrackedSubjectContext: noop,
|
||||||
|
selectTrackedSubjectContext: noop,
|
||||||
|
},
|
||||||
|
readout: { refreshTrackedReadout: noop },
|
||||||
|
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||||
|
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||||
|
};
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
globalThis.document = new EventTarget();
|
||||||
|
const state = createState({ services });
|
||||||
|
const parts = {};
|
||||||
|
const context = { state, services, parts, source };
|
||||||
|
for (const [name, factory] of Object.entries({
|
||||||
|
controls: createControls,
|
||||||
|
orbits: createOrbits,
|
||||||
|
labels: createLabels,
|
||||||
|
tracking: createTracking,
|
||||||
|
rendering: createRendering,
|
||||||
|
catalog: createCatalog,
|
||||||
|
interaction: createInteraction,
|
||||||
|
lifecycle: createLifecycle,
|
||||||
|
ingestion: createIngestion,
|
||||||
|
})) {
|
||||||
|
parts[name] = factory(context);
|
||||||
|
}
|
||||||
|
const viewer = {
|
||||||
|
scene: {
|
||||||
|
canvas: new EventTarget(),
|
||||||
|
primitives: new Cesium.PrimitiveCollection(),
|
||||||
|
preRender: new Cesium.Event(),
|
||||||
|
},
|
||||||
|
trackedEntityChanged: new Cesium.Event(),
|
||||||
|
};
|
||||||
|
await parts.lifecycle.methods.init(viewer);
|
||||||
|
t.after(async () => {
|
||||||
|
if (state._pointCollection) parts.lifecycle.methods.destroy(viewer);
|
||||||
|
viewer.scene.primitives.destroy();
|
||||||
|
if (previousDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = previousDocument;
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
viewer,
|
||||||
|
policy,
|
||||||
|
update: (options) => parts.ingestion.methods.update(viewer, options),
|
||||||
|
stats: () => parts.controls.methods.getStats(),
|
||||||
|
feed: () => layerFeedState(parts.controls.methods.getStats()),
|
||||||
|
async waitHeld(count) {
|
||||||
|
while (heldCount < count)
|
||||||
|
await once(held, 'request', { signal: AbortSignal.timeout(3000) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stale core records surface through actual HTTP, ingestion and feed state, then recover', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.feed(), 'nominal');
|
||||||
|
assert.equal(f.stats().count, 6);
|
||||||
|
f.policy.stale = new Set(GROUPS.slice(0, 6));
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().count, 6);
|
||||||
|
assert.equal(f.stats().stale, true);
|
||||||
|
assert.equal(f.feed(), 'stale');
|
||||||
|
f.policy.stale = new Set(['visual']);
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.feed(), 'stale');
|
||||||
|
f.policy.stale.clear();
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().stale, false);
|
||||||
|
assert.equal(f.feed(), 'nominal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale duplicate cannot taint its earlier fresh winning record', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
f.policy.stale.add('visual');
|
||||||
|
f.policy.ids.visual = 25545;
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().count, 5);
|
||||||
|
assert.equal(f.state._catalog.get(25545).group, 'stations');
|
||||||
|
assert.equal(f.stats().stale, false);
|
||||||
|
assert.equal(f.feed(), 'nominal');
|
||||||
|
f.policy.stale = new Set(['stations']);
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().count, 5);
|
||||||
|
assert.equal(
|
||||||
|
f.stats().stale,
|
||||||
|
true,
|
||||||
|
'a later fresh duplicate does not replace the stale winner',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid stale orbital elements are not admitted as provenance', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
f.policy.stale.add('visual');
|
||||||
|
f.policy.invalid.add('visual');
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().count, 5);
|
||||||
|
assert.equal(f.state._catalog.has(25546), false);
|
||||||
|
assert.equal(f.stats().stale, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed and aborted core refreshes keep provenance of retained records', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
f.policy.stale.add('stations');
|
||||||
|
await f.update();
|
||||||
|
const priorTime = f.stats().lastUpdate;
|
||||||
|
f.policy.status = 503;
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().count, 6);
|
||||||
|
assert.equal(f.stats().stale, true);
|
||||||
|
assert.equal(f.stats().status, 'unavailable');
|
||||||
|
assert.equal(f.stats().lastUpdate, priorTime);
|
||||||
|
f.policy.hold = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const work = f.update({ signal: controller.signal });
|
||||||
|
await f.waitHeld(6);
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(work, { name: 'AbortError' });
|
||||||
|
assert.equal(f.stats().count, 6);
|
||||||
|
assert.equal(f.stats().stale, true);
|
||||||
|
assert.equal(f.stats().lastUpdate, priorTime);
|
||||||
|
assert.equal(f.state._activeUpdateControllers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dense provenance belongs to its admitted records and clears on removal', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.update();
|
||||||
|
f.policy.stale.add('starlink');
|
||||||
|
f.state._params.catalog = 'dense';
|
||||||
|
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
|
||||||
|
assert.equal(f.stats().count, 7);
|
||||||
|
assert.equal(f.feed(), 'stale');
|
||||||
|
f.state._params.catalog = 'core';
|
||||||
|
f.parts.catalog._removeDenseCatalog();
|
||||||
|
assert.equal(f.stats().count, 6);
|
||||||
|
assert.equal(f.feed(), 'nominal');
|
||||||
|
f.policy.stale = new Set(['stations']);
|
||||||
|
await f.update();
|
||||||
|
f.state._params.catalog = 'dense';
|
||||||
|
await f.parts.catalog._loadDenseCatalog();
|
||||||
|
f.state._params.catalog = 'core';
|
||||||
|
f.parts.catalog._removeDenseCatalog();
|
||||||
|
assert.equal(
|
||||||
|
f.feed(),
|
||||||
|
'stale',
|
||||||
|
'removing dense data must not clear core provenance',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await f.update();
|
||||||
|
f.policy.stale.add('starlink');
|
||||||
|
f.state._params.catalog = 'dense';
|
||||||
|
await f.parts.catalog._loadDenseCatalog();
|
||||||
|
f.policy.hold = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const work = f.parts.catalog._loadDenseCatalog({ signal: controller.signal });
|
||||||
|
await f.waitHeld(1);
|
||||||
|
controller.abort();
|
||||||
|
assert.equal((await work).status, 'cancelled');
|
||||||
|
assert.equal(f.stats().count, 7);
|
||||||
|
assert.equal(f.feed(), 'stale');
|
||||||
|
f.policy.hold = false;
|
||||||
|
f.policy.stale.clear();
|
||||||
|
f.state._params.catalog = 'core';
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().count, 6);
|
||||||
|
assert.equal(f.feed(), 'nominal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy aborts an in-flight response and initialization starts with new ownership', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
f.policy.stale.add('stations');
|
||||||
|
await f.update();
|
||||||
|
f.policy.hold = true;
|
||||||
|
const work = f.update();
|
||||||
|
await f.waitHeld(6);
|
||||||
|
const rejected = assert.rejects(work, { name: 'AbortError' });
|
||||||
|
f.parts.lifecycle.methods.destroy(f.viewer);
|
||||||
|
assert.equal(f.stats().count, 0);
|
||||||
|
assert.equal(f.stats().stale, false);
|
||||||
|
await f.parts.lifecycle.methods.init(f.viewer);
|
||||||
|
await rejected;
|
||||||
|
assert.equal(f.stats().stale, false);
|
||||||
|
f.policy.hold = false;
|
||||||
|
f.policy.stale.clear();
|
||||||
|
await f.update();
|
||||||
|
assert.equal(f.stats().count, 6);
|
||||||
|
assert.equal(f.feed(), 'nominal');
|
||||||
|
});
|
||||||
@@ -40,6 +40,10 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
state._lastError = null;
|
state._lastError = null;
|
||||||
|
|
||||||
|
// Cache provenance belongs to admitted core and dense records separately.
|
||||||
|
state._coreStale = false;
|
||||||
|
state._denseStale = false;
|
||||||
|
|
||||||
state._activeUpdateControllers = new Set();
|
state._activeUpdateControllers = new Set();
|
||||||
|
|
||||||
state._denseLoadController = null;
|
state._denseLoadController = null;
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
|
||||||
|
|
||||||
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
|
|
||||||
const SURFACE_MIN_M = -500;
|
|
||||||
const SURFACE_MAX_M = 9000;
|
|
||||||
|
|
||||||
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
|
|
||||||
export function createCameraFollow({ state, parts }) {
|
|
||||||
const { render } = state.services;
|
|
||||||
|
|
||||||
function requestRender() {
|
|
||||||
render?.governorRequestRender?.('street-level-follow');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The application's camera authority (`attachNavigation`): `run(noun, move)`
|
|
||||||
* releases tracking and other owners before `move`, or refuses (cockpit);
|
|
||||||
* `begin(noun)` takes a deferred ticket (a generation, or false) that
|
|
||||||
* `reassert(generation)` honours only if nothing newer took the camera;
|
|
||||||
* `subscribeHandoff` reports a newer owner. Without it, moves run directly.
|
|
||||||
*/
|
|
||||||
let navigation = null;
|
|
||||||
let unsubscribeHandoff = null;
|
|
||||||
/** Set while our own claim stamps a handoff, so it does not end FOLLOW. */
|
|
||||||
let claiming = false;
|
|
||||||
|
|
||||||
/** Take the camera for `move`; false when the application refuses. */
|
|
||||||
function claim(move) {
|
|
||||||
if (!navigation?.run) {
|
|
||||||
move();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
claiming = true;
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
navigation.run('photo', () => {
|
|
||||||
move();
|
|
||||||
return true;
|
|
||||||
}) === true
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
claiming = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask for the camera as a photo starts opening. The ticket goes to
|
|
||||||
* `lookAtPosition`; null when the application refuses (cockpit).
|
|
||||||
*/
|
|
||||||
function beginFraming() {
|
|
||||||
if (!navigation?.begin || !navigation.reassert) return { generation: null };
|
|
||||||
claiming = true;
|
|
||||||
try {
|
|
||||||
const generation = navigation.begin('photo');
|
|
||||||
return generation === false ? null : { generation };
|
|
||||||
} finally {
|
|
||||||
claiming = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Another feature took the camera: stop following it. */
|
|
||||||
function onHandoff() {
|
|
||||||
if (claiming || !state.street.follow) return;
|
|
||||||
state.street.follow = false;
|
|
||||||
state.notify?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachNavigation(next) {
|
|
||||||
unsubscribeHandoff?.();
|
|
||||||
navigation = next || null;
|
|
||||||
unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ground under a photo. `sampleHeight` can return kilometres below the
|
|
||||||
* ellipsoid before tiles load, so a sample counts only when plausible and
|
|
||||||
* within the mesh window around bare earth (once that is known).
|
|
||||||
*/
|
|
||||||
function groundHeightAt(lon, lat, fallback) {
|
|
||||||
const scene = state.viewer?.scene;
|
|
||||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
|
||||||
const caster = parts?.groundCaster;
|
|
||||||
const dem = caster?.groundAt(lon, lat) ?? null;
|
|
||||||
if (dem === null) caster?.prepare([[lon, lat]]);
|
|
||||||
// The application's mesh window around bare earth, once that is known.
|
|
||||||
const withinPrior = state.services.ground?.meshFloorSampleWithinPrior;
|
|
||||||
const plausible = (height) =>
|
|
||||||
Number.isFinite(height) &&
|
|
||||||
height >= SURFACE_MIN_M &&
|
|
||||||
height <= SURFACE_MAX_M &&
|
|
||||||
(dem === null || !withinPrior || withinPrior(height, dem));
|
|
||||||
let height = null;
|
|
||||||
try {
|
|
||||||
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
|
||||||
} catch {
|
|
||||||
/* not sampleable yet */
|
|
||||||
}
|
|
||||||
if (!plausible(height)) height = dem;
|
|
||||||
if (!plausible(height)) height = scene?.globe?.getHeight?.(carto);
|
|
||||||
if (!plausible(height)) height = fallback;
|
|
||||||
return plausible(height) ? height : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Put the Cesium camera where the street-level camera is. */
|
|
||||||
function followCamera() {
|
|
||||||
const { position, bearing, tilt, follow, altitude } = state.street;
|
|
||||||
if (!follow || !position || !state.viewer) return;
|
|
||||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
|
||||||
state.viewer.camera.setView({
|
|
||||||
destination: Cesium.Cartesian3.fromDegrees(
|
|
||||||
position.lon,
|
|
||||||
position.lat,
|
|
||||||
ground + FOLLOW_EYE_HEIGHT_M,
|
|
||||||
),
|
|
||||||
orientation: {
|
|
||||||
heading: Cesium.Math.toRadians(bearing || 0),
|
|
||||||
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
|
|
||||||
roll: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
|
|
||||||
let framing = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frame the current image from a short distance behind it, unless another
|
|
||||||
* action took the camera since `ticket` (from `beginFraming`) was issued.
|
|
||||||
*/
|
|
||||||
function lookAtPosition(ticket) {
|
|
||||||
const { position, bearing, altitude } = state.street;
|
|
||||||
if (!ticket || !position || !state.viewer) return;
|
|
||||||
if (
|
|
||||||
ticket.generation !== null &&
|
|
||||||
!navigation?.reassert?.(ticket.generation)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
|
||||||
const fly = () => {
|
|
||||||
// Owned before the call: starting it cancels the previous flight (ours
|
|
||||||
// included) synchronously, and a zero-length one completes at once.
|
|
||||||
const flight = {};
|
|
||||||
framing = flight;
|
|
||||||
const release = () => {
|
|
||||||
if (framing === flight) framing = null;
|
|
||||||
};
|
|
||||||
state.viewer.camera.flyToBoundingSphere(
|
|
||||||
new Cesium.BoundingSphere(
|
|
||||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
|
||||||
4,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
offset: new Cesium.HeadingPitchRange(
|
|
||||||
Cesium.Math.toRadians(bearing || 0),
|
|
||||||
Cesium.Math.toRadians(-32),
|
|
||||||
140,
|
|
||||||
),
|
|
||||||
duration: 1.6,
|
|
||||||
complete: release,
|
|
||||||
cancel: release,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
// A reasserted ticket has already released the other owners.
|
|
||||||
if (ticket.generation === null) claim(fly);
|
|
||||||
else fly();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
|
|
||||||
function cancelFraming() {
|
|
||||||
if (!framing) return;
|
|
||||||
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
|
|
||||||
framing = null;
|
|
||||||
state.viewer?.camera?.cancelFlight?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFollow(enabled) {
|
|
||||||
const wanted = enabled === true && state.street.followAvailable;
|
|
||||||
if (wanted && !state.street.follow) {
|
|
||||||
// Following owns the camera until another feature takes it.
|
|
||||||
const granted = claim(() => {
|
|
||||||
state.street.follow = true;
|
|
||||||
// The framing tween would keep overwriting the follow view until it lands.
|
|
||||||
cancelFraming();
|
|
||||||
followCamera();
|
|
||||||
});
|
|
||||||
if (!granted) state.street.follow = false;
|
|
||||||
} else if (!wanted) {
|
|
||||||
state.street.follow = false;
|
|
||||||
}
|
|
||||||
state.notify?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy() {
|
|
||||||
attachNavigation(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
followCamera,
|
|
||||||
beginFraming,
|
|
||||||
lookAtPosition,
|
|
||||||
cancelFraming,
|
|
||||||
setFollow,
|
|
||||||
attachNavigation,
|
|
||||||
destroy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import { createCameraFollow } from './cameraFollow.js';
|
|
||||||
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
|
||||||
|
|
||||||
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
|
||||||
const heightOf = (cartesian) =>
|
|
||||||
Cesium.Cartographic.fromCartesian(cartesian).height;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
|
|
||||||
* cancels the current one, `land()` completes it, `cancelled` counts stops.
|
|
||||||
*/
|
|
||||||
function flightCamera(flights, views) {
|
|
||||||
let current = null;
|
|
||||||
function stop() {
|
|
||||||
const flight = current;
|
|
||||||
current = null;
|
|
||||||
flight?.options.cancel?.();
|
|
||||||
return Boolean(flight);
|
|
||||||
}
|
|
||||||
const camera = {
|
|
||||||
cancelled: 0,
|
|
||||||
flyToBoundingSphere(sphere, options = {}) {
|
|
||||||
stop();
|
|
||||||
current = { sphere, options };
|
|
||||||
flights.push(current);
|
|
||||||
},
|
|
||||||
cancelFlight() {
|
|
||||||
if (stop()) camera.cancelled++;
|
|
||||||
},
|
|
||||||
land() {
|
|
||||||
const flight = current;
|
|
||||||
current = null;
|
|
||||||
flight?.options.complete?.();
|
|
||||||
},
|
|
||||||
setView: (view) => views.push(view),
|
|
||||||
};
|
|
||||||
return camera;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
|
|
||||||
* under the photo load) and whose bare earth is `dem`.
|
|
||||||
*/
|
|
||||||
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
|
|
||||||
const flights = [];
|
|
||||||
const views = [];
|
|
||||||
const state = {
|
|
||||||
services: {},
|
|
||||||
street: {
|
|
||||||
position: { lon: -97.7364, lat: 30.2672 },
|
|
||||||
bearing: 105,
|
|
||||||
tilt: -10,
|
|
||||||
altitude,
|
|
||||||
follow: false,
|
|
||||||
followAvailable: true,
|
|
||||||
},
|
|
||||||
viewer: {
|
|
||||||
scene: {
|
|
||||||
sampleHeightSupported: true,
|
|
||||||
sampleHeight: () => sampled,
|
|
||||||
globe: { getHeight: () => globe },
|
|
||||||
},
|
|
||||||
camera: flightCamera(flights, views),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const parts = {
|
|
||||||
groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} },
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
follow: createCameraFollow({ state, parts }),
|
|
||||||
state,
|
|
||||||
flights,
|
|
||||||
views,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('framing a photo ignores a sample kilometres underground and uses bare earth', () => {
|
|
||||||
const { follow, flights } = setup({ sampled: -14_886, dem: 117 });
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
assert.equal(flights.length, 1);
|
|
||||||
const centre = heightOf(flights[0].sphere.center);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(centre - 119) < 0.01,
|
|
||||||
`centre at ${centre} m, not underground`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => {
|
|
||||||
const { follow, flights } = setup({ sampled: 121, dem: 117 });
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => {
|
|
||||||
const { follow, flights } = setup({
|
|
||||||
sampled: -14_886,
|
|
||||||
dem: null,
|
|
||||||
globe: undefined,
|
|
||||||
altitude: 149,
|
|
||||||
});
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('following stands the camera at eye height above the checked ground', () => {
|
|
||||||
const { follow, state, views } = setup({ sampled: -14_886, dem: 117 });
|
|
||||||
state.street.follow = true;
|
|
||||||
follow.followCamera();
|
|
||||||
assert.equal(views.length, 1);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) <
|
|
||||||
0.01,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cancelFraming stops the framing flight while it is still ours', () => {
|
|
||||||
const { follow, state } = setup({ sampled: 121, dem: 117 });
|
|
||||||
const { camera } = state.viewer;
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
follow.cancelFraming();
|
|
||||||
assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops');
|
|
||||||
follow.cancelFraming();
|
|
||||||
assert.equal(camera.cancelled, 1, 'and only once');
|
|
||||||
|
|
||||||
// Re-framing (the next photo) supersedes the first flight, not the claim.
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
follow.cancelFraming();
|
|
||||||
assert.equal(camera.cancelled, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
|
|
||||||
const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
|
|
||||||
const { camera } = state.viewer;
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
camera.land();
|
|
||||||
follow.cancelFraming();
|
|
||||||
assert.equal(camera.cancelled, 0, 'landed: nothing to stop');
|
|
||||||
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
// A search result flies the globe elsewhere; Cesium cancels ours first.
|
|
||||||
camera.flyToBoundingSphere(null, {});
|
|
||||||
assert.equal(flights.length, 3);
|
|
||||||
follow.cancelFraming();
|
|
||||||
assert.equal(camera.cancelled, 0, 'the newer flight keeps going');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('turning FOLLOW on stops the framing flight so it cannot fight the follow view', () => {
|
|
||||||
const { follow, state, views } = setup({ sampled: 121, dem: 117 });
|
|
||||||
const { camera } = state.viewer;
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
follow.setFollow(true);
|
|
||||||
assert.equal(camera.cancelled, 1, 'the framing tween stops');
|
|
||||||
assert.equal(views.length, 1, 'and the camera stands at the photo');
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The application's camera authority: `run` releases tracking (here, a tracked
|
|
||||||
* entity) and stamps a handoff before the move, or refuses like the cockpit.
|
|
||||||
*/
|
|
||||||
function navigationFor(state, { refuse = false } = {}) {
|
|
||||||
const listeners = new Set();
|
|
||||||
let generation = 0;
|
|
||||||
const stamp = () => {
|
|
||||||
generation++;
|
|
||||||
for (const listener of listeners) listener({ generation });
|
|
||||||
return generation;
|
|
||||||
};
|
|
||||||
const nav = {
|
|
||||||
runs: [],
|
|
||||||
begins: [],
|
|
||||||
run(noun, move) {
|
|
||||||
nav.runs.push(noun);
|
|
||||||
if (refuse) return false;
|
|
||||||
const current = stamp();
|
|
||||||
state.viewer.trackedEntity = undefined;
|
|
||||||
return move(current);
|
|
||||||
},
|
|
||||||
/** Deferred: stamp now, release only on a successful reassert. */
|
|
||||||
begin(noun) {
|
|
||||||
nav.begins.push(noun);
|
|
||||||
return refuse ? false : stamp();
|
|
||||||
},
|
|
||||||
reassert(ticket) {
|
|
||||||
if (ticket !== generation) return false;
|
|
||||||
state.viewer.trackedEntity = undefined;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
subscribeHandoff(listener) {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => listeners.delete(listener);
|
|
||||||
},
|
|
||||||
/** Another feature takes the camera. */
|
|
||||||
handOff: () => stamp(),
|
|
||||||
listeners,
|
|
||||||
};
|
|
||||||
return nav;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
|
|
||||||
const { follow, state, flights } = setup({ sampled: 160 });
|
|
||||||
state.viewer.trackedEntity = { id: 'aircraft' };
|
|
||||||
const nav = navigationFor(state);
|
|
||||||
follow.attachNavigation(nav);
|
|
||||||
const ticket = follow.beginFraming();
|
|
||||||
assert.deepEqual(nav.begins, ['photo']);
|
|
||||||
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
|
|
||||||
follow.lookAtPosition(ticket);
|
|
||||||
assert.equal(state.viewer.trackedEntity, undefined);
|
|
||||||
assert.equal(flights.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a photo that finishes loading after newer navigation does not frame', () => {
|
|
||||||
const { follow, state, flights } = setup({ sampled: 160 });
|
|
||||||
const nav = navigationFor(state);
|
|
||||||
follow.attachNavigation(nav);
|
|
||||||
const ticket = follow.beginFraming();
|
|
||||||
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
|
|
||||||
nav.handOff();
|
|
||||||
state.viewer.trackedEntity = { id: 'aircraft' };
|
|
||||||
follow.lookAtPosition(ticket);
|
|
||||||
assert.equal(flights.length, 0, 'no flight back to the photo');
|
|
||||||
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => {
|
|
||||||
const { follow, state, flights, views } = setup({ sampled: 160 });
|
|
||||||
follow.attachNavigation(navigationFor(state, { refuse: true }));
|
|
||||||
follow.lookAtPosition(follow.beginFraming());
|
|
||||||
follow.setFollow(true);
|
|
||||||
assert.equal(flights.length, 0);
|
|
||||||
assert.equal(views.length, 0);
|
|
||||||
assert.equal(state.street.follow, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('FOLLOW claims the camera once and ends when another feature takes it', () => {
|
|
||||||
const { follow, state, views } = setup({ sampled: 160 });
|
|
||||||
let notified = 0;
|
|
||||||
state.notify = () => notified++;
|
|
||||||
const nav = navigationFor(state);
|
|
||||||
follow.attachNavigation(nav);
|
|
||||||
follow.setFollow(true);
|
|
||||||
assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on');
|
|
||||||
assert.deepEqual(nav.runs, ['photo']);
|
|
||||||
follow.followCamera();
|
|
||||||
assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again');
|
|
||||||
assert.equal(views.length, 2);
|
|
||||||
nav.handOff();
|
|
||||||
assert.equal(state.street.follow, false);
|
|
||||||
assert.ok(notified >= 2);
|
|
||||||
follow.followCamera();
|
|
||||||
assert.equal(views.length, 2, 'the camera is no longer driven');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('destroying the follow part stops listening for handoffs', () => {
|
|
||||||
const { follow, state } = setup({ sampled: 160 });
|
|
||||||
const nav = navigationFor(state);
|
|
||||||
follow.attachNavigation(nav);
|
|
||||||
assert.equal(nav.listeners.size, 1);
|
|
||||||
follow.destroy();
|
|
||||||
assert.equal(nav.listeners.size, 0);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
|
||||||
|
|
||||||
|
/** Fly the globe camera once to frame a photo after it opens. */
|
||||||
|
export function createCameraFraming({ state }) {
|
||||||
|
/**
|
||||||
|
* The application's camera authority: `reassert(begin())` succeeds only if
|
||||||
|
* nothing newer took the camera. Without it, framing flies directly.
|
||||||
|
*/
|
||||||
|
let navigation = null;
|
||||||
|
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
|
||||||
|
let flight = null;
|
||||||
|
|
||||||
|
function attachNavigation(next) {
|
||||||
|
navigation = next || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask for the camera as a photo starts opening: a ticket for `frame`, null when refused (cockpit). */
|
||||||
|
function begin() {
|
||||||
|
if (!navigation?.begin || !navigation.reassert) return { generation: null };
|
||||||
|
const generation = navigation.begin('photo');
|
||||||
|
return generation === false ? null : { generation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground under a photo. `sampleHeight` can return kilometres below the
|
||||||
|
* ellipsoid before tiles load, so only a plausible height counts.
|
||||||
|
*/
|
||||||
|
function groundHeightAt(lon, lat, fallback) {
|
||||||
|
const scene = state.viewer?.scene;
|
||||||
|
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||||
|
let height = sampledSurfaceHeight(scene, carto);
|
||||||
|
if (height === null) height = scene?.globe?.getHeight?.(carto);
|
||||||
|
if (!plausibleSurfaceHeight(height)) height = fallback;
|
||||||
|
return plausibleSurfaceHeight(height) ? height : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame the open photo from a short distance behind it, unless another
|
||||||
|
* action took the camera since `ticket` (from `begin`) was issued.
|
||||||
|
*/
|
||||||
|
function frame(ticket) {
|
||||||
|
const { position, bearing, altitude } = state.street;
|
||||||
|
if (!ticket || !position || !state.viewer) return;
|
||||||
|
// Detached (shell teardown) since the ticket was issued: nothing to frame for.
|
||||||
|
if (ticket.generation !== null && !navigation?.reassert(ticket.generation))
|
||||||
|
return;
|
||||||
|
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||||
|
// Owned before the call: starting it cancels the previous flight (ours
|
||||||
|
// included) synchronously, and a zero-length one completes at once.
|
||||||
|
const current = {};
|
||||||
|
flight = current;
|
||||||
|
const release = () => {
|
||||||
|
if (flight === current) flight = null;
|
||||||
|
};
|
||||||
|
state.viewer.camera.flyToBoundingSphere(
|
||||||
|
new Cesium.BoundingSphere(
|
||||||
|
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
offset: new Cesium.HeadingPitchRange(
|
||||||
|
Cesium.Math.toRadians(bearing || 0),
|
||||||
|
Cesium.Math.toRadians(-32),
|
||||||
|
140,
|
||||||
|
),
|
||||||
|
duration: 1.6,
|
||||||
|
complete: release,
|
||||||
|
cancel: release,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
|
||||||
|
function cancel() {
|
||||||
|
if (!flight) return;
|
||||||
|
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
|
||||||
|
flight = null;
|
||||||
|
state.viewer?.camera?.cancelFlight?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachNavigation, begin, frame, cancel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createCameraFraming } from './cameraFraming.js';
|
||||||
|
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
||||||
|
const heightOf = (cartesian) =>
|
||||||
|
Cesium.Cartographic.fromCartesian(cartesian).height;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
|
||||||
|
* under the photo load) and whose globe terrain is `globe`.
|
||||||
|
*/
|
||||||
|
function setup({ sampled, globe = null, altitude = 149 }) {
|
||||||
|
const viewer = fakeCesiumViewer();
|
||||||
|
Object.assign(viewer.scene, {
|
||||||
|
sampleHeightSupported: true,
|
||||||
|
sampleHeight: () => sampled,
|
||||||
|
globe: { getHeight: () => globe },
|
||||||
|
});
|
||||||
|
const state = {
|
||||||
|
services: {},
|
||||||
|
street: {
|
||||||
|
position: { lon: -97.7364, lat: 30.2672 },
|
||||||
|
bearing: 105,
|
||||||
|
altitude,
|
||||||
|
},
|
||||||
|
viewer,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
framing: createCameraFraming({ state }),
|
||||||
|
state,
|
||||||
|
viewer,
|
||||||
|
flights: viewer.flights.list,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('framing a photo stands the sphere on a plausible scene sample', () => {
|
||||||
|
const { framing, flights } = setup({ sampled: 121 });
|
||||||
|
framing.frame(framing.begin());
|
||||||
|
assert.equal(flights.length, 1);
|
||||||
|
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sample kilometres underground falls back to the globe, then the image altitude', () => {
|
||||||
|
const globe = setup({ sampled: -14_886, globe: 117 });
|
||||||
|
globe.framing.frame(globe.framing.begin());
|
||||||
|
assert.ok(Math.abs(heightOf(globe.flights[0].sphere.center) - 119) < 0.01);
|
||||||
|
const altitude = setup({
|
||||||
|
sampled: -14_886,
|
||||||
|
globe: undefined,
|
||||||
|
altitude: 149,
|
||||||
|
});
|
||||||
|
altitude.framing.frame(altitude.framing.begin());
|
||||||
|
assert.ok(Math.abs(heightOf(altitude.flights[0].sphere.center) - 151) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel stops the framing flight while it is still ours', () => {
|
||||||
|
const { framing, viewer } = setup({ sampled: 121 });
|
||||||
|
framing.frame(framing.begin());
|
||||||
|
framing.cancel();
|
||||||
|
assert.equal(
|
||||||
|
viewer.flights.cancelled,
|
||||||
|
1,
|
||||||
|
'the flight toward the photo stops',
|
||||||
|
);
|
||||||
|
framing.cancel();
|
||||||
|
assert.equal(viewer.flights.cancelled, 1, 'and only once');
|
||||||
|
|
||||||
|
// Re-framing (the next photo) supersedes the first flight, not the claim.
|
||||||
|
framing.frame(framing.begin());
|
||||||
|
framing.frame(framing.begin());
|
||||||
|
framing.cancel();
|
||||||
|
assert.equal(viewer.flights.cancelled, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel leaves a landed flight and a newer navigation flight alone', () => {
|
||||||
|
const { framing, viewer, flights } = setup({ sampled: 121 });
|
||||||
|
framing.frame(framing.begin());
|
||||||
|
viewer.flights.land();
|
||||||
|
framing.cancel();
|
||||||
|
assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop');
|
||||||
|
|
||||||
|
framing.frame(framing.begin());
|
||||||
|
// A search result flies the globe elsewhere; Cesium cancels ours first.
|
||||||
|
viewer.camera.flyToBoundingSphere(null, {});
|
||||||
|
assert.equal(flights.length, 3);
|
||||||
|
framing.cancel();
|
||||||
|
assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's camera authority: `begin` stamps a generation (or refuses
|
||||||
|
* like the cockpit), `reassert` releases tracking (here, a tracked entity)
|
||||||
|
* only while that generation is current.
|
||||||
|
*/
|
||||||
|
function navigationFor(state, { refuse = false } = {}) {
|
||||||
|
let generation = 0;
|
||||||
|
const stamp = () => ++generation;
|
||||||
|
const nav = {
|
||||||
|
begins: [],
|
||||||
|
/** Deferred: stamp now, release only on a successful reassert. */
|
||||||
|
begin(noun) {
|
||||||
|
nav.begins.push(noun);
|
||||||
|
return refuse ? false : stamp();
|
||||||
|
},
|
||||||
|
reassert(ticket) {
|
||||||
|
if (ticket !== generation) return false;
|
||||||
|
state.viewer.trackedEntity = undefined;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
/** Another feature takes the camera. */
|
||||||
|
handOff: () => stamp(),
|
||||||
|
};
|
||||||
|
return nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
|
||||||
|
const { framing, state, flights } = setup({ sampled: 160 });
|
||||||
|
state.viewer.trackedEntity = { id: 'aircraft' };
|
||||||
|
const nav = navigationFor(state);
|
||||||
|
framing.attachNavigation(nav);
|
||||||
|
const ticket = framing.begin();
|
||||||
|
assert.deepEqual(nav.begins, ['photo']);
|
||||||
|
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
|
||||||
|
framing.frame(ticket);
|
||||||
|
assert.equal(state.viewer.trackedEntity, undefined);
|
||||||
|
assert.equal(flights.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a photo that finishes loading after newer navigation does not frame', () => {
|
||||||
|
const { framing, state, flights } = setup({ sampled: 160 });
|
||||||
|
const nav = navigationFor(state);
|
||||||
|
framing.attachNavigation(nav);
|
||||||
|
const ticket = framing.begin();
|
||||||
|
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
|
||||||
|
nav.handOff();
|
||||||
|
state.viewer.trackedEntity = { id: 'aircraft' };
|
||||||
|
framing.frame(ticket);
|
||||||
|
assert.equal(flights.length, 0, 'no flight back to the photo');
|
||||||
|
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refused claim (cockpit) does not frame the photo', () => {
|
||||||
|
const { framing, state, flights } = setup({ sampled: 160 });
|
||||||
|
framing.attachNavigation(navigationFor(state, { refuse: true }));
|
||||||
|
framing.frame(framing.begin());
|
||||||
|
assert.equal(flights.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a photo that loads after the navigation was detached does not frame', () => {
|
||||||
|
const { framing, state, flights } = setup({ sampled: 160 });
|
||||||
|
framing.attachNavigation(navigationFor(state));
|
||||||
|
const ticket = framing.begin();
|
||||||
|
framing.attachNavigation(null);
|
||||||
|
assert.doesNotThrow(() => framing.frame(ticket));
|
||||||
|
assert.equal(flights.length, 0);
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ export function sameFilter(a, b) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stored filter (relative days, stable in share links) to the absolute form
|
* Stored filter (relative days, stable in share links) to the absolute form
|
||||||
* providers compare capture times against.
|
* coverage, cones and lookups compare capture times against.
|
||||||
* @returns {{pano: string, sinceMs: number|null}}
|
* @returns {{pano: string, sinceMs: number|null}}
|
||||||
*/
|
*/
|
||||||
export function resolveFilter(filter, now = Date.now()) {
|
export function resolveFilter(filter, now = Date.now()) {
|
||||||
|
|||||||
@@ -1,48 +1,27 @@
|
|||||||
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
|
/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position markers. */
|
||||||
|
|
||||||
const _cache = new Map();
|
const px = (n) => Number(n.toFixed(2));
|
||||||
|
|
||||||
/** A square canvas glyph drawn once per key, centred at (c, c). */
|
function svg(size, body) {
|
||||||
function cachedGlyph(key, size, draw) {
|
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${body}</svg>`;
|
||||||
if (_cache.has(key)) return _cache.get(key);
|
return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`;
|
||||||
const element = document.createElement('canvas');
|
|
||||||
element.width = size;
|
|
||||||
element.height = size;
|
|
||||||
draw(element.getContext('2d'), size / 2);
|
|
||||||
_cache.set(key, element);
|
|
||||||
return element;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
|
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
|
||||||
function wedge(ctx, c, radius, halfAngle, color, alpha) {
|
function wedge(size, radius, halfAngle, color, opacity) {
|
||||||
ctx.beginPath();
|
const c = size / 2;
|
||||||
ctx.moveTo(c, c);
|
const dx = px(radius * Math.sin(halfAngle));
|
||||||
ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle);
|
const dy = px(radius * Math.cos(halfAngle));
|
||||||
ctx.closePath();
|
const r = px(radius);
|
||||||
ctx.fillStyle = color;
|
return `<path d="M${c} ${c}L${px(c - dx)} ${px(c - dy)}A${r} ${r} 0 0 1 ${px(c + dx)} ${px(c - dy)}Z" fill="${color}" fill-opacity="${opacity}"/>`;
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ring(ctx, c, radius, color, width, alpha) {
|
function ring(size, radius, color, width, opacity) {
|
||||||
ctx.beginPath();
|
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="none" stroke="${color}" stroke-width="${px(width)}" stroke-opacity="${opacity}"/>`;
|
||||||
ctx.arc(c, c, radius, 0, Math.PI * 2);
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = width;
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dot(ctx, c, radius, color, outline, width) {
|
function dot(size, radius, color, outline, width) {
|
||||||
ctx.beginPath();
|
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="${color}" stroke="${outline}" stroke-width="${px(width)}"/>`;
|
||||||
ctx.arc(c, c, radius, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.lineWidth = width;
|
|
||||||
ctx.strokeStyle = outline;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
|
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
|
||||||
@@ -51,34 +30,38 @@ export function imageConeGlyph({
|
|||||||
color = '#e8eaed',
|
color = '#e8eaed',
|
||||||
pano = false,
|
pano = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
|
const shape = pano
|
||||||
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
|
? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5)
|
||||||
else wedge(ctx, c, size * 0.46, 0.62, color, 0.42);
|
: wedge(size, size * 0.46, 0.62, color, 0.42);
|
||||||
dot(
|
return svg(
|
||||||
ctx,
|
size,
|
||||||
c,
|
shape +
|
||||||
size * 0.16,
|
dot(
|
||||||
color,
|
size,
|
||||||
'rgba(10,10,15,0.85)',
|
size * 0.16,
|
||||||
Math.max(1, size * 0.05),
|
color,
|
||||||
);
|
'rgba(10,10,15,0.85)',
|
||||||
});
|
Math.max(1, size * 0.05),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
|
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
|
||||||
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
|
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
|
||||||
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
|
return svg(
|
||||||
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
|
size,
|
||||||
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
|
wedge(size, size * 0.48, 0.62, color, 0.38) +
|
||||||
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
|
wedge(size, size * 0.3, 0.62, color, 0.3) +
|
||||||
});
|
dot(size, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marker for the image the viewer currently shows. */
|
/** Marker for the image the viewer currently shows. */
|
||||||
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
||||||
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
|
return svg(
|
||||||
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
|
size,
|
||||||
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
|
wedge(size, size * 0.48, 0.55, color, 0.55) +
|
||||||
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
|
dot(size, size * 0.2, color, '#0a0a0f', 2) +
|
||||||
});
|
ring(size, size * 0.3, color, 1.5, 0.8),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ground casting for street-level overlays on Google 3D. Draped overlays land
|
|
||||||
* on the mesh top (trees, overpasses), so casting places them at bare-earth
|
|
||||||
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
|
|
||||||
* Heights come from a coarse grid shared with the ground-floor service; a
|
|
||||||
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { wrapLon } from './tileMath.js';
|
|
||||||
|
|
||||||
/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */
|
|
||||||
export const GROUND_CAST_STEP_DEG = 0.001;
|
|
||||||
/** Longest line segment left undivided, in degrees, so lines follow the grid. */
|
|
||||||
export const GROUND_CAST_DENSIFY_DEG = 0.0005;
|
|
||||||
/** Metres above the bare earth, so lines sit on the road instead of in it. */
|
|
||||||
export const GROUND_CAST_LIFT_M = 2;
|
|
||||||
/** Grid corners one prepare call may request; more stays draped. */
|
|
||||||
export const GROUND_CAST_MAX_CORNERS = 1024;
|
|
||||||
/** Cached grid corners before the oldest are dropped to make room. */
|
|
||||||
const GROUND_CAST_CACHE_MAX = 50_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
|
|
||||||
* misses trenches, steep streets and roads under trees):
|
|
||||||
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
|
|
||||||
* - higher mesh is over the road (canopy, deck): interpolate the road's
|
|
||||||
* offset from the nearest road samples on both sides;
|
|
||||||
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
|
|
||||||
*/
|
|
||||||
export const MESH_ROAD_ABOVE_M = 3;
|
|
||||||
/** A sample this far below bare earth is a bad probe, not a road. */
|
|
||||||
export const MESH_ROAD_BELOW_M = 30;
|
|
||||||
/** Metres above the sampled road surface. */
|
|
||||||
export const MESH_LIFT_M = 1;
|
|
||||||
/** Longest run of covered points bridged from the road on either side. */
|
|
||||||
export const MESH_BRIDGE_MAX_POINTS = 8;
|
|
||||||
/** Line spacing, in degrees (~22 m), while the mesh refines heights. */
|
|
||||||
export const MESH_DENSIFY_DEG = 0.0002;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Heights for a line's points by the rule above.
|
|
||||||
* @param {Array<{dem: number, mesh?: number|null}>} points
|
|
||||||
* @param {{lift?: number}} [options]
|
|
||||||
* @returns {Array<number>}
|
|
||||||
*/
|
|
||||||
export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) {
|
|
||||||
const road = points.map(({ dem, mesh }) =>
|
|
||||||
Number.isFinite(mesh) &&
|
|
||||||
mesh <= dem + MESH_ROAD_ABOVE_M &&
|
|
||||||
mesh >= dem - MESH_ROAD_BELOW_M
|
|
||||||
? mesh - dem
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
return points.map(({ dem, mesh }, i) => {
|
|
||||||
if (road[i] !== null) return dem + road[i] + MESH_LIFT_M;
|
|
||||||
if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) {
|
|
||||||
let a = i - 1;
|
|
||||||
while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--;
|
|
||||||
let b = i + 1;
|
|
||||||
while (
|
|
||||||
b < points.length &&
|
|
||||||
b - i <= MESH_BRIDGE_MAX_POINTS &&
|
|
||||||
road[b] === null
|
|
||||||
)
|
|
||||||
b++;
|
|
||||||
const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null;
|
|
||||||
const right =
|
|
||||||
b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null;
|
|
||||||
if (left !== null && right !== null) {
|
|
||||||
const t = (i - a) / (b - a);
|
|
||||||
return dem + left + (right - left) * t + MESH_LIFT_M;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dem + lift;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Camera height (m above ground) below which Google 3D switches to terrain mode. */
|
|
||||||
export const SURFACE_TERRAIN_ENTER_M = 1400;
|
|
||||||
/** Camera height above which terrain mode switches back to draped. */
|
|
||||||
export const SURFACE_TERRAIN_EXIT_M = 1800;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next surface mode: terrain only on Google 3D at street zoom, with
|
|
||||||
* hysteresis between the enter and exit heights.
|
|
||||||
* @param {'draped'|'terrain'} current
|
|
||||||
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
|
|
||||||
* @returns {'draped'|'terrain'}
|
|
||||||
*/
|
|
||||||
export function nextSurfaceMode(current, { photoreal, heightM, available }) {
|
|
||||||
if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped';
|
|
||||||
if (current === 'terrain')
|
|
||||||
return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain';
|
|
||||||
return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split long segments so a line follows the terrain. Segments across ±180°
|
|
||||||
* go the short way round, with added points wrapped into [-180, 180].
|
|
||||||
* @param {Array<[number, number]>} coords [lon, lat] pairs
|
|
||||||
* @param {number} [maxStep] longest segment, in degrees
|
|
||||||
* @returns {Array<[number, number]>}
|
|
||||||
*/
|
|
||||||
export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
|
|
||||||
if (!Array.isArray(coords) || coords.length < 2) return coords || [];
|
|
||||||
const out = [coords[0]];
|
|
||||||
for (let i = 1; i < coords.length; i++) {
|
|
||||||
const [lon0, lat0] = coords[i - 1];
|
|
||||||
const [lon1, lat1] = coords[i];
|
|
||||||
const dLon = wrapLon(lon1 - lon0);
|
|
||||||
const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0));
|
|
||||||
const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9));
|
|
||||||
for (let k = 1; k < parts; k++) {
|
|
||||||
const t = k / parts;
|
|
||||||
out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]);
|
|
||||||
}
|
|
||||||
out.push(coords[i]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bare-earth height cache over the terrain service.
|
|
||||||
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options
|
|
||||||
*/
|
|
||||||
export function createGroundCaster({
|
|
||||||
terrain,
|
|
||||||
step = GROUND_CAST_STEP_DEG,
|
|
||||||
lift = GROUND_CAST_LIFT_M,
|
|
||||||
maxCorners = GROUND_CAST_MAX_CORNERS,
|
|
||||||
cacheMax = GROUND_CAST_CACHE_MAX,
|
|
||||||
}) {
|
|
||||||
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
|
|
||||||
throw new TypeError('Ground casting requires a terrain service');
|
|
||||||
/** "i,j" grid corner → ellipsoidal ground height in metres. */
|
|
||||||
const heights = new Map();
|
|
||||||
/** Prepares run one at a time, so neighbouring tiles share their corners. */
|
|
||||||
let queue = Promise.resolve();
|
|
||||||
|
|
||||||
const cornerKey = (i, j) => `${i},${j}`;
|
|
||||||
|
|
||||||
function missingCorners(points) {
|
|
||||||
const missing = new Map();
|
|
||||||
for (const point of points) {
|
|
||||||
const [lon, lat] = point;
|
|
||||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
|
||||||
const i0 = Math.floor(lon / step);
|
|
||||||
const j0 = Math.floor(lat / step);
|
|
||||||
for (let di = 0; di <= 1; di++)
|
|
||||||
for (let dj = 0; dj <= 1; dj++) {
|
|
||||||
const key = cornerKey(i0 + di, j0 + dj);
|
|
||||||
if (!heights.has(key) && !missing.has(key))
|
|
||||||
missing.set(key, { i: i0 + di, j: j0 + dj });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...missing.entries()];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolve(points, signal) {
|
|
||||||
if (signal?.aborted) return false;
|
|
||||||
// Evict before counting, or we could drop corners this request counted
|
|
||||||
// as cached. Oldest first, so the tiles still on screen keep theirs.
|
|
||||||
for (const key of heights.keys()) {
|
|
||||||
if (heights.size + maxCorners <= cacheMax) break;
|
|
||||||
heights.delete(key);
|
|
||||||
}
|
|
||||||
const missing = missingCorners(points);
|
|
||||||
if (!missing.length) return true;
|
|
||||||
if (missing.length > maxCorners) return false;
|
|
||||||
// A corner east of 180° is asked for at its wrapped longitude.
|
|
||||||
const results = await terrain.resolveEllipsoidalGround(
|
|
||||||
missing.map(([, { i, j }]) => ({
|
|
||||||
lon: Number(wrapLon(i * step).toFixed(6)),
|
|
||||||
lat: Number((j * step).toFixed(6)),
|
|
||||||
})),
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
let complete = true;
|
|
||||||
missing.forEach(([key], n) => {
|
|
||||||
const result = results?.[n];
|
|
||||||
if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid))
|
|
||||||
heights.set(key, result.ellipsoid);
|
|
||||||
else complete = false;
|
|
||||||
});
|
|
||||||
return complete && !signal?.aborted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the grid corners around the points. True when all can be cast;
|
|
||||||
* false when some are unknown, too many, or aborted. Never rejects.
|
|
||||||
* @param {Array<[number, number]>} points [lon, lat] pairs
|
|
||||||
* @param {{signal?: AbortSignal}} [options]
|
|
||||||
*/
|
|
||||||
function prepare(points, { signal } = {}) {
|
|
||||||
const run = queue.then(() => resolve(points, signal)).catch(() => false);
|
|
||||||
queue = run;
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `prepare` for lines at both densities: the finer mesh points can fall in
|
|
||||||
* cells the coarse ones skip, and a missing cell leaves the line draped.
|
|
||||||
*/
|
|
||||||
function prepareLines(lines, options) {
|
|
||||||
return prepare(
|
|
||||||
lines.flatMap((coords) => [
|
|
||||||
...densifyLine(coords),
|
|
||||||
...densifyLine(coords, MESH_DENSIFY_DEG),
|
|
||||||
]),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */
|
|
||||||
function groundAt(lon, lat) {
|
|
||||||
const x = lon / step;
|
|
||||||
const y = lat / step;
|
|
||||||
const i0 = Math.floor(x);
|
|
||||||
const j0 = Math.floor(y);
|
|
||||||
const h00 = heights.get(cornerKey(i0, j0));
|
|
||||||
const h10 = heights.get(cornerKey(i0 + 1, j0));
|
|
||||||
const h01 = heights.get(cornerKey(i0, j0 + 1));
|
|
||||||
const h11 = heights.get(cornerKey(i0 + 1, j0 + 1));
|
|
||||||
if (h00 === undefined || h10 === undefined) return null;
|
|
||||||
if (h01 === undefined || h11 === undefined) return null;
|
|
||||||
const fx = x - i0;
|
|
||||||
const fy = y - j0;
|
|
||||||
const south = h00 + (h10 - h00) * fx;
|
|
||||||
const north = h01 + (h11 - h01) * fx;
|
|
||||||
return south + (north - south) * fy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Densified line as flat [lon, lat, height, ...]; null until every corner is
|
|
||||||
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
|
|
||||||
* @param {Array<[number, number]>} coords
|
|
||||||
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
|
|
||||||
*/
|
|
||||||
function castLine(coords, { meshAt = null } = {}) {
|
|
||||||
const rows = [];
|
|
||||||
const points = meshAt
|
|
||||||
? densifyLine(coords, MESH_DENSIFY_DEG)
|
|
||||||
: densifyLine(coords);
|
|
||||||
for (const [lon, lat] of points) {
|
|
||||||
const dem = groundAt(lon, lat);
|
|
||||||
if (dem === null) return null;
|
|
||||||
rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null });
|
|
||||||
}
|
|
||||||
const heights = refineHeights(rows, { lift });
|
|
||||||
const flat = [];
|
|
||||||
rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i]));
|
|
||||||
return flat;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { prepare, prepareLines, groundAt, castLine };
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import {
|
|
||||||
GROUND_CAST_LIFT_M,
|
|
||||||
MESH_LIFT_M,
|
|
||||||
refineHeights,
|
|
||||||
SURFACE_TERRAIN_ENTER_M,
|
|
||||||
SURFACE_TERRAIN_EXIT_M,
|
|
||||||
createGroundCaster,
|
|
||||||
densifyLine,
|
|
||||||
nextSurfaceMode,
|
|
||||||
} from './groundCast.js';
|
|
||||||
|
|
||||||
/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */
|
|
||||||
function fakeTerrain({ source = 'reearth', fail = false } = {}) {
|
|
||||||
const calls = [];
|
|
||||||
return {
|
|
||||||
calls,
|
|
||||||
async resolveEllipsoidalGround(coords) {
|
|
||||||
calls.push(coords);
|
|
||||||
if (fail) throw new Error('proxy down');
|
|
||||||
return coords.map(({ lon, lat }) => ({
|
|
||||||
ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000,
|
|
||||||
source,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => {
|
|
||||||
const view = { photoreal: true, available: true };
|
|
||||||
assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain');
|
|
||||||
assert.equal(
|
|
||||||
nextSurfaceMode('draped', {
|
|
||||||
...view,
|
|
||||||
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
|
|
||||||
}),
|
|
||||||
'draped',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
nextSurfaceMode('terrain', {
|
|
||||||
...view,
|
|
||||||
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
|
|
||||||
}),
|
|
||||||
'terrain',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
nextSurfaceMode('terrain', {
|
|
||||||
...view,
|
|
||||||
heightM: SURFACE_TERRAIN_EXIT_M + 1,
|
|
||||||
}),
|
|
||||||
'draped',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }),
|
|
||||||
'draped',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }),
|
|
||||||
'draped',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
nextSurfaceMode('terrain', { ...view, heightM: null }),
|
|
||||||
'draped',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('densifyLine splits long segments and keeps the vertices', () => {
|
|
||||||
const line = [
|
|
||||||
[10, 50],
|
|
||||||
[10.002, 50],
|
|
||||||
];
|
|
||||||
const dense = densifyLine(line, 0.0005);
|
|
||||||
assert.equal(dense.length, 5);
|
|
||||||
assert.deepEqual(dense[0], [10, 50]);
|
|
||||||
assert.deepEqual(dense.at(-1), [10.002, 50]);
|
|
||||||
assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9);
|
|
||||||
const short = [
|
|
||||||
[10, 50],
|
|
||||||
[10.0001, 50],
|
|
||||||
];
|
|
||||||
assert.deepEqual(densifyLine(short, 0.0005), short);
|
|
||||||
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('densifyLine crosses the date line the short way and wraps what it adds', () => {
|
|
||||||
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
|
|
||||||
const short = [
|
|
||||||
[179.9999, 0],
|
|
||||||
[-179.9999, 0],
|
|
||||||
];
|
|
||||||
assert.deepEqual(densifyLine(short), short);
|
|
||||||
const line = [
|
|
||||||
[179.999, 50.0005],
|
|
||||||
[-179.999, 50.0005],
|
|
||||||
];
|
|
||||||
const dense = densifyLine(line, 0.0005);
|
|
||||||
assert.equal(dense.length, 5);
|
|
||||||
assert.deepEqual(dense[0], line[0]);
|
|
||||||
assert.deepEqual(dense.at(-1), line[1]);
|
|
||||||
for (const [lon] of dense)
|
|
||||||
assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`);
|
|
||||||
assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9);
|
|
||||||
assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9);
|
|
||||||
assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9);
|
|
||||||
// Westward across the date line too.
|
|
||||||
const back = densifyLine([...line].reverse(), 0.0005);
|
|
||||||
assert.equal(back.length, 5);
|
|
||||||
assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the caster interpolates cached grid corners and adds the lift', async () => {
|
|
||||||
const terrain = fakeTerrain();
|
|
||||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
|
||||||
assert.equal(caster.groundAt(10.0005, 50.0005), null);
|
|
||||||
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
|
|
||||||
assert.equal(terrain.calls.length, 1);
|
|
||||||
assert.equal(terrain.calls[0].length, 4);
|
|
||||||
// Centre of the cell: mean of 100, 101, 102, 103.
|
|
||||||
assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6);
|
|
||||||
// A cast line sits the lift above it.
|
|
||||||
const point = [10.0005, 50.0005];
|
|
||||||
const [, , height] = caster.castLine([point, point]);
|
|
||||||
assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6);
|
|
||||||
// Same cell again: nothing to fetch.
|
|
||||||
assert.equal(await caster.prepare([[10.0002, 50.0008]]), true);
|
|
||||||
assert.equal(terrain.calls.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('castLine returns lon, lat, height triples or null when a corner is missing', async () => {
|
|
||||||
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
|
|
||||||
const line = [
|
|
||||||
[10.0001, 50.0001],
|
|
||||||
[10.0009, 50.0001],
|
|
||||||
];
|
|
||||||
assert.equal(caster.castLine(line), null);
|
|
||||||
await caster.prepareLines([line]);
|
|
||||||
const flat = caster.castLine(line);
|
|
||||||
assert.equal(flat.length % 3, 0);
|
|
||||||
assert.ok(flat.length >= 6);
|
|
||||||
assert.equal(flat[0], 10.0001);
|
|
||||||
assert.equal(flat[1], 50.0001);
|
|
||||||
assert.ok(flat[2] > 100 && flat[2] < 110);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a geoid fallback or a failing proxy leaves the heights unknown', async () => {
|
|
||||||
const fallback = createGroundCaster({
|
|
||||||
terrain: fakeTerrain({ source: 'geoid-fallback' }),
|
|
||||||
});
|
|
||||||
assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false);
|
|
||||||
assert.equal(fallback.groundAt(10.0005, 50.0005), null);
|
|
||||||
|
|
||||||
const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) });
|
|
||||||
assert.equal(await down.prepare([[10.0005, 50.0005]]), false);
|
|
||||||
assert.equal(down.groundAt(10.0005, 50.0005), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('too many corners are not requested, and aborted prepares do nothing', async () => {
|
|
||||||
const terrain = fakeTerrain();
|
|
||||||
const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 });
|
|
||||||
const wide = [];
|
|
||||||
for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]);
|
|
||||||
assert.equal(await caster.prepare(wide), false);
|
|
||||||
assert.equal(terrain.calls.length, 0);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
controller.abort();
|
|
||||||
assert.equal(
|
|
||||||
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(terrain.calls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prepares run one at a time so neighbours share corners', async () => {
|
|
||||||
const terrain = fakeTerrain();
|
|
||||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
|
||||||
const [a, b] = await Promise.all([
|
|
||||||
caster.prepare([[10.0005, 50.0005]]),
|
|
||||||
caster.prepare([[10.0015, 50.0005]]),
|
|
||||||
]);
|
|
||||||
assert.equal(a, true);
|
|
||||||
assert.equal(b, true);
|
|
||||||
// The second cell shares its west corners with the first.
|
|
||||||
assert.equal(terrain.calls[1].length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the caster needs a terrain service', () => {
|
|
||||||
assert.throws(() => createGroundCaster({ terrain: null }), TypeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refineHeights follows the mesh where it is the road', () => {
|
|
||||||
// A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it.
|
|
||||||
const heights = refineHeights([
|
|
||||||
{ dem: 100, mesh: 94 },
|
|
||||||
{ dem: 100, mesh: 102 },
|
|
||||||
{ dem: 100, mesh: null },
|
|
||||||
]);
|
|
||||||
assert.deepEqual(heights, [
|
|
||||||
94 + MESH_LIFT_M,
|
|
||||||
102 + MESH_LIFT_M,
|
|
||||||
100 + GROUND_CAST_LIFT_M,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refineHeights carries the road under a canopy from both sides', () => {
|
|
||||||
// Road 1 m and 3 m above bare earth either side of a tree 12 m tall.
|
|
||||||
const heights = refineHeights([
|
|
||||||
{ dem: 50, mesh: 51 },
|
|
||||||
{ dem: 50, mesh: 62 },
|
|
||||||
{ dem: 50, mesh: 62 },
|
|
||||||
{ dem: 50, mesh: 53 },
|
|
||||||
]);
|
|
||||||
assert.equal(heights[0], 51 + MESH_LIFT_M);
|
|
||||||
assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9);
|
|
||||||
assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9);
|
|
||||||
assert.equal(heights[3], 53 + MESH_LIFT_M);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
refineHeights([
|
|
||||||
{ dem: 20, mesh: 35 },
|
|
||||||
{ dem: 20, mesh: 35 },
|
|
||||||
]),
|
|
||||||
[20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M],
|
|
||||||
);
|
|
||||||
// A probe kilometres under the ground is not a road.
|
|
||||||
assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [
|
|
||||||
20 + GROUND_CAST_LIFT_M,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('castLine refines heights with sampled mesh and densifies finer', async () => {
|
|
||||||
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
|
|
||||||
const line = [
|
|
||||||
[10.0001, 50.0001],
|
|
||||||
[10.0009, 50.0001],
|
|
||||||
];
|
|
||||||
await caster.prepareLines([line]);
|
|
||||||
const plain = caster.castLine(line);
|
|
||||||
const meshed = caster.castLine(line, { meshAt: () => 98 });
|
|
||||||
assert.ok(meshed.length > plain.length, 'finer spacing with the mesh');
|
|
||||||
for (let i = 2; i < meshed.length; i += 3)
|
|
||||||
assert.equal(meshed[i], 98 + MESH_LIFT_M);
|
|
||||||
// Unsampled points keep the bare-earth height.
|
|
||||||
const unsampled = caster.castLine(line, { meshAt: () => undefined });
|
|
||||||
assert.ok(unsampled[2] > 100 && unsampled[2] < 110);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a full cache is cleared before a request counts its corners, so the request still casts', async () => {
|
|
||||||
const caster = createGroundCaster({
|
|
||||||
terrain: fakeTerrain(),
|
|
||||||
step: 0.001,
|
|
||||||
maxCorners: 8,
|
|
||||||
cacheMax: 5,
|
|
||||||
});
|
|
||||||
// One cell's four corners are cached...
|
|
||||||
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
|
|
||||||
// ...then a line over that cell and the next needs two more, and the first
|
|
||||||
// cell must survive the eviction.
|
|
||||||
const line = [
|
|
||||||
[10.0002, 50.0005],
|
|
||||||
[10.0018, 50.0005],
|
|
||||||
];
|
|
||||||
assert.equal(await caster.prepareLines([line]), true);
|
|
||||||
assert.ok(caster.castLine(line), 'the whole line casts');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a full cache drops its oldest corners, not every corner', async () => {
|
|
||||||
const caster = createGroundCaster({
|
|
||||||
terrain: fakeTerrain(),
|
|
||||||
step: 0.001,
|
|
||||||
maxCorners: 4,
|
|
||||||
cacheMax: 12,
|
|
||||||
});
|
|
||||||
// Three cells apart from each other, four corners each: the cache is full.
|
|
||||||
const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [
|
|
||||||
lon,
|
|
||||||
50.0005,
|
|
||||||
]);
|
|
||||||
for (const cell of cells.slice(0, 3))
|
|
||||||
assert.equal(await caster.prepare([cell]), true);
|
|
||||||
// A fourth makes room by dropping the first cell only.
|
|
||||||
assert.equal(await caster.prepare([cells[3]]), true);
|
|
||||||
assert.equal(caster.groundAt(...cells[0]), null, 'oldest dropped');
|
|
||||||
for (const cell of cells.slice(1))
|
|
||||||
assert.notEqual(caster.groundAt(...cell), null, 'newer cells kept');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cancelling a prepare cancels its terrain request', async () => {
|
|
||||||
const seen = [];
|
|
||||||
const caster = createGroundCaster({
|
|
||||||
terrain: {
|
|
||||||
async resolveEllipsoidalGround(coords, options) {
|
|
||||||
seen.push(options?.signal);
|
|
||||||
return coords.map(() => ({ ellipsoid: 100, source: 'reearth' }));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const controller = new AbortController();
|
|
||||||
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal });
|
|
||||||
assert.equal(seen.length, 1);
|
|
||||||
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
|
|
||||||
// Seeded walks with 50–300 m segments: the finer mesh points can land in
|
|
||||||
// grid cells the coarse points skip.
|
|
||||||
let seed = 7;
|
|
||||||
const random = () => {
|
|
||||||
seed = (seed * 16807) % 2147483647;
|
|
||||||
return seed / 2147483647;
|
|
||||||
};
|
|
||||||
let failures = 0;
|
|
||||||
for (let n = 0; n < 300; n++) {
|
|
||||||
const caster = createGroundCaster({ terrain: fakeTerrain() });
|
|
||||||
const line = [[10 + random() * 0.01, 50 + random() * 0.01]];
|
|
||||||
for (let k = 0; k < 10; k++) {
|
|
||||||
const [lon, lat] = line[line.length - 1];
|
|
||||||
const length = (50 + random() * 250) / 111_000;
|
|
||||||
const angle = random() * 2 * Math.PI;
|
|
||||||
line.push([
|
|
||||||
lon + length * Math.cos(angle),
|
|
||||||
lat + length * Math.sin(angle),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
assert.equal(await caster.prepareLines([line]), true);
|
|
||||||
if (caster.castLine(line, { meshAt: () => undefined }) === null) failures++;
|
|
||||||
}
|
|
||||||
assert.equal(failures, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a line across the date line casts along the date line', async () => {
|
|
||||||
// Ground that rises northward only, so both sides of ±180° agree.
|
|
||||||
const calls = [];
|
|
||||||
const terrain = {
|
|
||||||
async resolveEllipsoidalGround(coords) {
|
|
||||||
calls.push(...coords);
|
|
||||||
return coords.map(({ lat }) => ({
|
|
||||||
ellipsoid: 100 + (lat - 50) * 2000,
|
|
||||||
source: 'reearth',
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
|
||||||
const line = [
|
|
||||||
[179.9995, 50.0005],
|
|
||||||
[180, 50.0005],
|
|
||||||
[-179.9995, 50.0005],
|
|
||||||
];
|
|
||||||
assert.equal(await caster.prepareLines([line]), true);
|
|
||||||
for (const meshAt of [null, () => undefined]) {
|
|
||||||
const flat = caster.castLine(line, { meshAt });
|
|
||||||
assert.ok(flat, 'every point casts');
|
|
||||||
assert.ok(
|
|
||||||
flat.length / 3 <= 8,
|
|
||||||
`${flat.length / 3} points, not a line round the world`,
|
|
||||||
);
|
|
||||||
for (let i = 0; i < flat.length; i += 3) {
|
|
||||||
assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180);
|
|
||||||
assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Only ground near the date line is asked for, and every longitude is a
|
|
||||||
// real one (the far corner of a point on 180° is asked for at -179.999°).
|
|
||||||
assert.ok(calls.length <= 16, `${calls.length} corners`);
|
|
||||||
for (const { lon } of calls)
|
|
||||||
assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`);
|
|
||||||
});
|
|
||||||
@@ -3,34 +3,24 @@ import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
|
|||||||
import { cameraHeightAboveGround } from './view.js';
|
import { cameraHeightAboveGround } from './view.js';
|
||||||
|
|
||||||
/** Whether the camera is low enough for a click on the ground to mean a street. */
|
/** Whether the camera is low enough for a click on the ground to mean a street. */
|
||||||
export function groundClickHeightOk(viewer, groundAt) {
|
export function groundClickHeightOk(viewer) {
|
||||||
if (!viewer?.camera?.positionCartographic) return false;
|
if (!viewer?.camera?.positionCartographic) return false;
|
||||||
return (
|
return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M;
|
||||||
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A click on the map itself at street zoom opens the nearest image of the
|
* 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
|
* active `groundClick` providers, without moving the camera. The height is
|
||||||
* measured at the click, not read from the cached flag.
|
* measured at the click, not read from the cached flag.
|
||||||
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
|
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
|
||||||
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
|
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
|
||||||
*/
|
*/
|
||||||
export function createGroundClick({
|
export function createGroundClick({ state, openNearest, isAvailable }) {
|
||||||
state,
|
|
||||||
openNearest,
|
|
||||||
isAvailable,
|
|
||||||
groundAt = null,
|
|
||||||
}) {
|
|
||||||
return function openAtGround(screenPosition, picked) {
|
return function openAtGround(screenPosition, picked) {
|
||||||
const scenePick = state.services.scenePick;
|
const scenePick = state.services.scenePick;
|
||||||
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
|
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
|
||||||
return false;
|
return false;
|
||||||
if (
|
if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer))
|
||||||
!scenePick.isSurfacePick(picked) ||
|
|
||||||
!groundClickHeightOk(state.viewer, groundAt)
|
|
||||||
)
|
|
||||||
return false;
|
return false;
|
||||||
const providerIds = [...state.providers.values()]
|
const providerIds = [...state.providers.values()]
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
+108
-192
@@ -1,7 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createState } from './state.js';
|
import { createState } from './state.js';
|
||||||
import { createMarker } from './marker.js';
|
import { createMarker } from './marker.js';
|
||||||
import { createCameraFollow } from './cameraFollow.js';
|
import { createCameraFraming } from './cameraFraming.js';
|
||||||
import { createCredits } from './credits.js';
|
import { createCredits } from './credits.js';
|
||||||
import { createPickRouter } from './pickRouter.js';
|
import { createPickRouter } from './pickRouter.js';
|
||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
@@ -14,17 +14,12 @@ import {
|
|||||||
keyAlternatives,
|
keyAlternatives,
|
||||||
summarizeCoverage,
|
summarizeCoverage,
|
||||||
} from './uiState.js';
|
} from './uiState.js';
|
||||||
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
import { groundUnderCamera, viewCentre } from './view.js';
|
||||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
|
||||||
import { createMeshSampler } from './meshSampler.js';
|
|
||||||
import { msg, t, translateKnown } from '../../i18n/index.js';
|
import { msg, t, translateKnown } from '../../i18n/index.js';
|
||||||
|
|
||||||
/** Above this camera height the ground under the camera is not worth fetching. */
|
|
||||||
const SURFACE_WARM_BELOW_M = 6000;
|
|
||||||
import {
|
import {
|
||||||
AIM_AT_GROUND_MESSAGE,
|
AIM_AT_GROUND_MESSAGE,
|
||||||
FOLLOW_MAP_STACK_ID,
|
keyStatusLabel,
|
||||||
NEAREST_RADIUS_M,
|
NEAREST_RADIUS_M,
|
||||||
POSITION_PICK_ID,
|
POSITION_PICK_ID,
|
||||||
STREET_LEVEL_LAYER_ID,
|
STREET_LEVEL_LAYER_ID,
|
||||||
@@ -32,35 +27,31 @@ import {
|
|||||||
|
|
||||||
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||||
|
|
||||||
|
/** Longest wait for a provider's key status; past it, the provider decides. */
|
||||||
|
const KEY_STATUS_TIMEOUT_MS = 10_000;
|
||||||
|
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
|
||||||
|
const GROUND_RETRY_LIMIT = 5;
|
||||||
|
const GROUND_RETRY_MS = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Street Level layer over imagery providers (contract in registry.js).
|
* The Street Level layer over imagery providers (contract in registry.js).
|
||||||
* The core owns what they share; providers own coverage, sequences, viewer.
|
* The core owns what they share: the filter and switches, the photo viewer
|
||||||
|
* host, the position marker, the click handler and nearest-image lookups.
|
||||||
|
* Providers own coverage, sequences and their viewer. One source is lit at
|
||||||
|
* a time.
|
||||||
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||||
*/
|
*/
|
||||||
export function createStreetLevelLayer({
|
export function createStreetLevelLayer({
|
||||||
providers: definitions,
|
providers: definitions,
|
||||||
services = {},
|
services = {},
|
||||||
}) {
|
}) {
|
||||||
const definitionsFrozen = validateProviders(definitions);
|
const defs = validateProviders(definitions);
|
||||||
const state = createState({ services });
|
const state = createState({ services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const context = { state, parts };
|
const context = { state, parts };
|
||||||
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
|
|
||||||
? createGroundCaster({ terrain: services.terrain })
|
|
||||||
: null;
|
|
||||||
// Mesh samples follow the application's rules: a real bare-earth prior,
|
|
||||||
// its mesh window, and a tileset that has finished streaming.
|
|
||||||
parts.meshSampler = parts.groundCaster
|
|
||||||
? createMeshSampler({
|
|
||||||
getViewer: () => state.viewer,
|
|
||||||
groundAt: parts.groundCaster.groundAt,
|
|
||||||
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
|
|
||||||
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
parts.credits = createCredits();
|
parts.credits = createCredits();
|
||||||
parts.marker = createMarker(context);
|
parts.marker = createMarker(context);
|
||||||
parts.follow = createCameraFollow(context);
|
parts.framing = createCameraFraming(context);
|
||||||
parts.router = createPickRouter(() => state.providers.values(), {
|
parts.router = createPickRouter(() => state.providers.values(), {
|
||||||
positionId: POSITION_PICK_ID,
|
positionId: POSITION_PICK_ID,
|
||||||
});
|
});
|
||||||
@@ -78,7 +69,6 @@ export function createStreetLevelLayer({
|
|||||||
state,
|
state,
|
||||||
openNearest: (point, options) => layer.openNearest(point, options),
|
openNearest: (point, options) => layer.openNearest(point, options),
|
||||||
isAvailable: (entry) => providerAvailable(entry),
|
isAvailable: (entry) => providerAvailable(entry),
|
||||||
groundAt: parts.groundCaster?.groundAt ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let notifyQueued = false;
|
let notifyQueued = false;
|
||||||
@@ -102,11 +92,10 @@ export function createStreetLevelLayer({
|
|||||||
function providerContext(entry) {
|
function providerContext(entry) {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
services: state.services,
|
services: state.services,
|
||||||
|
/** Resolved against now, so "since N days" keeps up with the clock. */
|
||||||
getFilter: () => resolveFilter(state.filter),
|
getFilter: () => resolveFilter(state.filter),
|
||||||
|
/** The layer and this provider's switch are both on. */
|
||||||
isActive: () => state.enabled && entry.on,
|
isActive: () => state.enabled && entry.on,
|
||||||
groundCaster: parts.groundCaster,
|
|
||||||
meshSampler: parts.meshSampler,
|
|
||||||
getSurface: () => state.surface,
|
|
||||||
notify,
|
notify,
|
||||||
actions: {
|
actions: {
|
||||||
/** `{frame: false}` opens it without moving the camera. */
|
/** `{frame: false}` opens it without moving the camera. */
|
||||||
@@ -138,13 +127,15 @@ export function createStreetLevelLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One source at a time: the first provider that defaults on.
|
// One source at a time: the first provider that defaults on.
|
||||||
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
|
const startsOn = defs.find((def) => def.defaultOn !== false);
|
||||||
for (const def of definitionsFrozen) {
|
for (const def of defs) {
|
||||||
const entry = {
|
const entry = {
|
||||||
def,
|
def,
|
||||||
instance: null,
|
instance: null,
|
||||||
on: def === startsOn,
|
on: def === startsOn,
|
||||||
status: null,
|
status: null,
|
||||||
|
/** The status request in flight; overlapping checks share it. */
|
||||||
|
statusCheck: null,
|
||||||
/** The error this provider last reported, until it withdraws it. */
|
/** The error this provider last reported, until it withdraws it. */
|
||||||
reportedError: null,
|
reportedError: null,
|
||||||
};
|
};
|
||||||
@@ -155,15 +146,6 @@ export function createStreetLevelLayer({
|
|||||||
let mapStack = null;
|
let mapStack = null;
|
||||||
let unsubscribeMapStack = null;
|
let unsubscribeMapStack = null;
|
||||||
|
|
||||||
/** Follow is offered only on the Google 3D stack; leaving it stops following. */
|
|
||||||
function syncFollowAvailability() {
|
|
||||||
const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
|
||||||
if (available === state.street.followAvailable) return;
|
|
||||||
state.street.followAvailable = available;
|
|
||||||
if (!available && state.street.follow) parts.follow.setFollow(false);
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
|
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
|
||||||
function providerAvailable(entry) {
|
function providerAvailable(entry) {
|
||||||
const required = entry.def.requiresMapStack;
|
const required = entry.def.requiresMapStack;
|
||||||
@@ -184,109 +166,79 @@ export function createStreetLevelLayer({
|
|||||||
parts.viewerHost.unmount(entry.def.id);
|
parts.viewerHost.unmount(entry.def.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
remeasureGround();
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a ground click can open an image: the camera is at street zoom. */
|
let groundRetry = null;
|
||||||
|
let groundRetries = 0;
|
||||||
|
function cancelGroundRetry() {
|
||||||
|
clearTimeout(groundRetry);
|
||||||
|
groundRetry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a ground click can open an image: the camera is at street zoom.
|
||||||
|
* Under Google 3D the ground is unknown until its tiles load, so an unknown
|
||||||
|
* ground is measured again a few times instead of waiting for the next move.
|
||||||
|
*/
|
||||||
function syncGroundClickReady() {
|
function syncGroundClickReady() {
|
||||||
const ready =
|
cancelGroundRetry();
|
||||||
|
const ready = state.enabled && groundClickHeightOk(state.viewer);
|
||||||
|
if (
|
||||||
state.enabled &&
|
state.enabled &&
|
||||||
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
|
!ready &&
|
||||||
|
groundRetries < GROUND_RETRY_LIMIT &&
|
||||||
|
groundUnderCamera(state.viewer) === null
|
||||||
|
) {
|
||||||
|
groundRetries++;
|
||||||
|
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
|
||||||
|
}
|
||||||
if (ready === state.groundClickReady) return;
|
if (ready === state.groundClickReady) return;
|
||||||
state.groundClickReady = ready;
|
state.groundClickReady = ready;
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A camera move or a map change measures afresh. */
|
||||||
* Camera height above bare earth, for the surface mode. A cold grid cell is
|
function remeasureGround() {
|
||||||
* fetched once and the mode rechecked when it lands.
|
groundRetries = 0;
|
||||||
*/
|
|
||||||
function cameraHeightForSurface() {
|
|
||||||
const carto = state.viewer?.camera?.positionCartographic;
|
|
||||||
if (!carto) return null;
|
|
||||||
const caster = parts.groundCaster;
|
|
||||||
const height = cameraHeightAboveGround(state.viewer, {
|
|
||||||
groundAt: caster.groundAt,
|
|
||||||
});
|
|
||||||
const lon = (carto.longitude * 180) / Math.PI;
|
|
||||||
const lat = (carto.latitude * 180) / Math.PI;
|
|
||||||
if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M)
|
|
||||||
caster.prepare([[lon, lat]]).then((ready) => {
|
|
||||||
if (ready) scheduleSurfaceSync();
|
|
||||||
});
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
|
|
||||||
function syncSurface() {
|
|
||||||
syncGroundClickReady();
|
syncGroundClickReady();
|
||||||
const available = Boolean(parts.groundCaster) && state.enabled;
|
|
||||||
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
|
||||||
const mode = nextSurfaceMode(state.surface, {
|
|
||||||
available,
|
|
||||||
photoreal,
|
|
||||||
heightM: available && photoreal ? cameraHeightForSurface() : null,
|
|
||||||
});
|
|
||||||
if (mode === state.surface) return;
|
|
||||||
state.surface = mode;
|
|
||||||
parts.meshSampler?.setEnabled(mode === 'terrain');
|
|
||||||
parts.marker.setSurface(mode);
|
|
||||||
for (const entry of state.providers.values())
|
|
||||||
entry.instance.setSurface?.(mode);
|
|
||||||
notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let surfaceTimer = null;
|
let removeCameraListener = null;
|
||||||
function scheduleSurfaceSync() {
|
function watchCamera(viewer) {
|
||||||
clearTimeout(surfaceTimer);
|
removeCameraListener?.();
|
||||||
surfaceTimer = setTimeout(syncSurface, 150);
|
removeCameraListener =
|
||||||
}
|
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null;
|
||||||
|
|
||||||
let removeSurfaceListeners = null;
|
|
||||||
function watchSurface(viewer) {
|
|
||||||
removeSurfaceListeners?.();
|
|
||||||
const camera = viewer?.camera;
|
|
||||||
if (!camera?.changed || !camera?.moveEnd) {
|
|
||||||
removeSurfaceListeners = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
|
|
||||||
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
|
|
||||||
removeSurfaceListeners = () => {
|
|
||||||
removeChanged();
|
|
||||||
removeEnd();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwatchSurface() {
|
|
||||||
removeSurfaceListeners?.();
|
|
||||||
removeSurfaceListeners = null;
|
|
||||||
clearTimeout(surfaceTimer);
|
|
||||||
surfaceTimer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeEntries = () =>
|
const activeEntries = () =>
|
||||||
[...state.providers.values()].filter((entry) => entry.on);
|
[...state.providers.values()].filter((entry) => entry.on);
|
||||||
|
|
||||||
async function refreshStatus(entry) {
|
/** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */
|
||||||
try {
|
function checkStatus(entry) {
|
||||||
entry.status = await entry.instance.status();
|
entry.statusCheck ||= (async () => {
|
||||||
} catch {
|
try {
|
||||||
entry.status = null;
|
entry.status = await entry.instance.status({
|
||||||
}
|
signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS),
|
||||||
notify();
|
});
|
||||||
|
} catch {
|
||||||
|
entry.status = null;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
})().finally(() => {
|
||||||
|
entry.statusCheck = null;
|
||||||
|
});
|
||||||
|
return entry.statusCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate(entry) {
|
function activate(entry) {
|
||||||
if (!state.viewer) return;
|
if (!state.viewer) return;
|
||||||
entry.instance.activate(state.viewer);
|
entry.instance.activate(state.viewer);
|
||||||
if (!entry.status) refreshStatus(entry);
|
// Without a known key, a switch-on asks again: the server may be back,
|
||||||
if (!providerAvailable(entry)) return;
|
// or have a key now.
|
||||||
parts.credits.show(state.viewer, entry.def);
|
if (entry.status?.configured !== true) checkStatus(entry);
|
||||||
// The map may have changed by then.
|
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
|
||||||
whenIdle(() => {
|
|
||||||
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
|
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deactivate(entry) {
|
function deactivate(entry) {
|
||||||
@@ -442,7 +394,6 @@ export function createStreetLevelLayer({
|
|||||||
error: stats.error || null,
|
error: stats.error || null,
|
||||||
color: entry.def.colors.coverage,
|
color: entry.def.colors.coverage,
|
||||||
coverageLabel: entry.def.coverageLabel || null,
|
coverageLabel: entry.def.coverageLabel || null,
|
||||||
highlightsSequence: entry.def.highlightsSequence !== false,
|
|
||||||
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
|
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
|
||||||
groundClick: entry.def.groundClick === true,
|
groundClick: entry.def.groundClick === true,
|
||||||
filters: [...filtersOf(entry.def)],
|
filters: [...filtersOf(entry.def)],
|
||||||
@@ -450,23 +401,30 @@ export function createStreetLevelLayer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The selected sequence; the provider showing the open image answers first. */
|
||||||
function sequenceSnapshot() {
|
function sequenceSnapshot() {
|
||||||
// The provider showing the open image answers first.
|
|
||||||
const owner = state.providers.get(state.street.providerId);
|
const owner = state.providers.get(state.street.providerId);
|
||||||
const candidates = owner
|
const candidates = owner
|
||||||
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
|
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
|
||||||
: [...state.providers.values()];
|
: [...state.providers.values()];
|
||||||
for (const entry of candidates) {
|
for (const entry of candidates) {
|
||||||
const stats = entry.instance.sequenceStats?.();
|
const stats = entry.instance.sequenceStats?.();
|
||||||
if (stats?.selectedId || stats?.loading)
|
if (stats?.selectedId || stats?.loading || stats?.error)
|
||||||
return {
|
return {
|
||||||
providerId: entry.def.id,
|
providerId: entry.def.id,
|
||||||
selectedId: stats.selectedId || null,
|
selectedId: stats.selectedId || null,
|
||||||
images: stats.images || 0,
|
images: stats.images || 0,
|
||||||
loading: stats.loading === true,
|
loading: stats.loading === true,
|
||||||
|
error: stats.error || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { providerId: null, selectedId: null, images: 0, loading: false };
|
return {
|
||||||
|
providerId: null,
|
||||||
|
selectedId: null,
|
||||||
|
images: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUIState() {
|
function getUIState() {
|
||||||
@@ -478,7 +436,6 @@ export function createStreetLevelLayer({
|
|||||||
providers: providerSnapshots(),
|
providers: providerSnapshots(),
|
||||||
street,
|
street,
|
||||||
sequence: sequenceSnapshot(),
|
sequence: sequenceSnapshot(),
|
||||||
surface: state.surface,
|
|
||||||
});
|
});
|
||||||
// The lit source cannot run on this map: say so, not a blank panel.
|
// The lit source cannot run on this map: say so, not a blank panel.
|
||||||
const stranded = [...state.providers.values()].find(
|
const stranded = [...state.providers.values()].find(
|
||||||
@@ -503,15 +460,13 @@ export function createStreetLevelLayer({
|
|||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
/** The key the switched-on providers share (or null), for the layer row. */
|
/** The key the switched-on providers share (or null), for the layer row. */
|
||||||
get requiresKeyId() {
|
get requiresKeyId() {
|
||||||
const on = [...state.providers.values()].filter((entry) => entry.on);
|
const on = activeEntries();
|
||||||
return requiresKeyIdFor(
|
return requiresKeyIdFor(on.length ? on.map((entry) => entry.def) : defs);
|
||||||
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
/** Registered providers, in chip order. */
|
/** Registered providers, in chip order. */
|
||||||
providerIds: definitionsFrozen.map((def) => def.id),
|
providerIds: defs.map((def) => def.id),
|
||||||
getSourceAvailability() {
|
getSourceAvailability() {
|
||||||
const available = definitionsFrozen.length > 0;
|
const available = defs.length > 0;
|
||||||
return {
|
return {
|
||||||
available,
|
available,
|
||||||
source: STREET_LEVEL_LAYER_ID,
|
source: STREET_LEVEL_LAYER_ID,
|
||||||
@@ -522,15 +477,12 @@ export function createStreetLevelLayer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
if (state.initialized)
|
|
||||||
throw new Error('Street Level layer is already initialized');
|
|
||||||
state.viewer = viewer;
|
state.viewer = viewer;
|
||||||
state.initialized = true;
|
|
||||||
parts.marker.ensure(viewer);
|
parts.marker.ensure(viewer);
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
for (const entry of state.providers.values()) {
|
for (const entry of state.providers.values()) {
|
||||||
entry.instance.init(viewer);
|
entry.instance.init(viewer);
|
||||||
refreshStatus(entry);
|
checkStatus(entry);
|
||||||
}
|
}
|
||||||
console.log('[Data:StreetLevel] Initialized');
|
console.log('[Data:StreetLevel] Initialized');
|
||||||
},
|
},
|
||||||
@@ -541,18 +493,20 @@ export function createStreetLevelLayer({
|
|||||||
parts.marker.setVisible(true);
|
parts.marker.setVisible(true);
|
||||||
parts.selection.install(viewer);
|
parts.selection.install(viewer);
|
||||||
for (const entry of activeEntries()) activate(entry);
|
for (const entry of activeEntries()) activate(entry);
|
||||||
watchSurface(viewer);
|
watchCamera(viewer);
|
||||||
syncSurface();
|
remeasureGround();
|
||||||
notify();
|
notify();
|
||||||
},
|
},
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
abortNearest();
|
abortNearest();
|
||||||
unwatchSurface();
|
removeCameraListener?.();
|
||||||
|
removeCameraListener = null;
|
||||||
|
cancelGroundRetry();
|
||||||
parts.viewerHost.unmount();
|
parts.viewerHost.unmount();
|
||||||
for (const entry of state.providers.values()) entry.instance.deactivate();
|
for (const entry of state.providers.values()) entry.instance.deactivate();
|
||||||
syncSurface();
|
syncGroundClickReady();
|
||||||
parts.credits.hideAll(state.viewer);
|
parts.credits.hideAll(state.viewer);
|
||||||
parts.selection.uninstall();
|
parts.selection.uninstall();
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
@@ -568,27 +522,22 @@ export function createStreetLevelLayer({
|
|||||||
for (const entry of state.providers.values())
|
for (const entry of state.providers.values())
|
||||||
entry.instance.destroy(viewer);
|
entry.instance.destroy(viewer);
|
||||||
parts.marker.destroy(viewer);
|
parts.marker.destroy(viewer);
|
||||||
parts.meshSampler?.destroy();
|
parts.framing.attachNavigation(null);
|
||||||
parts.follow.destroy();
|
|
||||||
unsubscribeMapStack?.();
|
unsubscribeMapStack?.();
|
||||||
unsubscribeMapStack = null;
|
unsubscribeMapStack = null;
|
||||||
mapStack = null;
|
mapStack = null;
|
||||||
state.listeners.clear();
|
state.listeners.clear();
|
||||||
state.viewer = null;
|
state.viewer = null;
|
||||||
state.initialized = false;
|
|
||||||
state.destroyed = true;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getStats() {
|
getStats() {
|
||||||
// The lifecycle polls this every second: summarise, don't snapshot.
|
// The lifecycle polls this every second: summarise, don't snapshot.
|
||||||
|
// English data: the layer panel translates these through translateKnown.
|
||||||
const snapshots = providerSnapshots();
|
const snapshots = providerSnapshots();
|
||||||
const coverage = summarizeCoverage(snapshots);
|
const coverage = summarizeCoverage(snapshots);
|
||||||
// English data: the layer panel translates these through translateKnown.
|
const keyLabel = keyStatusLabel(coverage);
|
||||||
let loadingLabel = '';
|
let loadingLabel = '';
|
||||||
const keyLabel = coverage.keyRejected
|
if (keyLabel) loadingLabel = keyLabel;
|
||||||
? msg('KEY REJECTED')
|
|
||||||
: msg('KEY REQUIRED');
|
|
||||||
if (coverage.keyRequired) loadingLabel = keyLabel;
|
|
||||||
else if (coverage.loading) loadingLabel = msg('loading coverage...');
|
else if (coverage.loading) loadingLabel = msg('loading coverage...');
|
||||||
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||||
return {
|
return {
|
||||||
@@ -597,7 +546,8 @@ export function createStreetLevelLayer({
|
|||||||
loading: coverage.loading,
|
loading: coverage.loading,
|
||||||
keyRequired: coverage.keyRequired,
|
keyRequired: coverage.keyRequired,
|
||||||
keyAlternatives: keyAlternatives(snapshots),
|
keyAlternatives: keyAlternatives(snapshots),
|
||||||
error: coverage.keyRequired
|
// A rejected key's message names the fix; a missing one says so.
|
||||||
|
error: keyLabel
|
||||||
? coverage.keyRejected
|
? coverage.keyRejected
|
||||||
? coverage.error || keyLabel
|
? coverage.error || keyLabel
|
||||||
: keyLabel
|
: keyLabel
|
||||||
@@ -606,30 +556,18 @@ export function createStreetLevelLayer({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** A photo frames itself only if it still owns the camera once loaded. */
|
||||||
* The application's camera authority: `run(noun, move)`, the deferred
|
|
||||||
* `begin(noun)` / `reassert(generation)` pair and
|
|
||||||
* `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a
|
|
||||||
* photo claims it when opening starts and frames only if it still owns
|
|
||||||
* it once loaded. FOLLOW stops when another feature takes the camera.
|
|
||||||
*/
|
|
||||||
attachNavigation(navigation) {
|
attachNavigation(navigation) {
|
||||||
parts.follow.attachNavigation(navigation);
|
parts.framing.attachNavigation(navigation);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** The application map stack; FOLLOW is available only on Google 3D. */
|
/** The application map stack; Street View is usable only on Google 3D. */
|
||||||
attachMapStackController(controller) {
|
attachMapStackController(controller) {
|
||||||
unsubscribeMapStack?.();
|
unsubscribeMapStack?.();
|
||||||
mapStack = controller || null;
|
mapStack = controller || null;
|
||||||
unsubscribeMapStack =
|
unsubscribeMapStack =
|
||||||
mapStack?.subscribe?.(() => {
|
mapStack?.subscribe?.(syncProviderAvailability) || null;
|
||||||
syncFollowAvailability();
|
|
||||||
syncProviderAvailability();
|
|
||||||
syncSurface();
|
|
||||||
}) || null;
|
|
||||||
syncFollowAvailability();
|
|
||||||
syncProviderAvailability();
|
syncProviderAvailability();
|
||||||
syncSurface();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Share-link and stored state: provider switches plus the filter. */
|
/** Share-link and stored state: provider switches plus the filter. */
|
||||||
@@ -640,6 +578,7 @@ export function createStreetLevelLayer({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Unknown providers and malformed values are ignored, so any link applies. */
|
||||||
setParams(params = {}) {
|
setParams(params = {}) {
|
||||||
const decoded = decodeParams(params, {
|
const decoded = decodeParams(params, {
|
||||||
providerIds: state.providers.keys(),
|
providerIds: state.providers.keys(),
|
||||||
@@ -666,23 +605,14 @@ export function createStreetLevelLayer({
|
|||||||
/** The DOM element provider viewers render into. */
|
/** The DOM element provider viewers render into. */
|
||||||
attachViewerHost(element) {
|
attachViewerHost(element) {
|
||||||
parts.viewerHost.attach(element);
|
parts.viewerHost.attach(element);
|
||||||
if (element && state.enabled)
|
|
||||||
whenIdle(
|
|
||||||
() =>
|
|
||||||
parts.viewerHost.prewarm(
|
|
||||||
activeEntries().filter((entry) => providerAvailable(entry)),
|
|
||||||
),
|
|
||||||
1500,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
setProviderEnabled,
|
setProviderEnabled,
|
||||||
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
|
||||||
setCoverageFilter,
|
|
||||||
openImage,
|
openImage,
|
||||||
/**
|
/**
|
||||||
* Open the nearest image of the active providers (or `providerIds`) near
|
* Open the nearest image of the active providers (or `providerIds`) near
|
||||||
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
|
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
|
||||||
* under the screen centre (sky: nothing).
|
* under the screen centre (sky: nothing). Providers without
|
||||||
|
* `nearestImage` are skipped.
|
||||||
*/
|
*/
|
||||||
async openNearest(
|
async openNearest(
|
||||||
point,
|
point,
|
||||||
@@ -712,6 +642,7 @@ export function createStreetLevelLayer({
|
|||||||
let lastError = null;
|
let lastError = null;
|
||||||
const asked = (entry) =>
|
const asked = (entry) =>
|
||||||
(!providerIds || providerIds.includes(entry.def.id)) &&
|
(!providerIds || providerIds.includes(entry.def.id)) &&
|
||||||
|
typeof entry.instance.nearestImage === 'function' &&
|
||||||
providerAvailable(entry);
|
providerAvailable(entry);
|
||||||
const entries = activeEntries().filter(asked);
|
const entries = activeEntries().filter(asked);
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -751,35 +682,20 @@ export function createStreetLevelLayer({
|
|||||||
})
|
})
|
||||||
: t(
|
: t(
|
||||||
'No street-level imagery within {radius} m of the view centre',
|
'No street-level imagery within {radius} m of the view centre',
|
||||||
{
|
{ radius: NEAREST_RADIUS_M },
|
||||||
radius: NEAREST_RADIUS_M,
|
|
||||||
},
|
|
||||||
));
|
));
|
||||||
state.street.loading = false;
|
state.street.loading = false;
|
||||||
notify();
|
notify();
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
/** Close the image and deselect it everywhere on the map. */
|
/** Close the image and deselect its sequence on the map. */
|
||||||
closeViewer() {
|
closeViewer() {
|
||||||
abortNearest();
|
abortNearest();
|
||||||
parts.viewerHost.close();
|
parts.viewerHost.close();
|
||||||
parts.clearSequences();
|
parts.clearSequences();
|
||||||
},
|
},
|
||||||
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
||||||
setFollow: (enabled) => parts.follow.setFollow(enabled),
|
|
||||||
resizeViewer: () => parts.viewerHost.resize(),
|
resizeViewer: () => parts.viewerHost.resize(),
|
||||||
selectSequence(
|
|
||||||
sequenceId,
|
|
||||||
providerId = state.providers.keys().next().value,
|
|
||||||
) {
|
|
||||||
return state.providers
|
|
||||||
.get(providerId)
|
|
||||||
?.instance.selectSequence?.(sequenceId);
|
|
||||||
},
|
|
||||||
clearSequence: () => parts.clearSequences(),
|
|
||||||
refreshCoverage() {
|
|
||||||
for (const entry of activeEntries()) entry.instance.refreshCoverage();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,306 @@ import { createStreetLevelLayer } from './index.js';
|
|||||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||||
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
||||||
import { createKartaViewProvider } from './providers/kartaview/index.js';
|
import { createKartaViewProvider } from './providers/kartaview/index.js';
|
||||||
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
|
import {
|
||||||
|
fakeCesiumViewer,
|
||||||
|
fakeMapillarySource,
|
||||||
|
fakePhotoViewer,
|
||||||
|
fakeStreetLevelProvider as fakeProvider,
|
||||||
|
} from '../../testSupport/streetLevelFakes.mjs';
|
||||||
import { setLocale } from '../../i18n/index.js';
|
import { setLocale } from '../../i18n/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
|
||||||
|
* and (unless `enable` is false) enabled.
|
||||||
|
*/
|
||||||
|
async function startLayer(
|
||||||
|
t,
|
||||||
|
{
|
||||||
|
source = fakeMapillarySource(),
|
||||||
|
photoViewer = fakePhotoViewer(),
|
||||||
|
providers = [createMapillaryProvider({ source, photoViewer })],
|
||||||
|
view = false,
|
||||||
|
enable = true,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const saved = globalThis.document;
|
||||||
|
// The click handler listens for Esc on the document.
|
||||||
|
globalThis.document = new EventTarget();
|
||||||
|
// Skip Cesium's one-time terrain table download for draped lines.
|
||||||
|
t.mock.method(
|
||||||
|
Cesium.GroundPolylinePrimitive,
|
||||||
|
'initializeTerrainHeights',
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
const viewer = fakeCesiumViewer({ view });
|
||||||
|
const layer = createStreetLevelLayer({ providers });
|
||||||
|
layer.init(viewer);
|
||||||
|
if (enable) layer.enable(viewer);
|
||||||
|
t.after(() => {
|
||||||
|
layer.destroy();
|
||||||
|
globalThis.document = saved;
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
return { layer, viewer, source, photoViewer };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((done, fail) => {
|
||||||
|
resolve = done;
|
||||||
|
reject = fail;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A photo viewer reporting a pose for every image it opens. */
|
||||||
|
const posingViewer = () =>
|
||||||
|
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
|
||||||
|
|
||||||
|
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
|
||||||
|
const { layer, viewer } = await startLayer(t);
|
||||||
|
const shown = () => viewer.credits.map((credit) => credit.html);
|
||||||
|
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
|
||||||
|
assert.match(shown()[0], /CC BY-SA 4\.0/);
|
||||||
|
layer.setParams({ mapillary: false });
|
||||||
|
assert.deepEqual(shown(), [], 'hidden with the switch');
|
||||||
|
layer.setParams({ mapillary: true });
|
||||||
|
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
|
||||||
|
layer.disable();
|
||||||
|
assert.deepEqual(shown(), [], 'the credit goes with the layer');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
|
||||||
|
for (const configured of [false, true]) {
|
||||||
|
const status = deferred();
|
||||||
|
const source = fakeMapillarySource({ getStatus: () => status.promise });
|
||||||
|
const { layer } = await startLayer(t, { source, view: true });
|
||||||
|
assert.equal(source.calls.tiles.length, 0, 'nothing before the status');
|
||||||
|
status.resolve({ configured });
|
||||||
|
await settle();
|
||||||
|
if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage');
|
||||||
|
else {
|
||||||
|
assert.equal(
|
||||||
|
source.calls.tiles.length,
|
||||||
|
0,
|
||||||
|
'a key-less install never asks',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[layer.getStats().loadingLabel, layer.getStats().error],
|
||||||
|
['KEY REQUIRED', 'KEY REQUIRED'],
|
||||||
|
);
|
||||||
|
assert.equal(layer.getUIState().keyRequired, true);
|
||||||
|
assert.equal(layer.getUIState().keyRejected, false);
|
||||||
|
}
|
||||||
|
layer.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a key status that failed is asked again on the next switch-on', async (t) => {
|
||||||
|
let up = false;
|
||||||
|
let asked = 0;
|
||||||
|
const source = fakeMapillarySource({
|
||||||
|
hasToken: () => false,
|
||||||
|
getStatus: async () => {
|
||||||
|
asked++;
|
||||||
|
if (!up) throw new TypeError('fetch failed');
|
||||||
|
return { configured: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||||||
|
assert.equal(asked, 1, 'one check for init and the first switch-on');
|
||||||
|
assert.equal(layer.getUIState().keyRequired, true);
|
||||||
|
up = true; // the server is reachable again
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
await settle();
|
||||||
|
assert.equal(asked, 2);
|
||||||
|
assert.equal(layer.getUIState().keyRequired, false);
|
||||||
|
assert.ok(source.calls.tiles.length > 0, 'coverage loads');
|
||||||
|
// A known key is not asked again.
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
await settle();
|
||||||
|
assert.equal(asked, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a key status that never answers gives up, so the next switch-on asks again', async (t) => {
|
||||||
|
// The check's timeout, fired when the test says.
|
||||||
|
const timeouts = [];
|
||||||
|
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
timeouts.push({ ms, fire: () => controller.abort(new Error('timed out')) });
|
||||||
|
return controller.signal;
|
||||||
|
});
|
||||||
|
let asked = 0;
|
||||||
|
const source = fakeMapillarySource({
|
||||||
|
hasToken: () => false,
|
||||||
|
getStatus: ({ signal } = {}) =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
asked++;
|
||||||
|
signal?.addEventListener('abort', () => reject(signal.reason));
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||||||
|
assert.equal(asked, 1);
|
||||||
|
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
|
||||||
|
assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile');
|
||||||
|
timeouts[0].fire();
|
||||||
|
await settle();
|
||||||
|
assert.equal(layer.getUIState().coverage.loading, false);
|
||||||
|
assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED');
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
assert.equal(asked, 2, 'asked again, not stuck on the first');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
|
||||||
|
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
|
||||||
|
keyRejected: true,
|
||||||
|
});
|
||||||
|
const source = fakeMapillarySource({
|
||||||
|
getTile: async () => {
|
||||||
|
throw rejected;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||||||
|
await settle();
|
||||||
|
const stats = layer.getStats();
|
||||||
|
assert.equal(stats.keyRequired, true);
|
||||||
|
assert.equal(stats.loadingLabel, 'KEY REJECTED');
|
||||||
|
assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||||||
|
const ui = layer.getUIState();
|
||||||
|
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
|
||||||
|
const { layer, photoViewer } = await startLayer(t);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||||
|
assert.deepEqual(photoViewer.calls.open, ['img1']);
|
||||||
|
assert.equal(layer.getUIState().street.open, true);
|
||||||
|
layer.setParams({ mapillary: false });
|
||||||
|
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
|
||||||
|
assert.equal(layer.getUIState().street.open, false);
|
||||||
|
assert.equal(layer.getUIState().providers[0].on, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
|
||||||
|
const source = fakeMapillarySource({
|
||||||
|
getSequenceImages: async () => {
|
||||||
|
throw new Error('Sequence images unavailable');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The photo's pose names its sequence, which opening selects.
|
||||||
|
const photoViewer = fakePhotoViewer({
|
||||||
|
pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' },
|
||||||
|
});
|
||||||
|
const { layer } = await startLayer(t, { source, photoViewer });
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||||
|
await settle();
|
||||||
|
let ui = layer.getUIState();
|
||||||
|
assert.equal(ui.sequence.error, 'Sequence images unavailable');
|
||||||
|
assert.equal(ui.street.error, null, 'the photo itself is fine');
|
||||||
|
layer.closeViewer();
|
||||||
|
ui = layer.getUIState();
|
||||||
|
assert.equal(ui.sequence.error, null, 'withdrawn with the sequence');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
|
||||||
|
const { layer, viewer } = await startLayer(t, {
|
||||||
|
photoViewer: posingViewer(),
|
||||||
|
});
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||||
|
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
||||||
|
layer.closeViewer();
|
||||||
|
assert.equal(viewer.flights.cancelled, 1, 'closed');
|
||||||
|
|
||||||
|
await layer.openImage('mapillary', 'img2');
|
||||||
|
layer.disable();
|
||||||
|
assert.equal(viewer.flights.cancelled, 2, 'layer off');
|
||||||
|
|
||||||
|
layer.enable(viewer);
|
||||||
|
await layer.openImage('mapillary', 'img3');
|
||||||
|
layer.destroy();
|
||||||
|
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
|
||||||
|
const { layer } = await startLayer(t);
|
||||||
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
mapillary: true,
|
||||||
|
pano: 'all',
|
||||||
|
sinceDays: 0,
|
||||||
|
});
|
||||||
|
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||||
|
assert.equal(layer.getParams().sinceDays, 365);
|
||||||
|
layer.setParams({ sinceDays: 0 });
|
||||||
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
mapillary: true,
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: 0,
|
||||||
|
});
|
||||||
|
layer.setParams({ mapillary: false });
|
||||||
|
assert.equal(layer.getParams().mapillary, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setParams ignores unknown keys and malformed values', async (t) => {
|
||||||
|
const { layer } = await startLayer(t);
|
||||||
|
layer.setParams({ pano: 'pano', sinceDays: 730 });
|
||||||
|
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
|
||||||
|
layer.setParams({ sinceDays: -3 });
|
||||||
|
layer.setParams(null);
|
||||||
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
mapillary: true,
|
||||||
|
pano: 'pano',
|
||||||
|
sinceDays: 730,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching Mapillary on while the layer is off draws nothing', async (t) => {
|
||||||
|
const { layer, viewer, source, photoViewer } = await startLayer(t, {
|
||||||
|
view: true,
|
||||||
|
enable: false,
|
||||||
|
});
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
layer.setParams({ mapillary: false });
|
||||||
|
layer.setParams({ mapillary: true });
|
||||||
|
await settle();
|
||||||
|
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
|
||||||
|
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||||
|
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
|
||||||
|
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||||||
|
// Enabling the layer is what draws it.
|
||||||
|
layer.enable(viewer);
|
||||||
|
await settle();
|
||||||
|
assert.ok(source.calls.tiles.length > 0);
|
||||||
|
assert.equal(viewer.credits.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
||||||
|
const { layer, viewer } = await startLayer(t, { providers: [] });
|
||||||
|
assert.deepEqual(layer.providerIds, []);
|
||||||
|
assert.deepEqual(layer.getUIState().providers, []);
|
||||||
|
assert.equal(layer.getUIState().keyRequired, false);
|
||||||
|
assert.match(
|
||||||
|
layer.getUIState().coverage.hint,
|
||||||
|
/No street-level imagery providers/,
|
||||||
|
);
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
assert.deepEqual(layer.getUIState().providers, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Several providers: one source at a time, Google 3D, nearest lookups ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
||||||
* click handler, camera events, a primitive list and a credit display.
|
* click handler, camera events, a primitive list and a credit display.
|
||||||
@@ -55,8 +352,11 @@ function fakeViewer() {
|
|||||||
},
|
},
|
||||||
creditDisplay: {
|
creditDisplay: {
|
||||||
addStaticCredit: (credit) => credits.push(credit),
|
addStaticCredit: (credit) => credits.push(credit),
|
||||||
removeStaticCredit: (credit) =>
|
removeStaticCredit: (credit) => {
|
||||||
credits.splice(credits.indexOf(credit), 1),
|
// Cesium ignores a credit it does not hold.
|
||||||
|
const at = credits.indexOf(credit);
|
||||||
|
if (at !== -1) credits.splice(at, 1);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,19 +381,6 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
|
|||||||
return { layer, viewer };
|
return { layer, viewer };
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
function deferred() {
|
|
||||||
let resolve;
|
|
||||||
let reject;
|
|
||||||
const promise = new Promise((done, fail) => {
|
|
||||||
resolve = done;
|
|
||||||
reject = fail;
|
|
||||||
});
|
|
||||||
return { promise, resolve, reject };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A provider whose viewer reports a pose for every image it opens. */
|
|
||||||
function posingProvider(options) {
|
function posingProvider(options) {
|
||||||
const provider = fakeProvider(options);
|
const provider = fakeProvider(options);
|
||||||
const create = provider.create;
|
const create = provider.create;
|
||||||
@@ -148,35 +435,6 @@ function fakeMapStack(initial) {
|
|||||||
listenerCount: () => listeners.size,
|
listenerCount: () => listeners.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('FOLLOW is available only while the Google 3D map stack is active', () => {
|
|
||||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
|
||||||
assert.equal(
|
|
||||||
layer.getUIState().street.followAvailable,
|
|
||||||
false,
|
|
||||||
'unknown stack: off',
|
|
||||||
);
|
|
||||||
|
|
||||||
const stack = fakeMapStack('esri-imagery');
|
|
||||||
layer.attachMapStackController(stack);
|
|
||||||
assert.equal(layer.getUIState().street.followAvailable, false);
|
|
||||||
layer.setFollow(true);
|
|
||||||
assert.equal(layer.getUIState().street.follow, false, 'refused on Esri');
|
|
||||||
|
|
||||||
stack.switchTo('photoreal');
|
|
||||||
assert.equal(layer.getUIState().street.followAvailable, true);
|
|
||||||
layer.setFollow(true);
|
|
||||||
assert.equal(layer.getUIState().street.follow, true);
|
|
||||||
|
|
||||||
stack.switchTo('osm');
|
|
||||||
assert.equal(layer.getUIState().street.followAvailable, false);
|
|
||||||
assert.equal(
|
|
||||||
layer.getUIState().street.follow,
|
|
||||||
false,
|
|
||||||
'leaving Google 3D stops following',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the layer lets go of the map stack when destroyed or re-attached', () => {
|
test('the layer lets go of the map stack when destroyed or re-attached', () => {
|
||||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||||
const first = fakeMapStack('photoreal');
|
const first = fakeMapStack('photoreal');
|
||||||
@@ -190,60 +448,6 @@ test('the layer lets go of the map stack when destroyed or re-attached', () => {
|
|||||||
assert.equal(second.listenerCount(), 0);
|
assert.equal(second.listenerCount(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
|
|
||||||
const { layer, viewer } = await enabledLayer(t);
|
|
||||||
const shown = () => viewer.credits.map((credit) => credit.html);
|
|
||||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
|
|
||||||
assert.match(shown()[0], /CC BY-SA 4\.0/);
|
|
||||||
layer.setProviderEnabled('mapillary', false);
|
|
||||||
assert.deepEqual(shown(), [], 'hidden with the provider');
|
|
||||||
layer.setProviderEnabled('mapillary', true);
|
|
||||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
|
|
||||||
layer.disable();
|
|
||||||
assert.deepEqual(shown(), [], 'every credit goes with the layer');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getStats tells a rejected key from a missing one', async (t) => {
|
|
||||||
const provider = fakeProvider();
|
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
|
||||||
provider.stats.loading = true;
|
|
||||||
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
|
|
||||||
assert.equal(layer.getStats().keyRequired, false);
|
|
||||||
|
|
||||||
provider.stats.keyRequired = true;
|
|
||||||
assert.deepEqual(
|
|
||||||
[layer.getStats().loadingLabel, layer.getStats().error],
|
|
||||||
['KEY REQUIRED', 'KEY REQUIRED'],
|
|
||||||
);
|
|
||||||
|
|
||||||
// A rejected key gates the provider exactly like a missing one.
|
|
||||||
provider.stats.keyRequired = false;
|
|
||||||
provider.stats.keyRejected = true;
|
|
||||||
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
|
|
||||||
const stats = layer.getStats();
|
|
||||||
assert.equal(stats.keyRequired, true);
|
|
||||||
assert.equal(stats.loadingLabel, 'KEY REJECTED');
|
|
||||||
assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
|
|
||||||
const ui = layer.getUIState();
|
|
||||||
assert.equal(ui.keyRequired, true);
|
|
||||||
assert.equal(ui.keyRejected, true);
|
|
||||||
assert.equal(ui.providers[0].keyRequired, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('switching a provider off deactivates it and closes the image it shows', async (t) => {
|
|
||||||
const provider = fakeProvider();
|
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
|
||||||
layer.attachViewerHost({});
|
|
||||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
|
||||||
assert.deepEqual(provider.calls.open, ['img1']);
|
|
||||||
assert.equal(layer.getUIState().street.open, true);
|
|
||||||
layer.setProviderEnabled('mapillary', false);
|
|
||||||
assert.equal(provider.calls.deactivate, 1);
|
|
||||||
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
|
|
||||||
assert.equal(layer.getUIState().street.open, false);
|
|
||||||
assert.equal(layer.getUIState().street.providerId, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('openNearest without the panel reports it instead of loading forever', async (t) => {
|
test('openNearest without the panel reports it instead of loading forever', async (t) => {
|
||||||
const provider = fakeProvider({ nearestImage: async () => 'img1' });
|
const provider = fakeProvider({ nearestImage: async () => 'img1' });
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
@@ -299,40 +503,6 @@ test('a provider withdraws only the error it reported', async (t) => {
|
|||||||
actions.reportError(null);
|
actions.reportError(null);
|
||||||
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the photo stops the globe flying to it', async (t) => {
|
|
||||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
|
||||||
layer.attachViewerHost({});
|
|
||||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
|
||||||
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
|
||||||
layer.closeViewer();
|
|
||||||
assert.equal(viewer.flights.cancelled, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('switching the layer off or destroying it stops the framing flight', async (t) => {
|
|
||||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
|
||||||
layer.attachViewerHost({});
|
|
||||||
await layer.openImage('mapillary', 'img1');
|
|
||||||
layer.disable();
|
|
||||||
assert.equal(viewer.flights.cancelled, 1, 'layer off');
|
|
||||||
|
|
||||||
layer.enable(viewer);
|
|
||||||
await layer.openImage('mapillary', 'img2');
|
|
||||||
layer.destroy();
|
|
||||||
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('closing the photo leaves a newer navigation flight alone', async (t) => {
|
|
||||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
|
||||||
layer.attachViewerHost({});
|
|
||||||
await layer.openImage('mapillary', 'img1');
|
|
||||||
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
|
|
||||||
// A search result flies the globe elsewhere before the framing lands.
|
|
||||||
viewer.camera.flyToBoundingSphere(null, {});
|
|
||||||
layer.closeViewer();
|
|
||||||
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
||||||
const { lookups, nearestImage } = slowLookups();
|
const { lookups, nearestImage } = slowLookups();
|
||||||
const provider = fakeProvider({ nearestImage });
|
const provider = fakeProvider({ nearestImage });
|
||||||
@@ -395,54 +565,6 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
|
|||||||
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
|
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the Mapillary provider hands the lookup signal to its source', async () => {
|
|
||||||
const requests = [];
|
|
||||||
const source = {
|
|
||||||
hasToken: () => true,
|
|
||||||
getStatus: async () => ({ configured: true }),
|
|
||||||
getTile: async () => new Uint8Array(0),
|
|
||||||
getSequenceImages: async () => [],
|
|
||||||
nearestImages: async (query, options) => {
|
|
||||||
requests.push(options);
|
|
||||||
return [{ id: 7, is_pano: false, captured_at: 10 }];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const instance = createMapillaryProvider({ source }).create({
|
|
||||||
services: {},
|
|
||||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
|
||||||
isActive: () => true,
|
|
||||||
notify() {},
|
|
||||||
actions: { openImage() {}, reportError() {} },
|
|
||||||
});
|
|
||||||
const { signal } = new AbortController();
|
|
||||||
assert.equal(
|
|
||||||
await instance.nearestImage({ lat: 1, lon: 2 }, { signal }),
|
|
||||||
'7',
|
|
||||||
);
|
|
||||||
assert.equal(requests[0]?.signal, signal);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
|
|
||||||
const provider = fakeProvider();
|
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
|
||||||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
|
||||||
assert.equal(layer.getParams().sinceDays, 365);
|
|
||||||
layer.setParams({ sinceDays: 0 });
|
|
||||||
assert.deepEqual(layer.getParams(), {
|
|
||||||
mapillary: true,
|
|
||||||
pano: 'flat',
|
|
||||||
sinceDays: 0,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
provider.filters.at(-1).sinceMs,
|
|
||||||
null,
|
|
||||||
'providers drop the cut-off',
|
|
||||||
);
|
|
||||||
layer.setParams({ mapillary: false });
|
|
||||||
assert.equal(layer.getParams().mapillary, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A layer initialised on a stand-in viewer but never enabled. */
|
|
||||||
function initialisedLayer(t, providers) {
|
function initialisedLayer(t, providers) {
|
||||||
const saved = globalThis.document;
|
const saved = globalThis.document;
|
||||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||||
@@ -635,7 +757,7 @@ test('one imagery source at a time: lighting one darkens the other, either way',
|
|||||||
assert.deepEqual(lit(), []);
|
assert.deepEqual(lit(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
|
test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => {
|
||||||
const { layer, viewer, stack } = await withStreetView(t, {
|
const { layer, viewer, stack } = await withStreetView(t, {
|
||||||
mapStack: 'esri-imagery',
|
mapStack: 'esri-imagery',
|
||||||
});
|
});
|
||||||
@@ -646,7 +768,6 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
|
|||||||
const credited = () =>
|
const credited = () =>
|
||||||
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
|
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
|
||||||
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
|
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(credited(), false, 'no Google credit on another map');
|
||||||
assert.equal(layer.source, 'Mapillary');
|
assert.equal(layer.source, 'Mapillary');
|
||||||
assert.equal(layer.getStats().source, 'Mapillary');
|
assert.equal(layer.getStats().source, 'Mapillary');
|
||||||
@@ -668,6 +789,62 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
|
|||||||
assert.deepEqual(listed(), ['mapillary']);
|
assert.deepEqual(listed(), ['mapillary']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => {
|
||||||
|
const saved = globalThis.document;
|
||||||
|
globalThis.document = new EventTarget();
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = saved;
|
||||||
|
});
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const viewer = fakeViewer();
|
||||||
|
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
|
||||||
|
let ground;
|
||||||
|
viewer.camera.positionCartographic = { height: 4000 };
|
||||||
|
viewer.scene.globe = { show: false };
|
||||||
|
viewer.scene.sampleHeightSupported = true;
|
||||||
|
viewer.scene.sampleHeight = () => ground;
|
||||||
|
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||||
|
layer.init(viewer);
|
||||||
|
layer.enable(viewer);
|
||||||
|
t.after(() => layer.destroy());
|
||||||
|
assert.equal(layer.getUIState().groundClickReady, false);
|
||||||
|
|
||||||
|
ground = 2000;
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(layer.getUIState().groundClickReady, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => {
|
||||||
|
const saved = globalThis.document;
|
||||||
|
globalThis.document = new EventTarget();
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = saved;
|
||||||
|
});
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const viewer = fakeViewer();
|
||||||
|
let samples = 0;
|
||||||
|
viewer.camera.positionCartographic = { height: 4000 };
|
||||||
|
viewer.scene.globe = { show: false };
|
||||||
|
viewer.scene.sampleHeightSupported = true;
|
||||||
|
viewer.scene.sampleHeight = () => {
|
||||||
|
samples++;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||||
|
layer.init(viewer);
|
||||||
|
layer.enable(viewer);
|
||||||
|
t.after(() => layer.destroy());
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
const measured = samples;
|
||||||
|
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
|
||||||
|
|
||||||
|
viewer.camera.moveEnd.raiseEvent();
|
||||||
|
layer.disable();
|
||||||
|
const atDisable = samples;
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
assert.equal(samples, atDisable, 'no measuring once the layer is off');
|
||||||
|
});
|
||||||
|
|
||||||
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
|
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
|
||||||
const { layer } = await withStreetView(t);
|
const { layer } = await withStreetView(t);
|
||||||
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
|
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
|
||||||
@@ -825,20 +1002,6 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl
|
|||||||
assert.equal(layer.getStats().keyRequired, false);
|
assert.equal(layer.getStats().keyRequired, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
|
||||||
const { layer, viewer } = await enabledLayer(t, []);
|
|
||||||
assert.deepEqual(layer.providerIds, []);
|
|
||||||
assert.deepEqual(layer.getUIState().providers, []);
|
|
||||||
assert.equal(layer.getUIState().keyRequired, false);
|
|
||||||
assert.match(
|
|
||||||
layer.getUIState().coverage.hint,
|
|
||||||
/No street-level imagery providers/,
|
|
||||||
);
|
|
||||||
layer.disable();
|
|
||||||
layer.enable(viewer);
|
|
||||||
assert.deepEqual(layer.getUIState().providers, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
|
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
|
||||||
const failing = fakeProvider({
|
const failing = fakeProvider({
|
||||||
nearestImage: async () => {
|
nearestImage: async () => {
|
||||||
@@ -890,7 +1053,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav
|
|||||||
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
|
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => {
|
test('KartaView through the core: its lines counted as streets', async (t) => {
|
||||||
const kartaView = createKartaViewProvider({
|
const kartaView = createKartaViewProvider({
|
||||||
source: {
|
source: {
|
||||||
getTile: async () => ({ lines: [] }),
|
getTile: async () => ({ lines: [] }),
|
||||||
@@ -906,15 +1069,7 @@ test('KartaView through the core: its own swatch without Selected, its lines cou
|
|||||||
layer.setProviderEnabled('kartaview', true);
|
layer.setProviderEnabled('kartaview', true);
|
||||||
await settle();
|
await settle();
|
||||||
const ui = layer.getUIState();
|
const ui = layer.getUIState();
|
||||||
assert.deepEqual(
|
|
||||||
ui.legend.map((entry) => entry.key),
|
|
||||||
['kartaview'],
|
|
||||||
);
|
|
||||||
assert.equal(ui.coverage.label?.noun, 'streets');
|
assert.equal(ui.coverage.label?.noun, 'streets');
|
||||||
assert.equal(
|
|
||||||
ui.providers.find((p) => p.id === 'kartaview').highlightsSequence,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
|
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
|
||||||
@@ -962,3 +1117,13 @@ test('switching source by params clears a failed lookup’s error; reapplying th
|
|||||||
second.setParams({ mapillary: false, other: true });
|
second.setParams({ mapillary: false, other: true });
|
||||||
assert.equal(second.getUIState().street.error, kept);
|
assert.equal(second.getUIState().street.error, kept);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => {
|
||||||
|
const { presentStreetLevelPanel } =
|
||||||
|
await import('../../ui/streetLevelPresentation.js');
|
||||||
|
const { layer, stack } = await withStreetView(t);
|
||||||
|
layer.setProviderEnabled('google', true);
|
||||||
|
stack.switchTo('esri-imagery');
|
||||||
|
const view = presentStreetLevelPanel(layer.getUIState());
|
||||||
|
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
|
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
|
||||||
import { COLORS, POSITION_PICK_ID } from './policy.js';
|
import { COLORS, POSITION_PICK_ID } from './policy.js';
|
||||||
import { refineHeights } from './groundCast.js';
|
|
||||||
import { createHorizonCull } from './view.js';
|
import { createHorizonCull } from './view.js';
|
||||||
|
|
||||||
const SPRITE_ID = 'street-level:marker';
|
const SPRITE_ID = 'street-level:marker';
|
||||||
@@ -22,10 +21,9 @@ function markerLook(style) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
|
/** On-globe marker for the open image, clamped to the ground. */
|
||||||
export function createMarker({ state, parts }) {
|
export function createMarker({ state }) {
|
||||||
const { render, sprites } = state.services;
|
const { render, sprites } = state.services;
|
||||||
let last = null;
|
|
||||||
|
|
||||||
function requestRender() {
|
function requestRender() {
|
||||||
render?.governorRequestRender?.('street-level-marker');
|
render?.governorRequestRender?.('street-level-marker');
|
||||||
@@ -47,38 +45,10 @@ export function createMarker({ state, parts }) {
|
|||||||
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
|
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Terrain-mode height (bare earth refined by mesh samples), or null to
|
|
||||||
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
|
|
||||||
*/
|
|
||||||
function castHeight(position) {
|
|
||||||
const ground = parts?.groundCaster;
|
|
||||||
if (state.surface !== 'terrain' || !ground) return null;
|
|
||||||
const { lon, lat } = position;
|
|
||||||
const dem = ground.groundAt(lon, lat);
|
|
||||||
if (dem === null) {
|
|
||||||
ground.prepare([[lon, lat]]).then((ready) => {
|
|
||||||
if (ready && last?.position === position)
|
|
||||||
set(position, last.bearing, last.style);
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const mesh = parts.meshSampler?.meshAt(lon, lat);
|
|
||||||
if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]);
|
|
||||||
return refineHeights([{ dem, mesh }])[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new mesh sample may move the marker onto (or off) the road surface.
|
|
||||||
parts?.meshSampler?.onSampled(() => {
|
|
||||||
if (last && state.surface === 'terrain')
|
|
||||||
set(last.position, last.bearing, last.style);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
|
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
|
||||||
function set(position, bearing, style = null) {
|
function set(position, bearing, style = null) {
|
||||||
const collection = state.marker.collection;
|
const collection = state.marker.collection;
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
last = position ? { position, bearing, style } : null;
|
|
||||||
if (!position) {
|
if (!position) {
|
||||||
collection.removeAll();
|
collection.removeAll();
|
||||||
state.marker.billboard = null;
|
state.marker.billboard = null;
|
||||||
@@ -86,16 +56,7 @@ export function createMarker({ state, parts }) {
|
|||||||
requestRender();
|
requestRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const height = castHeight(position);
|
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
|
||||||
const cartesian = Cesium.Cartesian3.fromDegrees(
|
|
||||||
position.lon,
|
|
||||||
position.lat,
|
|
||||||
height ?? 0,
|
|
||||||
);
|
|
||||||
const heightReference =
|
|
||||||
height === null
|
|
||||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
|
||||||
: Cesium.HeightReference.NONE;
|
|
||||||
const look = markerLook(style);
|
const look = markerLook(style);
|
||||||
if (state.marker.billboard && state.marker.look !== look.key) {
|
if (state.marker.billboard && state.marker.look !== look.key) {
|
||||||
collection.remove?.(state.marker.billboard);
|
collection.remove?.(state.marker.billboard);
|
||||||
@@ -111,12 +72,11 @@ export function createMarker({ state, parts }) {
|
|||||||
width: look.size,
|
width: look.size,
|
||||||
height: look.size,
|
height: look.size,
|
||||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||||
heightReference,
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
state.marker.billboard.heightReference = heightReference;
|
|
||||||
state.marker.billboard.position = cartesian;
|
state.marker.billboard.position = cartesian;
|
||||||
}
|
}
|
||||||
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
||||||
@@ -139,17 +99,11 @@ export function createMarker({ state, parts }) {
|
|||||||
state.marker.billboard = null;
|
state.marker.billboard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-place the marker after the surface mode changes. */
|
|
||||||
function setSurface() {
|
|
||||||
if (last) set(last.position, last.bearing, last.style);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ensure,
|
ensure,
|
||||||
set,
|
set,
|
||||||
clear: () => set(null),
|
clear: () => set(null),
|
||||||
setVisible,
|
setVisible,
|
||||||
setSurface,
|
|
||||||
destroy,
|
destroy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2,17 +2,9 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createMarker } from './marker.js';
|
import { createMarker } from './marker.js';
|
||||||
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
|
|
||||||
|
|
||||||
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
|
||||||
const noop = () => {};
|
|
||||||
const context2d = new Proxy({}, { get: () => noop, set: () => true });
|
|
||||||
globalThis.document ??= {
|
|
||||||
createElement: () => ({ getContext: () => context2d }),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||||
function setup({ parts = {}, surface = 'draped' } = {}) {
|
function setup() {
|
||||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||||
const preRender = new Set();
|
const preRender = new Set();
|
||||||
const viewer = {
|
const viewer = {
|
||||||
@@ -34,7 +26,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
|||||||
const state = {
|
const state = {
|
||||||
services: {},
|
services: {},
|
||||||
viewer,
|
viewer,
|
||||||
surface,
|
|
||||||
marker: {
|
marker: {
|
||||||
// Stands in for the billboard collection; ensure() keeps it.
|
// Stands in for the billboard collection; ensure() keeps it.
|
||||||
collection: {
|
collection: {
|
||||||
@@ -46,7 +37,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
|||||||
billboard: null,
|
billboard: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const marker = createMarker({ state, parts });
|
const marker = createMarker({ state });
|
||||||
const frame = () => {
|
const frame = () => {
|
||||||
for (const listener of [...preRender]) listener();
|
for (const listener of [...preRender]) listener();
|
||||||
};
|
};
|
||||||
@@ -79,114 +70,23 @@ test('destroying the marker stops the horizon cull', () => {
|
|||||||
assert.equal(preRender.size, 0);
|
assert.equal(preRender.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Bare earth from a ground caster (null until its cell is fetched) and the
|
|
||||||
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
|
|
||||||
*/
|
|
||||||
function terrainParts({ dem = null, mesh } = {}) {
|
|
||||||
const ground = { dem, prepares: [] };
|
|
||||||
const sampler = { mesh, requests: [], listeners: [] };
|
|
||||||
return {
|
|
||||||
ground,
|
|
||||||
sampler,
|
|
||||||
parts: {
|
|
||||||
groundCaster: {
|
|
||||||
groundAt: () => ground.dem,
|
|
||||||
prepare(points) {
|
|
||||||
let resolve;
|
|
||||||
const promise = new Promise((done) => {
|
|
||||||
resolve = done;
|
|
||||||
});
|
|
||||||
ground.prepares.push({ points, resolve });
|
|
||||||
return promise;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
meshSampler: {
|
|
||||||
meshAt: () => sampler.mesh,
|
|
||||||
request: (points) => sampler.requests.push(points),
|
|
||||||
onSampled: (listener) => sampler.listeners.push(listener),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const heightOf = (billboard) =>
|
|
||||||
Cesium.Cartographic.fromCartesian(billboard.position).height;
|
|
||||||
const SPOT = { lon: -121.49, lat: 38.58 };
|
const SPOT = { lon: -121.49, lat: 38.58 };
|
||||||
|
|
||||||
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
|
test('the marker is clamped to the ground and turned to the bearing', () => {
|
||||||
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
|
const { state, marker } = setup();
|
||||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
|
||||||
marker.set(SPOT, 45);
|
|
||||||
const billboard = state.marker.billboard;
|
|
||||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
|
|
||||||
`height ${heightOf(billboard)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
|
|
||||||
const { parts, sampler } = terrainParts({ dem: 10 });
|
|
||||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
|
||||||
marker.set(SPOT, 45);
|
|
||||||
const billboard = state.marker.billboard;
|
|
||||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
|
|
||||||
`height ${heightOf(billboard)}`,
|
|
||||||
);
|
|
||||||
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
|
|
||||||
// The sample lands: the marker moves onto the road surface.
|
|
||||||
sampler.mesh = 10.5;
|
|
||||||
for (const listener of sampler.listeners) listener();
|
|
||||||
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
|
|
||||||
const { parts, ground } = terrainParts({ mesh: 11 });
|
|
||||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
|
||||||
marker.set(SPOT, 45);
|
marker.set(SPOT, 45);
|
||||||
const billboard = state.marker.billboard;
|
const billboard = state.marker.billboard;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
billboard.heightReference,
|
billboard.heightReference,
|
||||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
);
|
);
|
||||||
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
|
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45));
|
||||||
ground.dem = 10;
|
// Moving it keeps the same billboard.
|
||||||
ground.prepares[0].resolve(true);
|
marker.set({ lon: -121.5, lat: 38.6 }, 90);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
assert.equal(state.marker.billboard, billboard);
|
||||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
const placed = Cesium.Cartographic.fromCartesian(billboard.position);
|
||||||
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
|
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9);
|
||||||
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
|
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90));
|
||||||
});
|
|
||||||
|
|
||||||
test('ground that arrives after the marker moved on does not move it back', async () => {
|
|
||||||
const { parts, ground } = terrainParts();
|
|
||||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
|
||||||
marker.set(SPOT, 0);
|
|
||||||
const elsewhere = { lon: -121.5, lat: 38.6 };
|
|
||||||
marker.set(elsewhere, 0);
|
|
||||||
ground.dem = 10;
|
|
||||||
ground.prepares[0].resolve(true);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
const placed = Cesium.Cartographic.fromCartesian(
|
|
||||||
state.marker.billboard.position,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('draped mode clamps the marker to the globe whatever the ground says', () => {
|
|
||||||
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
|
|
||||||
const { state, marker } = setup({ parts, surface: 'draped' });
|
|
||||||
marker.set(SPOT, 0);
|
|
||||||
assert.equal(
|
|
||||||
state.marker.billboard.heightReference,
|
|
||||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
||||||
);
|
|
||||||
assert.equal(ground.prepares.length, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
|
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
|
||||||
@@ -205,9 +105,6 @@ test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pi
|
|||||||
// Dragging the panorama keeps the look and the billboard.
|
// Dragging the panorama keeps the look and the billboard.
|
||||||
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
|
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
|
||||||
assert.equal(state.marker.billboard, cone);
|
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.
|
// A Mapillary photo next (its provider has no marker): the pin.
|
||||||
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
|
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
|
||||||
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
|
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { metresBetween, whenIdle } from './view.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
|
|
||||||
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
|
|
||||||
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
|
|
||||||
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
|
|
||||||
* mesh floor, a probe waits for the visible tileset to finish streaming and a
|
|
||||||
* sample counts only within the shared window around a real bare-earth height;
|
|
||||||
* anything else is a miss, retried later. Probes hit 3D tilesets only.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
|
|
||||||
export const MESH_CELL_DEG = 0.0001;
|
|
||||||
/** Cells farther than this from the point under the camera are not probed. */
|
|
||||||
export const MESH_SAMPLE_RADIUS_M = 900;
|
|
||||||
/** Main-thread time per idle slice, taking requests in and ranking included. */
|
|
||||||
export const MESH_SAMPLE_BUDGET_MS = 6;
|
|
||||||
/** The queue is re-ranked once the camera has moved this far, in metres. */
|
|
||||||
export const MESH_RERANK_M = 100;
|
|
||||||
/** A cell whose probe missed is tried again after this long. */
|
|
||||||
export const MESH_MISS_RETRY_MS = 8000;
|
|
||||||
/** While the visible tileset is streaming, the next probe waits this long. */
|
|
||||||
export const MESH_STREAMING_WAIT_MS = 500;
|
|
||||||
/**
|
|
||||||
* A sampled cell is probed again once the camera is half as far from it as at
|
|
||||||
* its last probe (finer tiles have loaded by then), down to this distance:
|
|
||||||
* at most a handful of probes per cell.
|
|
||||||
*/
|
|
||||||
export const MESH_REFRESH_MIN_M = 40;
|
|
||||||
/** A refreshed sample that moved less than this is not redrawn. */
|
|
||||||
const MESH_REFRESH_CHANGE_M = 0.5;
|
|
||||||
/** Cached cells (and remembered misses) before they are dropped and refilled. */
|
|
||||||
const MESH_CACHE_MAX = 80_000;
|
|
||||||
/** Listeners hear about new samples at most this often. */
|
|
||||||
const MESH_NOTIFY_MS = 700;
|
|
||||||
|
|
||||||
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
|
|
||||||
|
|
||||||
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
|
|
||||||
export const meshCellKey = (lon, lat) =>
|
|
||||||
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
|
|
||||||
|
|
||||||
/** Add a queued cell to a min-heap on `distance`. */
|
|
||||||
function heapPush(heap, cell) {
|
|
||||||
let i = heap.push(cell) - 1;
|
|
||||||
while (i > 0) {
|
|
||||||
const parent = (i - 1) >> 1;
|
|
||||||
if (heap[parent].distance <= cell.distance) break;
|
|
||||||
heap[i] = heap[parent];
|
|
||||||
i = parent;
|
|
||||||
}
|
|
||||||
heap[i] = cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restore the heap below `i` after its cell was replaced. */
|
|
||||||
function siftDown(heap, i) {
|
|
||||||
const cell = heap[i];
|
|
||||||
const n = heap.length;
|
|
||||||
for (;;) {
|
|
||||||
let child = 2 * i + 1;
|
|
||||||
if (child >= n) break;
|
|
||||||
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
|
|
||||||
child++;
|
|
||||||
if (heap[child].distance >= cell.distance) break;
|
|
||||||
heap[i] = heap[child];
|
|
||||||
i = child;
|
|
||||||
}
|
|
||||||
heap[i] = cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
function heapPop(heap) {
|
|
||||||
const top = heap[0];
|
|
||||||
const last = heap.pop();
|
|
||||||
if (heap.length) {
|
|
||||||
heap[0] = last;
|
|
||||||
siftDown(heap, 0);
|
|
||||||
}
|
|
||||||
return top;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every top-level primitive that is not a 3D tileset: what a probe skips. */
|
|
||||||
function overlays(scene) {
|
|
||||||
const out = [];
|
|
||||||
const primitives = scene.primitives;
|
|
||||||
for (let i = 0; i < primitives.length; i++) {
|
|
||||||
const primitive = primitives.get(i);
|
|
||||||
if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
|
|
||||||
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
|
|
||||||
* visible tileset has finished streaming.
|
|
||||||
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
|
|
||||||
*/
|
|
||||||
export function createMeshSampler({
|
|
||||||
getViewer,
|
|
||||||
groundAt = null,
|
|
||||||
withinPrior = null,
|
|
||||||
tilesReady = null,
|
|
||||||
budgetMs = MESH_SAMPLE_BUDGET_MS,
|
|
||||||
radiusM = MESH_SAMPLE_RADIUS_M,
|
|
||||||
cacheMax = MESH_CACHE_MAX,
|
|
||||||
now = () => performance.now(),
|
|
||||||
}) {
|
|
||||||
/** cell key → sampled mesh height (ellipsoidal metres). */
|
|
||||||
const heights = new Map();
|
|
||||||
/** cell key → camera distance (m) at the cell's last probe. */
|
|
||||||
const probedFrom = new Map();
|
|
||||||
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
|
|
||||||
const misses = new Map();
|
|
||||||
/** cell key → queued cell, so a cell is queued once. */
|
|
||||||
const wanted = new Map();
|
|
||||||
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
|
|
||||||
let queue = [];
|
|
||||||
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
|
|
||||||
let intake = [];
|
|
||||||
/** Where the camera was when the queue was ranked. */
|
|
||||||
let rankedFrom = null;
|
|
||||||
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
|
|
||||||
let probeMs = 1;
|
|
||||||
const listeners = new Set();
|
|
||||||
let enabled = false;
|
|
||||||
let running = false;
|
|
||||||
let fresh = [];
|
|
||||||
let notifyTimer = null;
|
|
||||||
|
|
||||||
/** The sampled mesh height for a point, or undefined when not sampled. */
|
|
||||||
function meshAt(lon, lat) {
|
|
||||||
return heights.get(meshCellKey(lon, lat));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The point under the camera, with the camera's height, or null. */
|
|
||||||
function cameraCentre() {
|
|
||||||
const carto = getViewer()?.camera?.positionCartographic;
|
|
||||||
if (!carto) return null;
|
|
||||||
return {
|
|
||||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
|
||||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
|
||||||
height: carto.height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Camera distance (m) to a cell at `height`, from its ground distance. */
|
|
||||||
function cameraDistance(ground, centre, height) {
|
|
||||||
const above = (centre.height ?? 0) - (height ?? 0);
|
|
||||||
return Number.isFinite(above)
|
|
||||||
? Math.hypot(ground, Math.max(0, above))
|
|
||||||
: ground;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A sampled cell is probed again once the camera is half as far as before. */
|
|
||||||
function dueForRefresh(key, distance) {
|
|
||||||
const last = probedFrom.get(key);
|
|
||||||
return last > MESH_REFRESH_MIN_M && distance < last / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A probe result the application would accept, or undefined. */
|
|
||||||
function validated(height, lon, lat) {
|
|
||||||
if (!Number.isFinite(height)) return undefined;
|
|
||||||
if (!groundAt) return height;
|
|
||||||
const prior = groundAt(lon, lat);
|
|
||||||
if (!Number.isFinite(prior)) return undefined;
|
|
||||||
return !withinPrior || withinPrior(height, prior) ? height : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask for the cells under [lon, lat] points; the list is read later, so do
|
|
||||||
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
|
|
||||||
*/
|
|
||||||
function request(points) {
|
|
||||||
if (!enabled || !points?.length) return;
|
|
||||||
const centre = cameraCentre();
|
|
||||||
if (!centre) return;
|
|
||||||
intake.push({ points, next: 0, centre });
|
|
||||||
schedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
|
|
||||||
function takeIn(until) {
|
|
||||||
const time = Date.now();
|
|
||||||
while (intake.length) {
|
|
||||||
const pending = intake[0];
|
|
||||||
const { points, centre } = pending;
|
|
||||||
while (pending.next < points.length) {
|
|
||||||
if ((pending.next & 255) === 255 && now() > until) return;
|
|
||||||
const [lon, lat] = points[pending.next++];
|
|
||||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
|
||||||
const key = meshCellKey(lon, lat);
|
|
||||||
if (wanted.has(key)) continue;
|
|
||||||
const cell = {
|
|
||||||
key,
|
|
||||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
|
||||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
|
||||||
distance: 0,
|
|
||||||
};
|
|
||||||
const ground = metresBetween(cell, centre);
|
|
||||||
if (ground > radiusM) continue;
|
|
||||||
if (
|
|
||||||
heights.has(key) &&
|
|
||||||
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
const retryAt = misses.get(key);
|
|
||||||
if (retryAt !== undefined) {
|
|
||||||
if (retryAt > time) continue;
|
|
||||||
misses.delete(key);
|
|
||||||
}
|
|
||||||
cell.distance = metresBetween(cell, rankedFrom);
|
|
||||||
wanted.set(key, cell);
|
|
||||||
heapPush(queue, cell);
|
|
||||||
}
|
|
||||||
intake.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rank the queue from where the camera is now, dropping cells it has left
|
|
||||||
* out of range: one linear heapify, run only after the camera has moved.
|
|
||||||
*/
|
|
||||||
function rerank(centre) {
|
|
||||||
rankedFrom = centre;
|
|
||||||
const kept = [];
|
|
||||||
for (const cell of queue) {
|
|
||||||
cell.distance = metresBetween(cell, centre);
|
|
||||||
if (cell.distance > radiusM) wanted.delete(cell.key);
|
|
||||||
else kept.push(cell);
|
|
||||||
}
|
|
||||||
queue = kept;
|
|
||||||
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearQueue() {
|
|
||||||
intake = [];
|
|
||||||
wanted.clear();
|
|
||||||
queue = [];
|
|
||||||
rankedFrom = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedule() {
|
|
||||||
if (running || !enabled || !(queue.length || intake.length)) return;
|
|
||||||
running = true;
|
|
||||||
whenIdle(step, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emit() {
|
|
||||||
notifyTimer = null;
|
|
||||||
const batch = fresh;
|
|
||||||
fresh = [];
|
|
||||||
for (const listener of [...listeners]) {
|
|
||||||
try {
|
|
||||||
listener(batch);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[Data:StreetLevel] mesh listener error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function step(deadline) {
|
|
||||||
running = false;
|
|
||||||
// The slice starts now: taking requests in and ranking count too.
|
|
||||||
const started = now();
|
|
||||||
const until =
|
|
||||||
started +
|
|
||||||
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
|
|
||||||
const scene = getViewer()?.scene;
|
|
||||||
const centre = cameraCentre();
|
|
||||||
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
|
|
||||||
// Mid-stream tiles answer with coarse heights: wait for the stream.
|
|
||||||
if (tilesReady && !tilesReady(scene)) {
|
|
||||||
running = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
running = false;
|
|
||||||
schedule();
|
|
||||||
}, MESH_STREAMING_WAIT_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Nearest first, ranked from where the camera was; out-of-range cells
|
|
||||||
// are dropped (they stay bare earth).
|
|
||||||
let worked = false;
|
|
||||||
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
|
|
||||||
rerank(centre);
|
|
||||||
worked = true;
|
|
||||||
}
|
|
||||||
if (intake.length) {
|
|
||||||
// Leave room for a probe, so a stream of requests cannot starve them.
|
|
||||||
takeIn(until - probeMs);
|
|
||||||
worked = true;
|
|
||||||
}
|
|
||||||
const exclude = overlays(scene);
|
|
||||||
let probed = 0;
|
|
||||||
while (queue.length) {
|
|
||||||
// Stop before a probe would overrun the slice. A slice that did
|
|
||||||
// nothing else always probes once, so the queue drains.
|
|
||||||
if ((probed || worked) && now() + probeMs > until) break;
|
|
||||||
const { key, lon, lat } = heapPop(queue);
|
|
||||||
wanted.delete(key);
|
|
||||||
// Ranked from up to MESH_RERANK_M away: check the range from here.
|
|
||||||
const ground = metresBetween({ lon, lat }, centre);
|
|
||||||
if (ground > radiusM) continue;
|
|
||||||
probed++;
|
|
||||||
const probeStarted = now();
|
|
||||||
let height;
|
|
||||||
try {
|
|
||||||
height = scene.sampleHeight(
|
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
|
||||||
exclude,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
height = undefined;
|
|
||||||
}
|
|
||||||
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
|
||||||
height = validated(height, lon, lat);
|
|
||||||
const previous = heights.get(key);
|
|
||||||
if (height === undefined) {
|
|
||||||
// A miss, or a failed refresh: the old sample (if any) and the
|
|
||||||
// distance it was taken from stay, and the probe is retried after
|
|
||||||
// the same cooldown.
|
|
||||||
if (misses.size >= cacheMax) misses.clear();
|
|
||||||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (previous === undefined && heights.size >= cacheMax) forget();
|
|
||||||
misses.delete(key);
|
|
||||||
heights.set(key, height);
|
|
||||||
probedFrom.set(key, cameraDistance(ground, centre, height));
|
|
||||||
if (
|
|
||||||
previous === undefined ||
|
|
||||||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
|
|
||||||
)
|
|
||||||
fresh.push([lon, lat]);
|
|
||||||
}
|
|
||||||
if (fresh.length && !notifyTimer)
|
|
||||||
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
|
|
||||||
schedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
function forget() {
|
|
||||||
heights.clear();
|
|
||||||
probedFrom.clear();
|
|
||||||
misses.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Probe only while overlays are cast (Google 3D at street zoom). */
|
|
||||||
function setEnabled(on) {
|
|
||||||
enabled = on === true;
|
|
||||||
if (!enabled) clearQueue();
|
|
||||||
else schedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hear about newly sampled cells, as [lon, lat] cell centres. */
|
|
||||||
function onSampled(listener) {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy() {
|
|
||||||
enabled = false;
|
|
||||||
forget();
|
|
||||||
clearQueue();
|
|
||||||
listeners.clear();
|
|
||||||
clearTimeout(notifyTimer);
|
|
||||||
notifyTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { meshAt, request, setEnabled, onSampled, destroy };
|
|
||||||
}
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import {
|
|
||||||
MESH_CELL_DEG,
|
|
||||||
MESH_MISS_RETRY_MS,
|
|
||||||
MESH_REFRESH_MIN_M,
|
|
||||||
MESH_SAMPLE_BUDGET_MS,
|
|
||||||
MESH_STREAMING_WAIT_MS,
|
|
||||||
createMeshSampler,
|
|
||||||
} from './meshSampler.js';
|
|
||||||
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
|
|
||||||
import { metresBetween } from './view.js';
|
|
||||||
|
|
||||||
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
const RAD = Math.PI / 180;
|
|
||||||
|
|
||||||
/** A scene whose mesh is 100 m everywhere except where `holes` say. */
|
|
||||||
function fakeViewer({ holes = () => false } = {}) {
|
|
||||||
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
|
|
||||||
const overlay = { name: 'lines' };
|
|
||||||
const probes = [];
|
|
||||||
const scene = {
|
|
||||||
sampleHeightSupported: true,
|
|
||||||
primitives: {
|
|
||||||
length: 2,
|
|
||||||
get: (i) => [tileset, overlay][i],
|
|
||||||
},
|
|
||||||
sampleHeight(carto, exclude) {
|
|
||||||
const lon = carto.longitude / RAD;
|
|
||||||
const lat = carto.latitude / RAD;
|
|
||||||
probes.push({ lon, lat, exclude });
|
|
||||||
return holes(lon, lat) ? undefined : 100;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
probes,
|
|
||||||
overlay,
|
|
||||||
viewer: {
|
|
||||||
scene,
|
|
||||||
camera: {
|
|
||||||
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('cells are probed once, nearest first, with overlays excluded', async () => {
|
|
||||||
const { viewer, probes, overlay } = fakeViewer();
|
|
||||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
const heard = [];
|
|
||||||
sampler.onSampled((batch) => heard.push(batch.length));
|
|
||||||
sampler.request([
|
|
||||||
[10.004, 50],
|
|
||||||
[10.001, 50],
|
|
||||||
[10.00101, 50.00001], // same ~11 m cell as the previous point
|
|
||||||
]);
|
|
||||||
await settle(1000);
|
|
||||||
assert.equal(probes.length, 2);
|
|
||||||
assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first');
|
|
||||||
assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit');
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
|
||||||
assert.equal(sampler.meshAt(10.00101, 50.00001), 100);
|
|
||||||
assert.deepEqual(heard, [2]);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle();
|
|
||||||
assert.equal(probes.length, 2, 'a sampled cell is not probed again');
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => {
|
|
||||||
const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 });
|
|
||||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle();
|
|
||||||
assert.equal(probes.length, 0, 'disabled: nothing queued');
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
sampler.request([
|
|
||||||
[10.05, 50], // ~3.6 km away
|
|
||||||
[10.005, 50], // a hole: tiles not streamed
|
|
||||||
]);
|
|
||||||
await settle();
|
|
||||||
assert.equal(probes.length, 1);
|
|
||||||
assert.equal(sampler.meshAt(10.05, 50), undefined);
|
|
||||||
assert.equal(sampler.meshAt(10.005, 50), undefined);
|
|
||||||
assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Probes run on a 16 ms timer headless; with mocked timers, run them now. */
|
|
||||||
function probeNow(t) {
|
|
||||||
t.mock.timers.tick(20);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a missed cell is probed again after the retry window, not before', (t) => {
|
|
||||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
|
||||||
const { viewer, probes } = fakeViewer({ holes: () => true });
|
|
||||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(probes.length, 1, 'probed, and missed');
|
|
||||||
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(probes.length, 1, 'within the window: not probed');
|
|
||||||
t.mock.timers.tick(100 + 1);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(probes.length, 2, 'after the window: probed again');
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('remembered misses are bounded like the samples', (t) => {
|
|
||||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
|
||||||
const { viewer, probes } = fakeViewer({ holes: () => true });
|
|
||||||
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
for (const lon of [10.001, 10.002, 10.003]) {
|
|
||||||
sampler.request([[lon, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
}
|
|
||||||
assert.equal(probes.length, 3);
|
|
||||||
// The third miss overflowed the list, which was dropped: the first cell
|
|
||||||
// is asked about again rather than remembered forever.
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(probes.length, 4);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('destroy forgets the samples', (t) => {
|
|
||||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
|
||||||
const { viewer } = fakeViewer();
|
|
||||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
|
||||||
sampler.destroy();
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs`
|
|
||||||
* and each read of the camera `cameraMs` (work done before any probe).
|
|
||||||
*/
|
|
||||||
function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) {
|
|
||||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
|
||||||
const fake = fakeViewer({ holes });
|
|
||||||
const clock = { now: 0 };
|
|
||||||
const { scene, camera } = fake.viewer;
|
|
||||||
const probe = scene.sampleHeight;
|
|
||||||
scene.sampleHeight = (...args) => {
|
|
||||||
clock.now += probeMs;
|
|
||||||
return probe(...args);
|
|
||||||
};
|
|
||||||
const position = { ...camera.positionCartographic };
|
|
||||||
Object.defineProperty(camera, 'positionCartographic', {
|
|
||||||
get: () => {
|
|
||||||
clock.now += cameraMs;
|
|
||||||
return position;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const sampler = createMeshSampler({
|
|
||||||
getViewer: () => fake.viewer,
|
|
||||||
now: () => clock.now,
|
|
||||||
});
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
/** Run one idle slice; its length on the fake clock. */
|
|
||||||
function slice() {
|
|
||||||
const started = clock.now;
|
|
||||||
probeNow(t);
|
|
||||||
return clock.now - started;
|
|
||||||
}
|
|
||||||
/** Put the camera over a point. */
|
|
||||||
function moveTo(lon, lat) {
|
|
||||||
position.longitude = lon * RAD;
|
|
||||||
position.latitude = lat * RAD;
|
|
||||||
}
|
|
||||||
return { ...fake, sampler, slice, moveTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */
|
|
||||||
function grid(lon, lat, n, step) {
|
|
||||||
const points = [];
|
|
||||||
for (let i = 0; i < n; i++)
|
|
||||||
for (let j = 0; j < n; j++)
|
|
||||||
points.push([
|
|
||||||
lon + (i - (n - 1) / 2) * step,
|
|
||||||
lat + (j - (n - 1) / 2) * step,
|
|
||||||
]);
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
|
|
||||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
|
||||||
// 400 cells ~3.6 km east, past the sampler's range.
|
|
||||||
sampler.request(grid(10.05, 50, 20, 0.0002));
|
|
||||||
// The camera gets there before any idle slice runs.
|
|
||||||
moveTo(10.05, 50);
|
|
||||||
for (let i = 0; i < 5; i++) slice();
|
|
||||||
assert.equal(probes.length, 0, 'nothing was queued for later');
|
|
||||||
// Asked again from there (as a camera move does), they are probed.
|
|
||||||
sampler.request([[10.05, 50]]);
|
|
||||||
slice();
|
|
||||||
assert.equal(probes.length, 1);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a slice keeps to its budget, counting the work before its first probe', (t) => {
|
|
||||||
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
|
|
||||||
sampler.request(grid(10.001, 50, 5, 0.0002));
|
|
||||||
const lengths = [];
|
|
||||||
while (probes.length < 25 && lengths.length < 40) lengths.push(slice());
|
|
||||||
assert.equal(probes.length, 25, 'every cell is probed in the end');
|
|
||||||
for (const length of lengths)
|
|
||||||
assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a large queue is ranked once, not sorted again every slice', (t) => {
|
|
||||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
|
||||||
// 2,500 cells ~17 m apart around the camera, all within range.
|
|
||||||
const cells = grid(10, 50, 50, 0.00015);
|
|
||||||
sampler.request(cells);
|
|
||||||
const sort = Array.prototype.sort;
|
|
||||||
let sorted = 0;
|
|
||||||
t.after(() => (Array.prototype.sort = sort));
|
|
||||||
Array.prototype.sort = function (...args) {
|
|
||||||
if (this.length >= 100) sorted += this.length;
|
|
||||||
return sort.apply(this, args);
|
|
||||||
};
|
|
||||||
for (let i = 0; i < 10; i++) slice();
|
|
||||||
const first = probes.map((probe) =>
|
|
||||||
metresBetween(probe, { lon: 10, lat: 50 }),
|
|
||||||
);
|
|
||||||
assert.ok(first.length >= 10, 'probes ran');
|
|
||||||
first.forEach((distance, i) =>
|
|
||||||
assert.ok(
|
|
||||||
!i || distance >= first[i - 1] - 0.01,
|
|
||||||
'nearest the camera first, across slices',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Array.prototype.sort = sort;
|
|
||||||
assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`);
|
|
||||||
// The camera moves ~200 m east: the queue is ranked from there.
|
|
||||||
const done = probes.length;
|
|
||||||
moveTo(10.003, 50);
|
|
||||||
slice();
|
|
||||||
slice();
|
|
||||||
const after = probes
|
|
||||||
.slice(done)
|
|
||||||
.map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 }));
|
|
||||||
assert.ok(after.length > 0);
|
|
||||||
assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the sampling range is measured the short way round the date line', (t) => {
|
|
||||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
|
||||||
moveTo(179.9999, 0);
|
|
||||||
sampler.request([
|
|
||||||
[-179.9999, 0], // ~22 m away, across the date line
|
|
||||||
[179.99, 0], // ~1.1 km away, past the range
|
|
||||||
]);
|
|
||||||
for (let i = 0; i < 5; i++) slice();
|
|
||||||
assert.equal(probes.length, 1, 'only the neighbour across ±180°');
|
|
||||||
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A scene whose mesh height can change (finer tiles) and a camera that can move. */
|
|
||||||
function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) {
|
|
||||||
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
|
|
||||||
const probes = [];
|
|
||||||
const viewer = {
|
|
||||||
surface,
|
|
||||||
ready,
|
|
||||||
scene: {
|
|
||||||
sampleHeightSupported: true,
|
|
||||||
primitives: { length: 1, get: () => tileset },
|
|
||||||
sampleHeight(carto) {
|
|
||||||
probes.push(carto);
|
|
||||||
return viewer.surface;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
camera: {
|
|
||||||
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { viewer, probes };
|
|
||||||
}
|
|
||||||
|
|
||||||
function liveSampler(viewer, options = {}) {
|
|
||||||
return createMeshSampler({
|
|
||||||
getViewer: () => viewer,
|
|
||||||
groundAt: () => 75,
|
|
||||||
withinPrior: meshFloorSampleWithinPrior,
|
|
||||||
tilesReady: () => viewer.ready(),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => {
|
|
||||||
const { viewer, probes } = liveViewer({ surface: 80, height: 700 });
|
|
||||||
const sampler = liveSampler(viewer);
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
const heard = [];
|
|
||||||
sampler.onSampled((batch) => heard.push(batch.length));
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle(900);
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
|
||||||
// Finer tiles load: the surface is really 100 m.
|
|
||||||
viewer.surface = 100;
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle();
|
|
||||||
assert.equal(probes.length, 1, 'no new probe from the same distance');
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
|
||||||
viewer.camera.positionCartographic.height = 250;
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle(900);
|
|
||||||
assert.equal(probes.length, 2);
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
|
||||||
assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw');
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshing stops once a cell was probed from close by', async () => {
|
|
||||||
const { viewer, probes } = liveViewer({ surface: 80, height: 110 });
|
|
||||||
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
|
|
||||||
const sampler = liveSampler(viewer);
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle();
|
|
||||||
viewer.camera.positionCartographic.height = 85;
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle();
|
|
||||||
assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('probes wait while the visible tileset is still streaming', async () => {
|
|
||||||
let ready = false;
|
|
||||||
const { viewer, probes } = liveViewer({ ready: () => ready });
|
|
||||||
const sampler = liveSampler(viewer);
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
await settle(MESH_STREAMING_WAIT_MS * 2);
|
|
||||||
assert.equal(probes.length, 0);
|
|
||||||
ready = true;
|
|
||||||
await settle(MESH_STREAMING_WAIT_MS * 2);
|
|
||||||
assert.equal(probes.length, 1);
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => {
|
|
||||||
const { viewer } = liveViewer({ surface: 300 });
|
|
||||||
const unknown = liveSampler(viewer, { groundAt: () => null });
|
|
||||||
unknown.setEnabled(true);
|
|
||||||
unknown.request([[10.001, 50]]);
|
|
||||||
const outside = liveSampler(viewer);
|
|
||||||
outside.setEnabled(true);
|
|
||||||
outside.request([[10.001, 50]]);
|
|
||||||
await settle();
|
|
||||||
assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept');
|
|
||||||
assert.equal(
|
|
||||||
outside.meshAt(10.001, 50),
|
|
||||||
undefined,
|
|
||||||
'225 m above it: not kept',
|
|
||||||
);
|
|
||||||
unknown.destroy();
|
|
||||||
outside.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a failed refresh keeps the last good distance and is retried after the cooldown', (t) => {
|
|
||||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
|
||||||
const { viewer, probes } = liveViewer({ surface: 90, height: 700 });
|
|
||||||
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
|
|
||||||
const sampler = liveSampler(viewer);
|
|
||||||
sampler.setEnabled(true);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 90);
|
|
||||||
// The camera comes down to 20 m and the refresh misses (tiles in flux).
|
|
||||||
viewer.camera.positionCartographic.height = 110;
|
|
||||||
viewer.surface = undefined;
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(probes.length, 2);
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 90, 'the old sample stays');
|
|
||||||
viewer.surface = 100;
|
|
||||||
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(probes.length, 2, 'not before the cooldown');
|
|
||||||
t.mock.timers.tick(100);
|
|
||||||
sampler.request([[10.001, 50]]);
|
|
||||||
probeNow(t);
|
|
||||||
assert.equal(probes.length, 3, 'retried after it');
|
|
||||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
|
||||||
sampler.destroy();
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,16 @@ import {
|
|||||||
import { imageSize } from './imageSize.js';
|
import { imageSize } from './imageSize.js';
|
||||||
import { msg, t } from '../../i18n/index.js';
|
import { msg, t } from '../../i18n/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPAND puts the viewer's wrap full screen, or (iPhone) opens it as a
|
||||||
|
* popover. Each is tried on its own: a browser that cannot parse one
|
||||||
|
* (Safari before 16.4) throws on the selector.
|
||||||
|
*/
|
||||||
|
const EXPANDED_SELECTORS = [
|
||||||
|
'.sl-viewer-wrap:fullscreen',
|
||||||
|
'.sl-viewer-wrap:popover-open',
|
||||||
|
];
|
||||||
|
|
||||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -535,14 +545,14 @@ export function createPhotoViewer({
|
|||||||
/**
|
/**
|
||||||
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
|
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
|
||||||
* caption) is the provider's before the panel's, which would collapse;
|
* caption) is the provider's before the panel's, which would collapse;
|
||||||
* expanded, the dialog's own Esc shrinks it.
|
* expanded, the panel's own Esc shrinks it.
|
||||||
*/
|
*/
|
||||||
function onEscapeKey(event) {
|
function onEscapeKey(event) {
|
||||||
if (
|
if (
|
||||||
event.key !== 'Escape' ||
|
event.key !== 'Escape' ||
|
||||||
event.shiftKey ||
|
event.shiftKey ||
|
||||||
event.defaultPrevented ||
|
event.defaultPrevented ||
|
||||||
root?.closest?.('.sl-viewer-wrap-expanded') ||
|
isExpanded() ||
|
||||||
!onEscape()
|
!onEscape()
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -739,9 +749,15 @@ export function createPhotoViewer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The viewer is the expanded dialog, not docked in the panel. */
|
/** The viewer is expanded (full screen, or the iPhone popover), not docked in the panel. */
|
||||||
function isExpanded() {
|
function isExpanded() {
|
||||||
return Boolean(root?.closest?.('.sl-viewer-wrap-expanded'));
|
return EXPANDED_SELECTORS.some((selector) => {
|
||||||
|
try {
|
||||||
|
return Boolean(root?.closest?.(selector));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Another photo, or none: a sharper panorama still on its way is not needed. */
|
/** Another photo, or none: a sharper panorama still on its way is not needed. */
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th
|
|||||||
assert.equal(asked, 1);
|
assert.equal(asked, 1);
|
||||||
assert.equal(docked.prevented && docked.stopped, true);
|
assert.equal(docked.prevented && docked.stopped, true);
|
||||||
root.closest = (selector) =>
|
root.closest = (selector) =>
|
||||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
|
||||||
const expanded = press();
|
const expanded = press();
|
||||||
assert.equal(asked, 1, 'not asked while expanded');
|
assert.equal(asked, 1, 'not asked while expanded');
|
||||||
assert.equal(expanded.prevented, false);
|
assert.equal(expanded.prevented, false);
|
||||||
@@ -536,3 +536,38 @@ test('an <img> that turns out too large is refused and let go', async (t) => {
|
|||||||
await assert.rejects(aborted);
|
await assert.rejects(aborted);
|
||||||
assert.equal(images.at(-1).src, '', 'its download stops');
|
assert.equal(images.at(-1).src, '', 'its download stops');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a browser without :fullscreen / :popover-open (Safari < 16.4) still opens a flat photo and hands Esc on', async () => {
|
||||||
|
let asked = 0;
|
||||||
|
const viewer = createPhotoViewer({
|
||||||
|
providerId: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
sources: SOURCES,
|
||||||
|
getPhoto: async (id) => PHOTOS[id],
|
||||||
|
step: async () => null,
|
||||||
|
onStep() {},
|
||||||
|
onEscape: () => ++asked > 0,
|
||||||
|
load: async (url) => ({ src: url }),
|
||||||
|
});
|
||||||
|
const host = fakeHost();
|
||||||
|
await viewer.mount(host);
|
||||||
|
const root = host.children[0];
|
||||||
|
// Safari 16.0–16.3: neither pseudo-class parses, so closest() throws.
|
||||||
|
root.closest = (selector) => {
|
||||||
|
if (/:fullscreen|:popover-open/.test(selector))
|
||||||
|
throw new SyntaxError(`'${selector}' is not a valid selector`);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let failure = null;
|
||||||
|
try {
|
||||||
|
await viewer.open('f');
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
assert.equal(failure, null, 'opening a flat photo does not throw');
|
||||||
|
const event = { key: 'Escape' };
|
||||||
|
event.preventDefault = () => {};
|
||||||
|
event.stopPropagation = () => {};
|
||||||
|
assert.doesNotThrow(() => root.fire('keydown', event), 'Esc does not throw');
|
||||||
|
assert.equal(asked, 1, 'Esc reaches the provider');
|
||||||
|
});
|
||||||
@@ -5,10 +5,16 @@ const msg = (text) => text;
|
|||||||
/** Identity and shared tuning for the provider-neutral Street Level layer. */
|
/** Identity and shared tuning for the provider-neutral Street Level layer. */
|
||||||
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pixel window a click picks in: coverage lines are 2.5 px wide and there
|
||||||
|
* is no hover cue, so a click a few pixels off still finds the line.
|
||||||
|
*/
|
||||||
|
export const CLICK_PICK_WINDOW_PX = 9;
|
||||||
|
|
||||||
/** Pick id of the viewer position marker; the core owns it, not a provider. */
|
/** Pick id of the viewer position marker; the core owns it, not a provider. */
|
||||||
export const POSITION_PICK_ID = 'sl:pos';
|
export const POSITION_PICK_ID = 'sl:pos';
|
||||||
|
|
||||||
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
|
/** One colour per imagery source: chip, coverage lines and cones. */
|
||||||
export const PROVIDER_COLORS = Object.freeze({
|
export const PROVIDER_COLORS = Object.freeze({
|
||||||
mapillary: '#05cb63',
|
mapillary: '#05cb63',
|
||||||
google: '#4285f4',
|
google: '#4285f4',
|
||||||
@@ -31,17 +37,11 @@ export const MAX_SINCE_DAYS = 36_500;
|
|||||||
/** Filter every provider starts with: all imagery, any date. */
|
/** Filter every provider starts with: all imagery, any date. */
|
||||||
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
||||||
|
|
||||||
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
|
/** Nearest-image search radius in metres. */
|
||||||
export const NEAREST_RADIUS_M = 50;
|
export const NEAREST_RADIUS_M = 50;
|
||||||
|
|
||||||
/**
|
/** The Google 3D map stack: Street View may be shown only on it. */
|
||||||
* The only map stack camera follow is offered on: elsewhere a camera at eye
|
export const GOOGLE_3D_MAP_STACK_ID = 'photoreal';
|
||||||
* height looks at a smeared texture.
|
|
||||||
*/
|
|
||||||
export const FOLLOW_MAP_STACK_ID = 'photoreal';
|
|
||||||
|
|
||||||
/** Eye height above the sampled ground when the globe camera follows the viewer. */
|
|
||||||
export const FOLLOW_EYE_HEIGHT_M = 2.4;
|
|
||||||
|
|
||||||
/** Above this, a ground click is a misclick, not a street. */
|
/** Above this, a ground click is a misclick, not a street. */
|
||||||
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||||
@@ -50,3 +50,9 @@ export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
|||||||
export const AIM_AT_GROUND_MESSAGE = msg(
|
export const AIM_AT_GROUND_MESSAGE = msg(
|
||||||
'Aim the middle of the view at a street to look for imagery there',
|
'Aim the middle of the view at a street to look for imagery there',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The key gate's label for the pill and the layer list, or null when the key is fine. */
|
||||||
|
export function keyStatusLabel({ keyRequired, keyRejected }) {
|
||||||
|
if (keyRejected) return msg('KEY REJECTED');
|
||||||
|
return keyRequired ? msg('KEY REQUIRED') : null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
import { GOOGLE_3D_MAP_STACK_ID } from '../../policy.js';
|
||||||
import { createMapsLoader } from './mapsLoader.js';
|
import { createMapsLoader } from './mapsLoader.js';
|
||||||
import { getLocale } from '../../../../i18n/index.js';
|
import { getLocale } from '../../../../i18n/index.js';
|
||||||
import { createGoogleViewer } from './viewer.js';
|
import { createGoogleViewer } from './viewer.js';
|
||||||
@@ -64,7 +64,7 @@ export function createGoogleProvider({
|
|||||||
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
|
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
|
||||||
groundClick: true,
|
groundClick: true,
|
||||||
// Google's terms forbid Street View beside non-Google maps.
|
// Google's terms forbid Street View beside non-Google maps.
|
||||||
requiresMapStack: FOLLOW_MAP_STACK_ID,
|
requiresMapStack: GOOGLE_3D_MAP_STACK_ID,
|
||||||
// Every panorama is billed to the Maps key.
|
// Every panorama is billed to the Maps key.
|
||||||
defaultOn: false,
|
defaultOn: false,
|
||||||
// All 360°, and no coverage for a date filter to thin.
|
// All 360°, and no coverage for a date filter to thin.
|
||||||
@@ -114,8 +114,6 @@ export function createGoogleProvider({
|
|||||||
stopAuthWatch();
|
stopAuthWatch();
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshCoverage() {},
|
|
||||||
|
|
||||||
/** No filter applies to Street View (see `filters`). */
|
/** No filter applies to Street View (see `filters`). */
|
||||||
setFilter() {},
|
setFilter() {},
|
||||||
|
|
||||||
@@ -123,8 +121,6 @@ export function createGoogleProvider({
|
|||||||
const keyRejected = loader.authFailed();
|
const keyRejected = loader.authFailed();
|
||||||
return {
|
return {
|
||||||
count: 0,
|
count: 0,
|
||||||
zoom: null,
|
|
||||||
kind: null,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
hint:
|
hint:
|
||||||
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
|
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function tileSegments(payload) {
|
|||||||
* fourfold.
|
* fourfold.
|
||||||
* @param {Array<Array<[number, number]>>} segments
|
* @param {Array<Array<[number, number]>>} segments
|
||||||
* @param {{x: number, y: number, z: number}} address
|
* @param {{x: number, y: number, z: number}} address
|
||||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>}}
|
||||||
*/
|
*/
|
||||||
export function chainSegments(segments, { x, y, z }) {
|
export function chainSegments(segments, { x, y, z }) {
|
||||||
const key = ([lon, lat]) => `${lon},${lat}`;
|
const key = ([lon, lat]) => `${lon},${lat}`;
|
||||||
@@ -84,7 +84,7 @@ export function chainSegments(segments, { x, y, z }) {
|
|||||||
isPano: false,
|
isPano: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { sequences, overview: [] };
|
return { sequences };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decode a KartaView coverage tile into the shared coverage's line records. */
|
/** Decode a KartaView coverage tile into the shared coverage's line records. */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
|
|||||||
const TILE = { x: 4412, y: 6061, z: 14 };
|
const TILE = { x: 4412, y: 6061, z: 14 };
|
||||||
|
|
||||||
test('segments that meet end to start chain into one line', () => {
|
test('segments that meet end to start chain into one line', () => {
|
||||||
const { sequences, overview } = decodeCoverageTile(
|
const { sequences } = decodeCoverageTile(
|
||||||
{
|
{
|
||||||
lines: [
|
lines: [
|
||||||
{ track: [at(1, 1), at(2, 1)], matched: '1' },
|
{ track: [at(1, 1), at(2, 1)], matched: '1' },
|
||||||
@@ -23,7 +23,6 @@ test('segments that meet end to start chain into one line', () => {
|
|||||||
},
|
},
|
||||||
TILE,
|
TILE,
|
||||||
);
|
);
|
||||||
assert.deepEqual(overview, []);
|
|
||||||
assert.equal(sequences.length, 2);
|
assert.equal(sequences.length, 2);
|
||||||
assert.deepEqual(sequences[0].parts, [
|
assert.deepEqual(sequences[0].parts, [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createLineCoverage } from '../../lineCoverage.js';
|
import { createLineCoverage, freshCoverage } from '../../lineCoverage.js';
|
||||||
import { createLru, createTileCache } from '../../tileCache.js';
|
import { createLru, createTileCache } from '../../tileCache.js';
|
||||||
|
|
||||||
export { createTileCache } from '../../tileCache.js';
|
export { createTileCache } from '../../tileCache.js';
|
||||||
@@ -49,10 +49,7 @@ const PAGE_CACHE_SIZE = 12;
|
|||||||
/** z14 near the street, z13 a little higher, nothing above. */
|
/** z14 near the street, z13 a little higher, nothing above. */
|
||||||
export function kartaViewZoomFor(heightM) {
|
export function kartaViewZoomFor(heightM) {
|
||||||
if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null;
|
if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null;
|
||||||
return {
|
return heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14;
|
||||||
zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14,
|
|
||||||
kind: 'sequence',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
|
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
|
||||||
@@ -88,8 +85,6 @@ export function createKartaViewProvider({
|
|||||||
filters: Object.freeze(['pano', 'since']),
|
filters: Object.freeze(['pano', 'since']),
|
||||||
// SINCE is the upload date for KartaView: the panel notes it.
|
// SINCE is the upload date for KartaView: the panel notes it.
|
||||||
sinceBasis: 'upload',
|
sinceBasis: 'upload',
|
||||||
// Its lines are streets, not sequences: a selected drive shows as cones.
|
|
||||||
highlightsSequence: false,
|
|
||||||
coverageLabel: Object.freeze({
|
coverageLabel: Object.freeze({
|
||||||
noun: COVERAGE_NOUN,
|
noun: COVERAGE_NOUN,
|
||||||
action: LINE_CLICK_HINT,
|
action: LINE_CLICK_HINT,
|
||||||
@@ -100,30 +95,13 @@ export function createKartaViewProvider({
|
|||||||
context,
|
context,
|
||||||
services: context.services,
|
services: context.services,
|
||||||
viewer: null,
|
viewer: null,
|
||||||
keyRequired: false,
|
coverage: freshCoverage({ keyless: true }),
|
||||||
keyRejected: false,
|
|
||||||
statusKnown: true,
|
|
||||||
coverage: {
|
|
||||||
zoom: null,
|
|
||||||
tiles: new Map(),
|
|
||||||
stale: new Map(),
|
|
||||||
staleTimer: null,
|
|
||||||
pending: new Map(),
|
|
||||||
lastError: null,
|
|
||||||
holdUntil: 0,
|
|
||||||
holdTimer: null,
|
|
||||||
debounceTimer: null,
|
|
||||||
removeCameraListener: null,
|
|
||||||
terrainReady: null,
|
|
||||||
hint: '',
|
|
||||||
kind: null,
|
|
||||||
},
|
|
||||||
sequence: {
|
sequence: {
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
images: [],
|
images: [],
|
||||||
cache: new Map(),
|
|
||||||
collection: null,
|
collection: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
error: null,
|
||||||
abort: null,
|
abort: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -349,6 +327,8 @@ export function createKartaViewProvider({
|
|||||||
// Filtered on the way in (fetchTile): a line has no date to test.
|
// Filtered on the way in (fetchTile): a line has no date to test.
|
||||||
passes: () => true,
|
passes: () => true,
|
||||||
});
|
});
|
||||||
|
// Its lines are streets, not drives: a selected drive shows as cones
|
||||||
|
// only, so the coverage is never highlighted.
|
||||||
|
|
||||||
const cones = createSequenceCones({
|
const cones = createSequenceCones({
|
||||||
state,
|
state,
|
||||||
@@ -480,7 +460,7 @@ export function createKartaViewProvider({
|
|||||||
deactivate() {
|
deactivate() {
|
||||||
coverage.detach();
|
coverage.detach();
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
coverage.resetErrors();
|
coverage.unblock();
|
||||||
cones.clearSelection();
|
cones.clearSelection();
|
||||||
cones.setVisible(false);
|
cones.setVisible(false);
|
||||||
drawnPage = null;
|
drawnPage = null;
|
||||||
@@ -492,17 +472,15 @@ export function createKartaViewProvider({
|
|||||||
destroy(cesiumViewer) {
|
destroy(cesiumViewer) {
|
||||||
coverage.detach();
|
coverage.detach();
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
coverage.resetErrors();
|
coverage.unblock();
|
||||||
cones.destroy(cesiumViewer);
|
cones.destroy(cesiumViewer);
|
||||||
tiles.clear();
|
tiles.clear();
|
||||||
masks.clear();
|
masks.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshCoverage: () => coverage.refresh(),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The filter changed: the lines come from KartaView again under it
|
* The filter changed: the lines come from KartaView again under it
|
||||||
* (the old ones show meanwhile), the cones are drawn again.
|
* (cleared until they come), the cones are drawn again.
|
||||||
*/
|
*/
|
||||||
setFilter() {
|
setFilter() {
|
||||||
const query = tileFilterQuery(context.getFilter());
|
const query = tileFilterQuery(context.getFilter());
|
||||||
@@ -513,29 +491,17 @@ export function createKartaViewProvider({
|
|||||||
cones.rerender();
|
cones.rerender();
|
||||||
},
|
},
|
||||||
|
|
||||||
setSurface() {
|
/** Keyless; an empty view that has loaded says there is nothing here. */
|
||||||
coverage.setSurface();
|
|
||||||
cones.rerender();
|
|
||||||
},
|
|
||||||
|
|
||||||
coverageStats() {
|
coverageStats() {
|
||||||
const { hint } = state.coverage;
|
const stats = coverage.stats();
|
||||||
const count = coverage.sequenceCount();
|
|
||||||
const loading = state.coverage.pending.size > 0;
|
|
||||||
const empty =
|
const empty =
|
||||||
state.coverage.zoom !== null &&
|
state.coverage.zoom !== null &&
|
||||||
!loading &&
|
!stats.loading &&
|
||||||
count === 0 &&
|
stats.count === 0 &&
|
||||||
!state.coverage.lastError;
|
!stats.error;
|
||||||
return {
|
return {
|
||||||
count,
|
...stats,
|
||||||
zoom: state.coverage.zoom,
|
hint: stats.hint || (empty ? NO_COVERAGE_HINT : ''),
|
||||||
kind: state.coverage.kind,
|
|
||||||
loading,
|
|
||||||
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
|
|
||||||
error: state.coverage.lastError,
|
|
||||||
keyRequired: false,
|
|
||||||
keyRejected: false,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -553,7 +519,7 @@ export function createKartaViewProvider({
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Never from the cache: it is keyed by sequence, the cones by page.
|
// Always loaded afresh: the cones show the page of the photo open now.
|
||||||
selectSequence: (sequenceId) =>
|
selectSequence: (sequenceId) =>
|
||||||
cones.select(sequenceId, { force: true }),
|
cones.select(sequenceId, { force: true }),
|
||||||
clearSequence: () => {
|
clearSequence: () => {
|
||||||
@@ -567,6 +533,7 @@ export function createKartaViewProvider({
|
|||||||
counts.get(state.sequence.selectedId) ??
|
counts.get(state.sequence.selectedId) ??
|
||||||
state.sequence.images.length,
|
state.sequence.images.length,
|
||||||
loading: state.sequence.loading,
|
loading: state.sequence.loading,
|
||||||
|
error: state.sequence.error,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user