diff --git a/CHANGELOG.md b/CHANGELOG.md index 108afbf66..cfb866ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,29 @@ ## [Unreleased] +- Street Level: KartaView is the third provider, keyless. Light its chip + (purple; off by default, one provider at a time) and zoom in below about + 6 km: the lines are the streets KartaView has photos of (z13–14 tiles from + `api.kartaview.org`). A click on a line opens the photo nearest the click + without moving the camera, flat or 360° (a small WebGL panorama: drag to + look, wheel to zoom), with cones along its drive and ‹ › / ← → to step + through it. The caption links to the full-size photo (KartaView's photo + pages fail for signed-out visitors), and the globe credits "© KartaView + contributors, CC BY-SA 4.0". Share links carry it in the provider + switches (`0.m.0_0.r.google-0*kartaview-1`). Archived photos (KartaView + answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and + pinch look around a 360° photo. The open photo's marker is a purple view + cone pointing along the drive (KartaView's own compass headings are often + 0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to + KartaView as to Mapillary: its lines are filtered by KartaView's own + server (its filtered PNG tiles, as a mask over the JSON lines), and the + photo a click opens, and the cones, by each photo's projection and upload + date. KartaView's SINCE is the upload date (all its server can filter by); + a note under the slider says so while KartaView is lit (`sinceBasis: + 'upload'` in the provider contract). + Mapillary's line coverage and sequence cones are now shared by both + (`lineCoverage.js`, `sequenceCones.js`). + - Street Level: Google Street View is the second provider. On the Google 3D map (Google's terms keep it off other maps), light its chip (off by default: every panorama is billed to the Google Maps key, which needs the diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index da47a4cc6..15bcfbf2a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -22,6 +22,7 @@ How to read this: | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | | **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | +| **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 | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index fbc3d1798..4029db402 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -302,7 +302,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | -| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary and Google Street View; Panoramax and KartaView planned | Mapillary · Google | 🟡 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 and **FOLLOW**. Mapillary, Google Street View and KartaView; Panoramax planned | Mapillary · Google · KartaView | 🟢 KartaView · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | @@ -340,13 +340,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ## 📷 Street Level -> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token) and **Google Street View** (your Google Maps key). +> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key) and **KartaView** (no key). -**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **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. +**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** needs nothing: its public API is 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 other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google"). +**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 and KartaView; 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. 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 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"). 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.r.google-1` Street View on, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. +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` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works 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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index aa1295198..51cc78fde 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3114,8 +3114,8 @@ meaning), one viewer host and one on-globe credit per active provider. Each provider draws in one colour (`PROVIDER_COLORS` in `src/layers/streetLevel/policy.js`); 360° cones are rings and the selected sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id, -e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary and Google -Street View are registered. Without +e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google +Street View and KartaView are registered. Without `MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 @@ -3166,9 +3166,12 @@ Providers implement the contract in `src/layers/streetLevel/registry.js`. The core routes clicks by pick prefix, swaps viewer adapters in the one host, manages each provider's credit and fans the filter out to every provider. To add a provider: implement the definition, register it in -`src/app/layers/streetLevel.js`, add its boolean option to the `street-level` -group in `src/data/layerState.js` and its modules to -`scripts/package-boundaries.json`. With providers on different keys the +`src/app/layers/streetLevel.js` and add its modules to +`scripts/package-boundaries.json`; its share switch travels in `r` by id. +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 +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 layer's `requiresKeyId` is null and each chip reports its own key. Google Street View (`providers/google/`) runs on the Maps JavaScript API with @@ -3215,6 +3218,97 @@ once; a lookup or open Google never answers fails after 10 or 20 s. In CI shows off Google 3D, even switched on by a share link; the click and button step skips as `no Google key`. +KartaView (`providers/kartaview/`) is keyless and browser-side: CORS is open +on `api.kartaview.org` (the documented `api.openstreetcam.org` answers but +serves empty tiles), `cdn.kartaview.org` and `storage*.openstreetcam.org`. +Its chip starts off. Coverage comes from z14 JSON tiles below 2.5 km above +ground and z13 up to 6 km (lower zooms take seconds per tile; z10 does not +answer), so higher up the hint says to zoom in, and an empty view says there +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 +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 +drawn as `kv:line:`, up to 1,600 per tile, and never highlighted +(`highlightsSequence: false`, so the legend has no "Selected"), and the +provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N +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`; +its JSON, GeoJSON and jsoncanvas tiles come back empty once a filter is +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 +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). +A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show +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 +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 +filter the area in time. KartaView's `startDate` is the upload date (its +server filters by nothing else), so SINCE goes by upload date throughout, +the cut-off taken as its UTC day (`passesKartaViewFilter`): the nearest +lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores +`fieldOfView`) and upload date and checks each answer the same way (when +photos are near but none passes, the panel says so), and the cones pass on +each photo's projection and upload date. The provider declares +`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by +upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and +uncacheable, so the last 24 decoded tiles are kept for 10 minutes while +KartaView is lit (`createTileCache`, dropped when it is switched off); one +that does not answer in 20 s is asked once more, and one that is not a line +list is not cached. A click on a line reaches +`handlePick(id, {groundPoint})`, and the provider asks the core +(`actions.openNearest`) for its nearest showable photo within 50 m, without +framing: many photos are archived (their file answers 409, while a HEAD says +200) or have no file, so the six nearest are probed at once with a GET cut +off at the headers, and where all are gone the panel says KartaView's photos +there are archived. Only a definite answer (403/404/409/410, or 200) is +remembered; a storage timeout or 5xx is asked again. The open photo's +sequence draws as cones from the 250-photo page holding it +(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos +(`countActivePhotos`, asked once per sequence). The camera direction (the +marker, cones, FOLLOW, and a panorama's centre) is the direction of travel +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 +drives without one and can sit a steady 110° off inside a car. Until the +drive's page arrives the compass stands in (0 counts as unknown), and the +pose is sent again once the course is known. The open photo's marker is a +purple view cone (`marker: {kind: 'view-cone'}`), as Street View's is blue. Pages follow photo ids, which only roughly +follow the index, so the page is found by search (both ways, halving past the +end when many photos were deleted), a neighbour page joins within 12 photos +of an edge, and the cones re-centre whenever a photo of the selected sequence +opens on another page. ‹ › (and ← →) open through the core without framing, +step on from the photo already on its way, skip archived photos, and disable +up front at the ends. `api.kartaview.org` speaks HTTP/1.1, so of the +browser's six connections to it tiles take three, lookups two and pages one +(newest first), and a lookup never waits behind tiles; the photo API answers +429 to more than about three requests at once, so a 429 is retried with +backoff (at most 10 s), and a lookup gets 8 s, then 15 s once more (KartaView +sometimes sits on a lookup for 11 s and answers the same one in 3 s); a +request that could not be made at all (no network, CORS) says so and is not +repeated. Answers +are read with a running byte count and cut off past 16 MB (KartaView sends +them chunked, without a length). Photo URLs are built from +`storage` and `filepath*`: the API's `fileurl*` always name storage1, where +photos stored elsewhere 404, and a `fileurl*` fallback must itself be an +https storage host. The viewer (`viewer.js`) shows a flat photo at 1,280 px, +then 2,560 px, from the image proxy (`pr:sharp/rs:fit:W:H/`), +with KartaView's own 1,280 px file as the fallback, and a 360° photo's +equirectangular `wrapped_proc` image (13,000 px wide upstream) resized to +1,024 px, then 2,048 (docked) or 4,096 px (expanded, or once the viewer +grows), decoded off the main thread into an ImageBitmap and uploaded as RGBA +(about 90 ms → 15 ms for 4,096 px on a phone-class CPU), on `panorama.js`, a +single-triangle WebGL renderer (image centre = the camera heading; drag, +pinch, wheel, Shift+← →, ↑ ↓, + −; the canvas re-measures every frame; a +closed viewer keeps a 1×1 texture). Without WebGL, or if the proxy fails +within 15 s, a 360° photo shows its front view. A lost context draws the +photo again on a new canvas (a lost canvas only gives back its lost +context), through the core and facing where the user was looking; a newer +photo that was opening is drawn on the new canvas instead. FIT / FILL hide +for panoramas (`supportsRenderModes`). The +caption has no place name (KartaView's is where the drive began) and links to +the full-size photo through the image proxy, since kartaview.org's photo +pages fail for signed-out visitors. + Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run qa:street-level:fixtures -- --url ` answers every Mapillary request diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs new file mode 100644 index 000000000..91caa8004 --- /dev/null +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -0,0 +1,371 @@ +import { deflateSync } from 'node:zlib'; +import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js'; +import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; + +/** + * KartaView's API, image proxy and photo storage answered from fixtures, so + * the gate's KartaView steps need no network. One drive runs north along + * KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while + * their `fileurl*` name storage1, as KartaView's API does, so the gate also + * proves the provider builds addresses from `storage`. Every odd-indexed + * photo is archived (storage answers 409, the image proxy still serves it), + * so the nearest photo opened is even and › must skip the one after it. The + * first nearest-photo lookup is rate-limited (429, Retry-After: 0), as + * KartaView does under load. + * + * Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the + * imagery filter drops it. KartaView filters coverage only in its PNG + * tiles, so filtered PNG tiles are drawn here for the streets that pass. + */ + +export const KV_SEQUENCE_ID = '900001'; +export const KV_LINE = Object.freeze({ + lon: -121.4931, + south: 38.5795, + north: 38.5875, +}); +/** A street ~130 m east of the drive with only flat 2019 photos. */ +export const KV_OLD_STREET = Object.freeze({ + lon: -121.4916, + south: 38.58, + north: 38.587, +}); +const OLD_STREET_UPLOADED = '2019-06-01'; +const KV_SPACING_M = 25; +const METRES_PER_DEG_LAT = 110_540; +/** KartaView's time format for `ms`. */ +const kartaViewTime = (ms) => + new Date(ms).toISOString().replace('T', ' ').slice(0, 23); +/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */ +const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000); + +const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); + +/** Lookups answered so far: the first one is rate-limited. */ +let nearestLookups = 0; + +/** Forget the lookups answered, so the next one is rate-limited again. */ +export function resetKartaViewFixture() { + 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. */ +export function kartaViewPhotos() { + const step = KV_SPACING_M / METRES_PER_DEG_LAT; + const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1; + return Array.from({ length: count }, (_, i) => ({ + id: String(77_000_000 + i), + index: i, + lon: KV_LINE.lon, + lat: Number((KV_LINE.south + i * step).toFixed(7)), + isPano: i % 10 === 5, + archived: i % 2 === 1, + })); +} + +function record(photo) { + const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`; + const folder = photo.isPano ? 'wrapped_proc' : 'proc'; + const path = (prefix) => `2024/5/2/${prefix}/${name}`; + const storage1 = (prefix) => + `https://storage1.openstreetcam.org/files/photo/${path(prefix)}`; + return { + id: photo.id, + sequenceId: KV_SEQUENCE_ID, + sequenceIndex: String(photo.index), + lat: String(photo.lat), + lng: String(photo.lon), + matchLat: String(photo.lat), + matchLng: String(photo.lon), + // A dashcam phone's compass, disturbed by the car: the drive runs north. + heading: '200', + fieldOfView: photo.isPano ? '360' : '0', + projection: photo.isPano ? 'SPHERE' : 'PLANE', + shotDate: SHOT, + dateAdded: SHOT, + storage: 'storage2', + filepath: path('{{sizeprefix}}'), + filepathLTh: path('lth'), + filepathProc: path(folder), + fileurl: storage1('{{sizeprefix}}'), + fileurlLTh: storage1('lth'), + fileurlProc: storage1(folder), + }; +} + +const ok = (result) => ({ + status: 200, + contentType: 'application/json', + headers: CORS, + body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }), +}); +const raw = (status, body) => ({ + status, + contentType: 'application/json', + headers: CORS, + body: JSON.stringify(body), +}); + +/** The streets with photos: the drive, and the old street beside it. */ +function streets() { + return [ + { line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] }, + { line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] }, + ]; +} + +/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */ +function stretch(line, z, x, y) { + const { west, east, south, north } = tileBounds(x, y, z); + if (line.lon < west || line.lon > east) return null; + const from = Math.max(line.south, south); + const to = Math.min(line.north, north); + return from < to ? [from, to] : null; +} + +/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */ +function tileLines(z, x, y) { + const lines = []; + for (const { line } of streets()) { + const inside = stretch(line, z, x, y); + if (!inside) continue; + const [from, to] = inside; + // Two segments meeting end to start, as KartaView splits ways. + const mid = (from + to) / 2; + const at = (lat) => ({ lat: String(lat), lng: String(line.lon) }); + lines.push( + { track: [at(from), at(mid)], matched: '1' }, + { track: [at(mid), at(to)], matched: '1' }, + ); + } + return lines; +} + +/** Whether a street has photos passing a filtered tile's query. */ +function passesTileQuery(street, query) { + const view = query.get('fieldOfView'); + if (view && !street.views.includes(view)) return false; + const since = query.get('startDate'); + return !since || street.uploaded >= since; +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(bytes) { + let c = 0xffffffff; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function pngChunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** + * A filtered coverage tile as KartaView draws it: 512 px, transparent, the + * passing streets three pixels wide. + */ +export function maskTilePng(z, x, y, query) { + const size = 512; + const rgba = Buffer.alloc(size * size * 4); + const { west, east } = tileBounds(x, y, z); + const n = 2 ** z; + const row = (lat) => { + const sin = Math.sin((lat * Math.PI) / 180); + const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); + return Math.round((my * n - y) * size); + }; + for (const street of streets()) { + if (!passesTileQuery(street, query)) continue; + const inside = stretch(street.line, z, x, y); + if (!inside) continue; + const column = Math.round( + ((street.line.lon - west) / (east - west)) * size, + ); + const top = Math.max(0, row(inside[1])); + const bottom = Math.min(size - 1, row(inside[0])); + for (let py = top; py <= bottom; py++) + for (let px = column - 1; px <= column + 1; px++) { + if (px < 0 || px >= size) continue; + rgba.set([99, 91, 255, 255], (py * size + px) * 4); + } + } + const rows = Buffer.alloc(size * (size * 4 + 1)); + for (let py = 0; py < size; py++) + rgba.copy( + rows, + py * (size * 4 + 1) + 1, + py * size * 4, + (py + 1) * size * 4, + ); + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(rows)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +/** A call named by host, path shape and parameter names. */ +export function describeKartaViewCall(method, url) { + const keys = [...url.searchParams.keys()].sort(); + const path = url.pathname + .replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json') + .replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png') + .replace(/\/photo\/\d+$/, '/photo/{id}') + .replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos') + .replace(/\/sequence\/\d+$/, '/sequence/{id}') + .replace(/^\/files\/photo\/.*$/, '/files/photo/{path}') + .replace( + /^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/, + '/pr:sharp/rs:fit:{w}:{h}/{address}', + ); + return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`; +} + +/** + * Answer one KartaView request; `known` is false for a call no fixture + * covers, so the gate fails on a new one instead of missing it. + */ +export function answerKartaViewRequest({ method, url }) { + const address = new URL(url); + const photos = kartaViewPhotos(); + if (method === 'OPTIONS') + return { status: 204, headers: CORS, body: '', known: true }; + const image = { + status: 200, + contentType: 'image/jpeg', + headers: CORS, + body: photoJpeg(), + known: true, + }; + if (address.hostname === 'cdn.kartaview.org') + return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname) + ? image + : { ...raw(404, {}), known: false }; + if (address.hostname === 'storage2.openstreetcam.org') { + if (!address.pathname.includes(KV_SEQUENCE_ID)) + return { ...raw(404, {}), known: false }; + const archived = photos.some( + (photo) => + photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`), + ); + // An archived photo's file is gone from storage: KartaView answers 409. + return archived ? { ...raw(409, {}), known: true } : image; + } + if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname)) + // The wrong host the API names: KartaView answers 404 there. + return { ...raw(404, {}), known: false }; + if (address.hostname !== 'api.kartaview.org') + return { ...raw(404, {}), known: false }; + const path = address.pathname; + const tile = path.match( + /^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/, + ); + if (tile) + return { + ...raw(200, { + lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])), + }), + known: true, + }; + // Filtered coverage: KartaView filters only its PNG tiles. + const mask = path.match( + /^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/, + ); + if (mask) + return { + status: 200, + contentType: 'image/png', + headers: CORS, + body: maskTilePng( + Number(mask[3]), + Number(mask[1]), + Number(mask[2]), + address.searchParams, + ), + known: true, + }; + if (path === '/2.0/photo/') { + if (nearestLookups++ === 0) + return { + ...raw(429, {}), + headers: { + ...CORS, + 'Retry-After': '0', + 'Access-Control-Expose-Headers': 'Retry-After', + }, + known: true, + }; + const q = address.searchParams; + const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + // Its own filter, as KartaView's: projection, and upload date. + const projection = q.get('projection'); + const since = q.get('startDate'); + const near = photos + .filter( + (photo) => + (!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) && + (!since || SHOT.slice(0, 10) >= since), + ) + .map((photo) => ({ photo, metres: metresApart(at, photo) })) + .filter(({ metres }) => metres < 200) + .sort((a, b) => a.metres - b.metres) + .slice(0, Number(q.get('itemsPerPage')) || 10) + .map(({ photo, metres }) => ({ + ...record(photo), + distance: metres.toFixed(2), + })); + return { ...ok({ data: near, hasMoreData: false }), known: true }; + } + const one = path.match(/^\/2\.0\/photo\/(\d+)$/); + if (one) { + const photo = photos.find((p) => p.id === one[1]); + return photo + ? { ...ok({ data: record(photo) }), known: true } + : { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true }; + } + const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/); + if (sequence) + return { + ...ok({ + data: { + id: sequence[1], + countActivePhotos: + sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0', + }, + }), + known: true, + }; + const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/); + if (page) { + const q = address.searchParams; + const size = Number(q.get('itemsPerPage')) || 100; + const number = Number(q.get('page')) || 1; + const rows = page[1] === KV_SEQUENCE_ID ? photos : []; + return { + ...ok({ + data: rows.slice((number - 1) * size, number * size).map(record), + hasMoreData: number * size < rows.length, + }), + known: true, + }; + } + return { ...raw(404, {}), known: false }; +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 862dc3f9c..1a880b284 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -908,6 +908,22 @@ "src/layers/streetLevel/providers/google/provider.test.mjs", "src/layers/streetLevel/providers/google/viewer.test.mjs", "src/layers/streetLevel/providers/google/mapsLoader.test.mjs", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/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/decode.test.mjs", + "src/layers/streetLevel/providers/kartaview/source.test.mjs", + "src/layers/streetLevel/providers/kartaview/panorama.test.mjs", + "src/layers/streetLevel/providers/kartaview/viewer.test.mjs", + "src/layers/streetLevel/providers/kartaview/provider.test.mjs", + "src/layers/streetLevel/lineCoverage.test.mjs", + "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", "server/providers/mapillary.js", "server/providers/mapillary/routes.js", @@ -919,6 +935,7 @@ "src/tooling/streetLevelQa.test.mjs", "scripts/qa-browserEvidence.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", + "scripts/fixtures/street-level/kartaView.mjs", "src/layers/streetLevel/filter.test.mjs", "src/layers/streetLevel/registry.test.mjs", "src/layers/streetLevel/params.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fc7308ab5..ba1f0c36a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1229,6 +1229,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "scripts/qa-street-level.mjs" ], @@ -2189,6 +2197,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", @@ -2720,6 +2736,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index a63836e0b..c47a3f9f8 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -3,8 +3,8 @@ * Browser QA for the Street Level layer against a running server. Run with * `npm run qa:street-level -- --url http://localhost:4173`. * - * `--fixtures` (the CI mode) answers every *.mapillary.com request from - * fixtures, so nothing reaches Mapillary. The server still needs a token + * `--fixtures` (the CI mode) answers every *.mapillary.com and KartaView + * request from fixtures, so nothing reaches Mapillary or KartaView. The server still needs a token * because the bundle carries it; CI uses a dummy one. Fixture runs also stage * a rejected key and a keyless second server. * @@ -37,6 +37,15 @@ import { PHOTO_SEQUENCE_ID, photoImages, } from './fixtures/street-level/mapillaryGraph.mjs'; +import { + answerKartaViewRequest, + describeKartaViewCall, + KV_LINE, + KV_SEQUENCE_ID, + kartaViewLookups, + kartaViewPhotos, + resetKartaViewFixture, +} from './fixtures/street-level/kartaView.mjs'; const PANEL_ID = 'street-level-panel'; @@ -49,7 +58,11 @@ export const VIEWPORTS = Object.freeze([ ]); /** Provider chips the panel must show, in order. */ -export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']); +export const EXPECTED_PROVIDERS = Object.freeze([ + 'mapillary', + 'google', + 'kartaview', +]); /** * Skips no setup can avoid: CI forks have no Google key (so neither Google 3D @@ -229,6 +242,9 @@ export async function serveFixtures(page, fixture) { patterns: [ { urlPattern: '*://*.mapillary.com/*' }, { urlPattern: '*/api/mapillary/*' }, + { urlPattern: '*://api.kartaview.org/*' }, + { urlPattern: '*://cdn.kartaview.org/*' }, + { urlPattern: '*://*.openstreetcam.org/*' }, ], }); const fulfil = (requestId, { status, headers = {}, contentType, body }) => @@ -259,6 +275,11 @@ export async function serveFixtures(page, fixture) { }); fixture.calls.add(call); if (!answer.known) fixture.unknown.push(call); + } else if (/(^|\.)(kartaview|openstreetcam)\.org$/.test(address.hostname)) { + const call = describeKartaViewCall(method, address); + answer = answerKartaViewRequest({ method, url: request.url }); + fixture.calls.add(call); + if (!answer.known) fixture.unknown.push(call); } else if ( address.pathname === '/api/mapillary/status' && fixture.configured !== undefined @@ -443,10 +464,50 @@ async function assertKeylessGate(page) { assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/); } +/** + * With no Mapillary key the panel points at KartaView, which needs none: + * lit, it draws coverage and the layer is not key-gated. + */ +async function assertKeylessKartaView(page) { + assert.match( + await page.$eval('#sl-coverage-meta', (node) => node.textContent), + /light KARTAVIEW/, + 'KEY REQUIRED points at KartaView', + ); + for (let attempt = 0; attempt < 4; attempt++) { + await page.evaluate((at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + }, PARK); + await sleep(1000); + } + await clickPanelControl( + page, + '#sl-provider-chips [data-chip-id="kartaview"]', + ); + await waitForCoverage(page); + const state = await page.evaluate(() => ({ + status: document.getElementById('sl-status').textContent, + keyRequired: window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().keyRequired, + })); + assert.equal(state.keyRequired, false); + assert.doesNotMatch(state.status, /KEY/); +} + /** * Runs in the page. Find a point where a real click picks `prefix` + `id` (any * id when null), searched around `points` ([lon, lat]), the aimed billboards, * or else a grid mid-screen. Only points where the canvas is topmost count. + * Each call picks for at most `budgetMs`; the next call resumes the search. * @returns {{x: number, y: number, id: string}|null} client coordinates */ export function findPickPoint({ @@ -455,6 +516,7 @@ export function findPickPoint({ points, billboardIds, billboardPrefix = null, + budgetMs = 4000, }) { const viewer = window.__godsEyeView.viewer; const scene = viewer.scene; @@ -509,24 +571,54 @@ export function findPickPoint({ centres.push([at.x, at.y]); } } - for (const [cx, cy] of centres) - for (let r = 0; r <= 16; r += 2) + // A pick is a render of the scene (0.5–2 s each under SwiftShader): try + // the centres the canvas is on top of first, nearest the screen centre, + // so the first pick usually hits. + const uncovered = ([x, y]) => + document.elementFromPoint(rect.left + x, rect.top + y) === canvas; + const fromMiddle = ([x, y]) => + (x - rect.width / 2) ** 2 + (y - rect.height / 2) ** 2; + centres.sort( + (a, b) => + Number(uncovered(b)) - Number(uncovered(a)) || + fromMiddle(a) - fromMiddle(b), + ); + const candidates = []; + for (const [cx, cy] of centres) { + candidates.push([cx, cy]); + for (let r = 2; r <= 16; r += 2) for (const [dx, dy] of [ - [0, 0], [r, 0], [-r, 0], [0, r], [0, -r], - ]) { - const hit = tryAt(cx + dx, cy + dy); - if (hit) return hit; - } - if (points || billboardIds) return null; - for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) - for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) { - const hit = tryAt(x, y); - if (hit) return hit; + ]) + candidates.push([cx + dx, cy + dy]); + } + // Aimed at known places: never fall back to a scan of the whole screen. + if (!points && !billboardIds && billboardPrefix === null) + for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) + for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) + candidates.push([x, y]); + // One evaluation stays well inside the caller's wait: past the budget it + // returns null, and the next poll carries on from where this one stopped. + const key = JSON.stringify([prefix, id, billboardPrefix]); + const cursors = (window.__qaPickCursors ??= {}); + const deadline = performance.now() + budgetMs; + let at = cursors[key] ?? 0; + if (at >= candidates.length) at = 0; + for (; at < candidates.length; at++) { + const hit = tryAt(...candidates[at]); + if (hit) { + delete cursors[key]; + return hit; } + if (performance.now() > deadline) { + cursors[key] = at + 1; + return null; + } + } + delete cursors[key]; return null; } @@ -726,6 +818,419 @@ async function main() { `${count} steps: ${count - skips.length} ok, ${skips.length} skipped`, ); + /** + * KartaView: keyless coverage from its JSON tiles, a line click opening + * the nearest photo, cones along the drive, › and a 360° photo. Runs on a + * draped map so a line is where it is drawn; leaves Mapillary lit. + */ + async function kartaViewSteps() { + // The first lookup from here on is the rate-limited one. + resetKartaViewFixture(); + const ui = () => + page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(), + ); + const uiUntil = (test, arg = null, options) => + settle( + page, + `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, + undefined, + options, + ); + const lit = (id) => + uiUntil( + (u, want) => + u.providers.find((p) => p.id === want)?.on === true && + u.providers.filter((p) => p.on).length === 1, + id, + ); + const stack = () => + page.evaluate(() => + window.__godsEyeView.mapStackController.getActiveId(), + ); + const setStack = async (id) => { + await page.evaluate( + (stackId) => + window.__godsEyeView.mapStackController.setStack(stackId), + id, + ); + await settle( + page, + (stackId) => + window.__godsEyeView.mapStackController.getActiveId() === stackId, + id, + ); + }; + const kvChip = '#sl-provider-chips [data-chip-id="kartaview"]'; + const original = await stack(); + const photos = kartaViewPhotos(); + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.closeViewer(), + ); + await setStack('esri-imagery'); + // Over the fixture drive, looking down: its line is mid-screen. + for (let attempt = 0; attempt < 4; attempt++) { + await page.evaluate((at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + }, PARK); + await sleep(1000); + } + await step( + 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date', + async () => { + await clickPanelControl(page, kvChip); + assert.ok(await lit('kartaview'), 'KartaView alone is lit'); + await waitForCoverage(page); + const u = await ui(); + assert.equal(u.keyRequired, false); + assert.deepEqual(u.filterControls, { pano: true, since: true }); + // KartaView's SINCE is its upload date: the panel says so. + assert.equal(u.sinceNote, 'KartaView: by upload date'); + assert.equal( + await page.$eval('#sl-since-note', (node) => + node.hidden ? null : node.textContent, + ), + 'KartaView: by upload date', + ); + assert.equal( + await page.$eval(kvChip, (chip) => + chip.style.getPropertyValue('--chip-color'), + ), + '#635bff', + ); + assert.match( + await page.$eval('#sl-coverage-meta', (node) => node.textContent), + /streets in view · click a line for its nearest photo/, + ); + await page.waitForFunction( + () => + document.body.innerHTML.includes('KartaView contributors'), + { timeout: 15_000 }, + ); + assert.ok( + await settle(page, () => + (new URLSearchParams(location.hash.slice(1)).get('lo') || '') + .split('_') + .some( + (entry) => + entry.startsWith('0.') && entry.includes('kartaview-1'), + ), + ), + `the share link lights KartaView: ${await page.evaluate(() => location.hash)}`, + ); + }, + ); + await step( + 'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back', + async () => { + const before = (await ui()).coverage.count; + const setStop = (index) => + page.$eval( + '#sl-since', + (input, value) => { + input.value = String(value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }, + index, + ); + // Filtered tiles are drawn by KartaView on demand: give them time. + const settled = async (test, arg, what) => + assert.ok( + await uiUntil(test, arg, { timeout: 60_000 }), + `${what}: ${JSON.stringify((await ui()).coverage)}`, + ); + await clickPanelControl(page, '[data-sl-pano="pano"]'); + await settled( + (u) => u.filter.pano === 'pano' && !u.coverage.loading, + null, + '360° drawn', + ); + const panos = (await ui()).coverage.count; + assert.ok(panos <= before, `${panos} ≤ ${before}`); + // Fixtures: the old street has only flat photos, the drive 360° too. + if (fixtures) + assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`); + await clickPanelControl(page, '[data-sl-pano="all"]'); + await settled( + (u, n) => + u.filter.pano === 'all' && + !u.coverage.loading && + u.coverage.count === n, + before, + 'every line again', + ); + await setStop(5); + await settled( + (u) => u.filter.sinceDays === 365 && !u.coverage.loading, + null, + 'LAST YEAR drawn', + ); + const lastYear = (await ui()).coverage.count; + assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`); + // Fixtures: the drive is a month old, the old street from 2019. + if (fixtures) + assert.ok( + lastYear > 0 && lastYear < before, + `0 < ${lastYear} < ${before}`, + ); + await setStop(0); + await settled( + (u, n) => + u.filter.sinceDays === 0 && + !u.coverage.loading && + u.coverage.count === n, + before, + 'ANY DATE: every line again', + ); + assert.equal((await ui()).coverage.error, null); + }, + ); + await step( + 'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo', + async ({ skip }) => { + const along = Array.from({ length: 9 }, (_, i) => [ + KV_LINE.lon, + KV_LINE.south + ((i + 2) / 13) * (KV_LINE.north - KV_LINE.south), + ]); + const handle = await page + .waitForFunction( + findPickPoint, + { timeout: 30_000, polling: 250 }, + { + prefix: 'kv:line:', + id: null, + points: fixtures ? along : null, + billboardIds: null, + }, + ) + .catch(() => null); + if (!handle && !fixtures) return skip('no KartaView line in view'); + assert.ok(handle, 'a KartaView line is pickable'); + const at = await handle.jsonValue(); + const camera = () => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionCartographic; + return { + lon: (c.longitude * 180) / Math.PI, + lat: (c.latitude * 180) / Math.PI, + height: c.height, + }; + }); + const parked = await camera(); + // The first lookup is rate-limited (fixtures): it waits and retries. + await page.mouse.click(at.x, at.y); + await page.waitForFunction( + () => { + const s = window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().street; + return (s.imageId && !s.loading) || s.error; + }, + { timeout: 60_000 }, + ); + const street = (await ui()).street; + if (!fixtures && /No street-level imagery/.test(street.error || '')) + return skip('no KartaView photo near the line'); + if (fixtures) + assert.ok( + kartaViewLookups() >= 2, + `the rate-limited lookup was asked again (${kartaViewLookups()} lookups)`, + ); + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, 'kartaview'); + assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo'); + assert.ok( + await settle(page, () => { + const img = document.querySelector('.sl-kv-photo'); + return img && !img.hidden && img.naturalWidth > 0; + }), + 'the photo is on screen', + ); + // A line click picks a spot: the camera stays where it was. + await sleep(1500); + const now = await camera(); + assert.ok( + Math.abs(now.lon - parked.lon) < 1e-6 && + Math.abs(now.lat - parked.lat) < 1e-6 && + Math.abs(now.height - parked.height) < 0.5, + `the camera did not move: ${JSON.stringify({ parked, now })}`, + ); + if (fixtures) { + const opened = photos.find((photo) => photo.id === street.imageId); + assert.ok(opened, `${street.imageId} is a fixture photo`); + assert.equal( + opened.archived, + false, + 'the nearest photo that storage still has', + ); + assert.ok( + await uiUntil( + (u, want) => + u.sequence.selectedId === want.id && + !u.sequence.loading && + u.sequence.images === want.count, + { id: KV_SEQUENCE_ID, count: photos.length }, + { timeout: 30_000 }, + ), + `cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`, + ); + // The drive runs north; the photos' compass says 200°. + assert.ok( + await uiUntil( + (u) => + Number.isFinite(u.street.bearing) && + Math.min(u.street.bearing, 360 - u.street.bearing) < 2, + ), + `the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`, + ); + } + const before = street.imageId; + await clickPanelControl(page, '.sl-kv-step-next'); + assert.ok( + await uiUntil( + (u, was) => + u.street.imageId && u.street.imageId !== was && !u.street.error, + before, + { timeout: 30_000 }, + ), + '› opened the next photo', + ); + if (fixtures) { + const skipped = photos.find( + (photo) => photo.id === String(Number(before) + 1), + ); + assert.equal( + skipped?.archived, + true, + 'the photo after is archived', + ); + assert.equal( + (await ui()).street.imageId, + String(Number(before) + 2), + '› skipped the archived photo to the next one along the drive', + ); + } + }, + ); + if (fixtures) + await step( + 'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context', + async () => { + const pano = photos.find((photo) => photo.isPano); + await page.evaluate((id) => { + void window.__godsEyeView.dataManager.layers + .get('street-level') + .module.openImage('kartaview', id); + }, pano.id); + assert.ok( + await uiUntil( + (u, want) => + u.street.imageId === want && + !u.street.loading && + u.street.isPano, + pano.id, + { timeout: 60_000 }, + ), + `the panorama opened: ${JSON.stringify((await ui()).street)}`, + ); + const u = await ui(); + assert.equal(u.street.error, null); + assert.equal(u.street.renderModes, false, 'no frame to fit'); + assert.equal( + await page.$eval('.sl-kv-pano', (canvas) => canvas.hidden), + false, + ); + const box = await page.$eval('.sl-kv-pano', (canvas) => { + const r = canvas.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + const bearing = u.street.bearing; + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + for (let i = 1; i <= 8; i++) + await page.mouse.move(box.x - i * 15, box.y); + await page.mouse.up(); + assert.ok( + await uiUntil((v, was) => v.street.bearing !== was, bearing), + 'dragging the panorama turned its bearing', + ); + // A GPU reset: the panorama comes back on a new canvas, and drags. + assert.ok( + await page.evaluate(() => { + const canvas = document.querySelector('.sl-kv-pano'); + window.__kvLostCanvas = canvas; + const lose = canvas + .getContext('webgl') + ?.getExtension('WEBGL_lose_context'); + lose?.loseContext(); + return Boolean(lose); + }), + 'the browser can lose a WebGL context on purpose', + ); + assert.ok( + await settle( + page, + () => { + const canvas = document.querySelector('.sl-kv-pano'); + return ( + canvas && + canvas !== window.__kvLostCanvas && + !canvas.hidden && + canvas.width > 1 + ); + }, + null, + { timeout: 30_000 }, + ), + 'the panorama is drawn again on a new canvas', + ); + const after = await ui(); + assert.equal(after.street.imageId, pano.id); + assert.equal(after.street.isPano, true); + assert.equal(after.street.error, null); + const turned = after.street.bearing; + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + for (let i = 1; i <= 8; i++) + await page.mouse.move(box.x + i * 15, box.y); + await page.mouse.up(); + assert.ok( + await uiUntil((v, was) => v.street.bearing !== was, turned), + 'the new canvas drags', + ); + }, + ); + else + await skipStep( + 'fixtures only', + 'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context', + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.closeViewer(), + ); + await clickPanelControl( + page, + '#sl-provider-chips [data-chip-id="mapillary"]', + ); + assert.ok(await lit('mapillary'), 'Mapillary is lit again'); + await setStack(original); + } + async function keyedSteps() { // A late startup flight only shows itself by moving the camera, so park // and check it stays put rather than reading a state. @@ -1116,9 +1621,10 @@ async function main() { ((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south), ]); // Ground primitives are built over a few frames: poll for a pick. + // A loaded machine renders a frame in seconds: 60 s, not 30. const handle = await page.waitForFunction( findPickPoint, - { timeout: 30_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:seq:', id: target, @@ -1143,22 +1649,28 @@ async function main() { ); if (fixtures) assert.equal(sequence.images, fixture.images.length, 'every photo'); - // The cones are on the globe: one of them picks as an image. + // The cones are on the globe: one of them picks as an image. Aim at + // where each cone billboard is drawn (its ground position at + // height 0 can sit a few pixels off, and every missed pick costs a + // render); the same 60 s as the line pick above. const cone = await page.waitForFunction( findPickPoint, - { timeout: 15_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:img:', id: null, - points: fixtures - ? fixture.images.map((image) => [image.lon, image.lat]) - : null, + points: null, billboardIds: null, - // Live: the cones' positions are only known from the globe. - billboardPrefix: fixtures ? null : 'mly:img:', + billboardPrefix: 'mly:img:', }, ); - assert.ok(await cone.jsonValue(), 'an image cone is pickable'); + const picked = await cone.jsonValue(); + assert.ok(picked, 'an image cone is pickable'); + if (fixtures) + assert.ok( + fixture.images.some((image) => image.id === picked.id), + `the cone is a photo of the drive: ${picked.id}`, + ); await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('street-level') @@ -1286,7 +1798,9 @@ async function main() { assert.ok(neighbour, 'a neighbour of the other camera type'); const handle = await page.waitForFunction( findPickPoint, - { timeout: 15_000, polling: 250 }, + // A pick is a render; under SwiftShader on a busy machine one + // evaluation can take seconds. + { timeout: 30_000, polling: 250 }, { prefix: 'mly:img:', id: neighbour.id, @@ -1803,16 +2317,24 @@ async function main() { credit: false, button: false, }); - // Street View rides the shared provider-switch field (r). + // Street View rides the shared provider-switch field (r), beside + // the other providers' switches a chip click recorded. const option = `${STREET_LEVEL_TOKEN}.r.google-1`; assert.ok( await settle( page, - (token) => + (prefix) => (new URLSearchParams(location.hash.slice(1)).get('lo') || '') .split('_') - .includes(token), - option, + .some( + (entry) => + entry.startsWith(prefix) && + entry + .slice(prefix.length) + .split('*') + .includes('google-1'), + ), + `${STREET_LEVEL_TOKEN}.r.`, // The link is written once the view rests for 500 ms; under a // software renderer with a photo open that can take a while. { timeout: 30_000 }, @@ -2026,6 +2548,7 @@ async function main() { } }, ); + await kartaViewSteps(); await step( 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', async () => { @@ -2084,7 +2607,7 @@ async function main() { ); if (fixtures) await step( - 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED', + 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED, and KartaView draws there', async () => { const keyless = await browser.newPage(); await keyless.setViewport(VIEWPORTS[0]); @@ -2093,6 +2616,7 @@ async function main() { await boot(keyless, url); await expandStrip(keyless); await assertKeylessGate(keyless); + await assertKeylessKartaView(keyless); renderErrorsSeen.push( ...(await readRenderErrors(keyless)).map( (text) => `[keyless] scene.renderError: ${text}`, @@ -2108,7 +2632,7 @@ async function main() { ); if (fixtures) await step( - 'every Mapillary request was answered from a fixture', + 'every Mapillary and KartaView request was answered from a fixture', () => { console.log( ` (answered: ${[...fixture.calls].sort().join(' | ')})`, diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 350e71dea..f176d97de 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -1,6 +1,7 @@ import { createStreetLevelLayer } from '../../layers/streetLevel/index.js'; import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js'; import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js'; +import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; @@ -10,12 +11,16 @@ import * as render from '../../renderGovernor.js'; /** The browser Maps key the scene was started with (src/app/scene.js). */ const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null; -/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */ +/** + * Mapillary, Google Street View and KartaView, once the Mapillary source is + * supplied; else none. + */ function defaultProviders(sources) { if (sources.mapillary == null) return []; return [ createMapillaryProvider({ source: sources.mapillary }), createGoogleProvider({ getApiKey: googleApiKey }), + createKartaViewProvider(), ]; } diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index 8087a2c38..c2c3ebb16 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it ); }); -test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => { +test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => { const { createDefaultLayerState } = await import('../../data/layerState.js'); const mapillary = { getStatus() {}, @@ -37,7 +37,7 @@ test('the defaults are Mapillary (given its source) and Street View, which start nearestImages() {}, }; const layer = createApplicationStreetLevel({ sources: { mapillary } }); - assert.deepEqual(layer.providerIds, ['mapillary', 'google']); + assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']); // A fresh page and a restored default agree: Street View is billed, so off. layer.setParams(createDefaultLayerState().options['street-level']); assert.equal(layer.getParams().google, false); @@ -45,7 +45,12 @@ test('the defaults are Mapillary (given its source) and Street View, which start // A link that lit Street View carries it in the shared switch field. layer.setParams({ providerSwitches: 'google-1' }); assert.equal(layer.getParams().google, true); - assert.equal(layer.getParams().providerSwitches, 'google-1'); + assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0'); + // KartaView is keyless and free, but one source shows at a time. + layer.setParams({ providerSwitches: 'kartaview-1' }); + assert.equal(layer.getParams().kartaview, true); + assert.equal(layer.getParams().google, false); + assert.equal(layer.getParams().mapillary, false); // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index eed4230ab..97b864d40 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -108,7 +108,15 @@ export function createStreetLevelLayer({ getSurface: () => state.surface, notify, actions: { - openImage: (imageId) => openImage(entry.def.id, imageId), + /** `{frame: false}` opens it without moving the camera. */ + openImage: (imageId, options) => + openImage(entry.def.id, imageId, options), + /** This provider's image nearest a point the user picked on the map. */ + openNearest: (point) => + layer.openNearest(point, { + providerIds: [entry.def.id], + frame: false, + }), /** * Show a provider error, or withdraw it (null). A withdrawal clears * only this provider's own error, never a newer one from elsewhere. @@ -334,15 +342,36 @@ export function createStreetLevelLayer({ function setProviderEnabled(providerId, on) { const entry = state.providers.get(providerId); if (!entry) return false; + const before = litIds(); const next = on !== false; if (next) for (const other of state.providers.values()) if (other !== entry && other.on) switchProvider(other, false); switchProvider(entry, next); + sourceChanged(before); notify(); return true; } + const litIds = () => + [...state.providers] + .filter(([, entry]) => entry.on) + .map(([id]) => id) + .join(); + + /** + * After a chip, voice or a link changed the lit source: a lookup still + * running and its spinner or error were about the source switched away + * (an open photo keeps its own state). + */ + function sourceChanged(before) { + if (litIds() === before) return; + abortNearest(); + if (state.street.open) return; + state.street.error = null; + state.street.loading = false; + } + function switchProvider(entry, on) { if (entry.on === on) return; entry.on = on; @@ -407,6 +436,9 @@ export function createStreetLevelLayer({ hint: hintFor(entry, stats), error: stats.error || null, color: entry.def.colors.coverage, + coverageLabel: entry.def.coverageLabel || null, + highlightsSequence: entry.def.highlightsSequence !== false, + sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture', groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; @@ -606,9 +638,11 @@ export function createStreetLevelLayer({ filter: state.filter, }); const lit = litByParams(decoded.providers); + const before = litIds(); for (const [id, entry] of state.providers) if (id !== lit) switchProvider(entry, false); if (lit) switchProvider(state.providers.get(lit), true); + sourceChanged(before); notify(); setCoverageFilter(decoded.filter); return true; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 5ae19c2d0..f50bcd380 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -4,6 +4,7 @@ import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { createMapillaryProvider } from './providers/mapillary/index.js'; +import { createKartaViewProvider } from './providers/kartaview/index.js'; import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs'; /** @@ -92,8 +93,8 @@ function deferred() { } /** A provider whose viewer reports a pose for every image it opens. */ -function posingProvider() { - const provider = fakeProvider(); +function posingProvider(options) { + const provider = fakeProvider(options); const create = provider.create; provider.create = (context) => { const instance = create(context); @@ -818,3 +819,127 @@ test('an empty provider composition enables and tears down without acquiring ima layer.enable(viewer); assert.deepEqual(layer.getUIState().providers, []); }); + +test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => { + const failing = fakeProvider({ + nearestImage: async () => { + throw new Error('KartaView did not answer in time — try again'); + }, + }); + const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' }); + const { layer, viewer } = await enabledLayer(t, [failing, other]); + layer.attachViewerHost({}); + await layer.openNearest({ lat: 1, lon: 1 }); + assert.match(layer.getUIState().street.error, /did not answer/); + layer.setProviderEnabled('other', true); + await settle(); + assert.equal(layer.getUIState().street.error, null); + // A provider's own open (KartaView's ‹ ›) can leave the camera alone. + await other.context().actions.openImage('img-1', { frame: false }); + assert.deepEqual(other.calls.open, ['img-1']); + assert.equal(viewer.flights.started, 0); +}); + +test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => { + const provider = posingProvider(); + const { layer, viewer } = await enabledLayer(t, [provider]); + layer.attachViewerHost({}); + await provider.context().actions.openImage('img-1', { frame: false }); + assert.deepEqual(provider.calls.open, ['img-1']); + assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera'); + await provider.context().actions.openImage('img-2'); + assert.equal(viewer.flights.started, 1, 'a default open frames the image'); +}); + +test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => { + const lit = posingProvider({ nearestImage: async () => 'lit-near' }); + const other = fakeProvider({ + id: 'other', + pickPrefix: 'oth:', + nearestImage: async () => 'other-near', + }); + const { layer, viewer } = await enabledLayer(t, [lit, other]); + layer.attachViewerHost({}); + const point = { lat: 38.58, lon: -121.49 }; + // The other source is off: its click asks nobody else. + await other.context().actions.openNearest(point); + await settle(); + assert.deepEqual(lit.calls.open, [], 'the lit source was not asked'); + assert.deepEqual(other.calls.open, []); + await lit.context().actions.openNearest(point); + assert.deepEqual(lit.calls.open, ['lit-near']); + 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) => { + const kartaView = createKartaViewProvider({ + source: { + getTile: async () => ({ lines: [] }), + nearestPhotos: async () => [], + getPhoto: async () => { + throw new Error('KartaView has no such photo'); + }, + getSequencePhotos: async () => ({ photos: [], hasMore: false }), + probeImage: async () => true, + }, + }); + const { layer } = await enabledLayer(t, [kartaView]); + layer.setProviderEnabled('kartaview', true); + await settle(); + const ui = layer.getUIState(); + assert.deepEqual( + ui.legend.map((entry) => entry.key), + ['kartaview'], + ); + assert.equal(ui.coverage.label?.noun, 'streets'); + assert.equal( + ui.providers.find((p) => p.id === 'kartaview').highlightsSequence, + false, + ); +}); + +test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => { + const slow = slowLookups(); + const looking = fakeProvider({ nearestImage: slow.nearestImage }); + const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' }); + const { layer } = await enabledLayer(t, [looking, other]); + layer.attachViewerHost({}); + const opening = layer.openNearest({ lat: 1, lon: 1 }); + await settle(); + assert.equal(layer.getUIState().street.loading, true); + layer.setParams({ mapillary: false, other: true }); + assert.equal(slow.lookups[0].signal.aborted, true, 'the lookup is stopped'); + assert.equal( + layer.getUIState().street.loading, + false, + 'no spinner left behind', + ); + // The stopped lookup answers late: nothing opens, nothing is reported. + slow.lookups[0].answer.resolve('img1'); + assert.equal(await opening, false); + assert.deepEqual(looking.calls.open, []); + assert.equal(layer.getUIState().street.error, null); +}); + +test('switching source by params clears a failed lookup’s error; reapplying the same switches does not', async (t) => { + const failing = fakeProvider({ + nearestImage: async () => { + throw new Error('KartaView did not answer in time — try again'); + }, + }); + const { layer: second } = await enabledLayer(t, [ + failing, + fakeProvider({ id: 'other', pickPrefix: 'oth:' }), + ]); + second.attachViewerHost({}); + await second.openNearest({ lat: 1, lon: 1 }); + assert.match(second.getUIState().street.error, /did not answer/); + second.setParams({ mapillary: false, other: true }); + assert.equal(second.getUIState().street.error, null); + // Reapplying the same switches (a restore) leaves state alone. + await second.openNearest({ lat: 1, lon: 1 }); + const kept = second.getUIState().street.error; + assert.match(kept, /No street-level imagery/); + second.setParams({ mapillary: false, other: true }); + assert.equal(second.getUIState().street.error, kept); +}); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js new file mode 100644 index 000000000..71b98ed6a --- /dev/null +++ b/src/layers/streetLevel/lineCoverage.js @@ -0,0 +1,936 @@ +import * as Cesium from 'cesium'; +import { passesImageryFilter } from './filter.js'; +import { + createHorizonCull, + groundUnderCamera, + metresBetween, + viewFocus, + visibleBbox, + whenIdle, +} from './view.js'; +import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js'; +import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js'; +import { tileBounds, tilesForBbox } from './tileMath.js'; + +/** Draped lines stay at most this long while a tile's cast lines build. */ +export const SWAP_MAX_WAIT_MS = 4000; +/** Tiles touched by new mesh samples are redrawn at most this often. */ +const REMESH_INTERVAL_MS = 1500; +/** Gap between redrawing one dirty tile and the next. */ +const REMESH_STAGGER_MS = 120; +/** Old-zoom tiles are kept at most this long after a zoom change. */ +const STALE_TILE_MAX_MS = 6000; +/** Sequences per primitive: smaller batches build, and show, sooner. */ +const SEQUENCE_PRIMITIVE_BATCH = 120; + +/** Separates a multi-part sequence's part index from its id in pick ids. */ +const PART_SEPARATOR = '~'; + +/** Pick ids for every part of a sequence; the first part is unsuffixed. */ +function partIds(sequence, prefix) { + return sequence.parts.map((_, index) => + index + ? `${prefix}${sequence.id}${PART_SEPARATOR}${index}` + : `${prefix}${sequence.id}`, + ); +} + +/** Sequence id for any part's pick id (`[~]`), else null. */ +export function sequenceIdFromPick(pickId, prefix) { + if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix)) + return null; + const rest = pickId.slice(prefix.length); + const cut = rest.indexOf(PART_SEPARATOR); + return cut === -1 ? rest : rest.slice(0, cut); +} + +/** + * Whether any point of `box` ({west, south, east, north}) is within the mesh + * sampler's range of `centre`, the ground point under the camera. + */ +export function meshBoxInRange(box, centre) { + // Move the camera's longitude to the box's side of the date line first: + // clamping 179.999 into a box at -179.99 would pick its far edge. + const lon = + centre.lon + + 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); + const nearest = { + lon: Math.min(box.east, Math.max(box.west, lon)), + lat: Math.min(box.north, Math.max(box.south, centre.lat)), + }; + return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; +} + +/** + * @typedef {object} LineCoverageTuning + * @property {number} maxTiles Line tiles drawn at once. + * @property {number} overviewMaxTiles Overview tiles drawn at once. + * @property {number} maxSequences Lines drawn at once, over every tile. + * @property {number} lineWidthPx + * @property {number} overviewPointPx + * @property {number} moveDebounceMs Camera rest before the tile set is recomputed. + * @property {number} viewRangeMinM Street-zoom coverage reaches at least this far… + * @property {number} viewRangePerHeight …or camera height times this. + * @property {number} viewNearM Ground always covered around a tilted camera. + */ + +/** + * Camera-driven coverage from any XYZ line-tile source: overview points from + * orbit, sequence lines near the ground. In terrain surface mode a tile is + * drawn draped first, then swapped for lines cast to the bare earth (draped + * lines would land on roofs), so coverage never waits on terrain heights. + * + * @param {object} options + * @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags. + * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array, overview: Array}>} options.fetchTile + * A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview + * points `{lon, lat, capturedAt, isPano}`. A rejection may carry + * `keyRequired`, `keyRejected` or `retryAfterSec`. + * @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor + * Tile zoom for a camera height above ground; null draws nothing. + * @param {{coverage: string, selected: string}} options.colors + * @param {string} options.pickPrefix Pick-id prefix of the lines. + * @param {string} options.renderKey Render-governor reason. + * @param {LineCoverageTuning} options.tuning + * @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages + * @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point. + */ +export function createLineCoverage({ + state, + fetchTile, + zoomFor, + colors, + pickPrefix, + renderKey, + tuning, + messages, + passes = passesImageryFilter, +}) { + const { render } = state.services; + const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles); + + /** Colour for a line: the provider's, GEV cyan while selected. */ + function sequenceColor({ selected = false } = {}) { + return selected + ? Cesium.Color.fromCssColorString(colors.selected) + : Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92); + } + + function requestRender() { + render?.governorRequestRender?.(renderKey); + } + + function ensureTerrainReady() { + return (state.coverage.terrainReady ||= + Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); + } + + function tileKey({ x, y, z }) { + return `${z}/${x}/${y}`; + } + + /** The imagery filter, resolved now: "since N days" moves with the clock. */ + function filter() { + return state.context.getFilter(); + } + + function terrainMode() { + return ( + state.context.getSurface?.() === 'terrain' && + Boolean(state.context.groundCaster) + ); + } + + /** + * A tile's sequences, filtered then capped: capping first could leave a + * dense tile of newer flat captures with no 360° lines at all. + */ + function drawnSequences(entry) { + const drawn = []; + const current = filter(); + for (const sequence of entry.sequenceList) { + if (drawn.length >= perTileSequenceCap) break; + if (passes(sequence, current)) drawn.push(sequence); + } + return drawn; + } + + /** + * Batched primitives for a tile's sequence parts: cast polylines where + * terrain heights are cached, draped ground lines otherwise. Each batch + * records the selection its colours were built with. + */ + function buildSequencePrimitives(sequences) { + const ground = terrainMode() ? state.context.groundCaster : null; + const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; + const selectedId = state.sequence.selectedId ?? null; + const draped = []; + const cast = []; + for (const sequence of sequences) { + const color = Cesium.ColorGeometryInstanceAttribute.fromColor( + sequenceColor({ selected: sequence.id === selectedId }), + ); + const ids = partIds(sequence, pickPrefix); + sequence.parts.forEach((coordinates, index) => { + const flat = ground?.castLine(coordinates, { meshAt }); + let positions; + try { + positions = flat + ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) + : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); + } catch { + return; + } + if (positions.length < 2) return; + const geometry = flat + ? new Cesium.PolylineGeometry({ + positions, + width: tuning.lineWidthPx, + vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, + arcType: Cesium.ArcType.NONE, + }) + : new Cesium.GroundPolylineGeometry({ + positions, + width: tuning.lineWidthPx, + }); + (flat ? cast : draped).push( + new Cesium.GeometryInstance({ + geometry, + id: ids[index], + attributes: { color }, + }), + ); + }); + } + const primitives = []; + for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push({ + onGround: true, + selectedId, + primitive: new Cesium.GroundPolylinePrimitive({ + geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance(), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + allowPicking: true, + }), + }); + for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push({ + onGround: false, + selectedId, + primitive: new Cesium.Primitive({ + geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance({ translucent: true }), + asynchronous: true, + allowPicking: true, + }), + }); + return { primitives, draped, cast: cast.length }; + } + + /** + * Fetch the terrain heights a tile's lines need, then redraw it cast. + * `castRequested` stops the redraw from casting again at once; a refresh + * `retry`s a tile whose lines are still draped (a failed or partial cast), + * and it is redrawn only when more of its lines can be cast. + */ + function castTile(entry, { retry = false } = {}) { + if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) + return; + entry.castRequested = true; + entry.castAbort = new AbortController(); + const { signal } = entry.castAbort; + const lines = [...entry.sequences.values()].flatMap( + (sequence) => sequence.parts, + ); + const caster = state.context.groundCaster; + caster.prepareLines(lines, { signal }).then(() => { + if (entry.castAbort?.signal === signal) entry.castAbort = null; + const attached = [...state.coverage.tiles.values()].includes(entry); + if (signal.aborted || !attached || !terrainMode()) return; + // Nothing new to cast: keep what is drawn until the next refresh. + // Counted like buildSequencePrimitives: a cast line needs two points. + const castable = lines.filter( + (coords) => caster.castLine(coords)?.length >= 6, + ).length; + if (castable <= (entry.castLines || 0)) { + entry.castRequested = (entry.castLines || 0) > 0; + return; + } + // A remesh may have cleared the flag while this was in flight. + entry.castRequested = true; + redrawTile(entry); + requestMesh(entry); + }); + } + + /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ + function redrawTile(entry) { + const previous = entry.primitives; + entry.primitives = []; + attachPrimitive(entry); + removeWhenReady(entry, previous); + } + + /** Lon/lat under the camera, where mesh range is measured from. */ + function meshCentre() { + const carto = state.viewer?.camera?.positionCartographic; + if (!carto) return null; + return { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + }; + } + + /** + * A tile's drawn lines with bounding boxes, built once per filter; mesh + * points are densified the first time a line is in range. + */ + function meshParts(entry) { + if (entry.meshParts) return entry.meshParts; + entry.meshParts = []; + for (const sequence of entry.sequences.values()) + for (const part of sequence.parts) { + const box = { + west: Infinity, + south: Infinity, + east: -Infinity, + north: -Infinity, + }; + for (const [lon, lat] of part) { + if (lon < box.west) box.west = lon; + if (lon > box.east) box.east = lon; + if (lat < box.south) box.south = lat; + if (lat > box.north) box.north = lat; + } + entry.meshParts.push({ part, box, points: null }); + } + return entry.meshParts; + } + + /** + * Ask for mesh samples under a cast tile's lines within the sampler's range + * of the camera; the rest are asked for when a camera move brings them in. + */ + function requestMesh(entry) { + const sampler = state.context.meshSampler; + if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; + const centre = meshCentre(); + if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) + return; + const points = []; + for (const line of meshParts(entry)) { + if (!meshBoxInRange(line.box, centre)) continue; + line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); + for (const point of line.points) points.push(point); + } + if (points.length) sampler.request(points); + } + + /** Redraw the tiles new mesh samples fall in, throttled. */ + function onMeshSampled(batch) { + if (!terrainMode() || !state.context.isActive()) return; + for (const entry of state.coverage.tiles.values()) { + if (entry.kind !== 'sequence' || !entry.bounds) continue; + const { west, south, east, north } = entry.bounds; + if ( + batch.some( + ([lon, lat]) => + lon >= west && lon <= east && lat >= south && lat <= north, + ) + ) + state.coverage.remeshDirty.add(entry); + } + if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; + const wait = Math.max( + 0, + REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), + ); + state.coverage.remeshTimer = setTimeout(remesh, wait); + } + + /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ + function remesh() { + state.coverage.remeshTimer = null; + state.coverage.remeshAt = Date.now(); + if (!terrainMode() || !state.context.isActive()) { + state.coverage.remeshDirty.clear(); + return; + } + const attached = new Set(state.coverage.tiles.values()); + const [entry] = state.coverage.remeshDirty; + if (!entry) return; + state.coverage.remeshDirty.delete(entry); + if (attached.has(entry)) { + redrawTile(entry); + requestRender(); + } + if (state.coverage.remeshDirty.size) + state.coverage.remeshTimer = setTimeout( + () => whenIdle(remesh, 500), + REMESH_STAGGER_MS, + ); + } + + state.coverage.remeshDirty = new Set(); + state.context.meshSampler?.onSampled(onMeshSampled); + + /** + * Remove a tile's previous primitives once all its current ones are ready + * (or after a few seconds); detaching the tile removes them at once. + */ + function removeWhenReady(entry, old) { + const scene = state.viewer?.scene; + finishSwap(entry); + if (!scene?.postRender) { + removePrimitives(old); + return; + } + const fresh = entry.primitives; + const started = Date.now(); + const stop = scene.postRender.addEventListener(() => { + const ready = fresh.every( + ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), + ); + if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { + requestRender(); + return; + } + finishSwap(entry); + requestRender(); + }); + entry.swap = { old, stop }; + requestRender(); + } + + function finishSwap(entry) { + if (!entry.swap) return; + entry.swap.stop(); + removePrimitives(entry.swap.old); + entry.swap = null; + } + + function buildOverviewCollection(points) { + const collection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + const tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha( + 0.85, + ); + const drawn = []; + const current = filter(); + for (const point of points) { + if (!passes(point, current)) continue; + drawn.push( + collection.add({ + position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), + color: tint, + pixelSize: tuning.overviewPointPx, + // Google 3D terrain and clouds must not hide the near side's dots; + // the horizon cull hides the far side's, which this lets through. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }), + ); + } + return { collection, points: drawn }; + } + + /** Every overview point on the globe, current zoom and old alike. */ + function* overviewPoints() { + for (const entry of drawnEntries()) + if (entry.overviewPoints) yield* entry.overviewPoints; + } + + // Overview points skip the depth test: hide the far hemisphere's. + const horizon = createHorizonCull({ + getViewer: () => state.viewer, + items: overviewPoints, + onChange: requestRender, + }); + + function attachPrimitive(entry) { + const scene = state.viewer?.scene; + if (!scene) return; + if (entry.kind === 'sequence') { + // Lookup, picking and counts follow what is drawn, not the whole tile. + const sequences = drawnSequences(entry); + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + const { primitives, draped, cast } = buildSequencePrimitives(sequences); + entry.primitives = primitives; + entry.count = sequences.length; + for (const { primitive, onGround } of primitives) + (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); + // Fewer lines cast than last time: the caster has dropped heights this + // tile used (a full cache), so it must be cast again. + if (cast < (entry.castLines || 0)) entry.castRequested = false; + entry.castLines = cast; + entry.drapedLines = draped.length; + if (draped.length) castTile(entry); + watchSelection(); + } else { + const { collection, points } = buildOverviewCollection(entry.points); + entry.primitive = collection; + entry.overviewPoints = points; + entry.count = points.length; + scene.primitives.add(collection); + // Cull the new points now, then all of them whenever the camera moves. + horizon.update(points); + } + } + + function removePrimitives(list) { + const scene = state.viewer?.scene; + for (const { primitive, onGround } of list || []) { + try { + (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( + primitive, + ); + } catch { + /* already gone */ + } + } + } + + function detachPrimitive(entry) { + const scene = state.viewer?.scene; + finishSwap(entry); + removePrimitives(entry.primitives); + entry.primitives = []; + if (entry.primitive) { + try { + scene?.primitives?.remove(entry.primitive); + } catch { + /* already gone */ + } + entry.primitive = null; + entry.overviewPoints = null; + } + } + + /** Stop a tile's pending terrain lookup (tile dropped or retired). */ + function cancelCast(entry) { + entry.castAbort?.abort(); + entry.castAbort = null; + } + + function removeTile(key) { + const entry = state.coverage.tiles.get(key); + if (!entry) return; + cancelCast(entry); + detachPrimitive(entry); + state.coverage.tiles.delete(key); + } + + async function loadTile(tile, kind) { + const key = tileKey(tile); + if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) + return; + const controller = new AbortController(); + state.coverage.pending.set(key, controller); + notify(); + try { + // Fetch and the one-time terrain-height table load run side by side, + // both handled from the start so an early tile failure is not unhandled. + const [decoded] = await Promise.all([ + fetchTile(tile, { signal: controller.signal }), + kind === 'sequence' ? ensureTerrainReady() : null, + ]); + // Only an abort, a retire, a clear or a newer request for this tile + // discards the bytes; a refresh that still wants the tile keeps them. + const current = () => + state.coverage.pending.get(key) === controller && + !state.coverage.tiles.has(key); + if ( + controller.signal.aborted || + !current() || + kind !== state.coverage.kind || + tile.z !== state.coverage.zoom + ) + return; + const entry = { kind, primitive: null, primitives: [], count: 0 }; + // Padded: vector tiles carry a small buffer past their edge. + const bounds = tileBounds(tile.x, tile.y, tile.z); + const pad = (bounds.east - bounds.west) * 0.05; + entry.bounds = { + west: bounds.west - pad, + east: bounds.east + pad, + south: bounds.south - pad, + north: bounds.north + pad, + }; + if (kind === 'sequence') { + // Newest first; the per-tile cap applies to what passes the filter. + entry.sequenceList = decoded.sequences.sort( + (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), + ); + entry.sequences = new Map(); + entry.total = decoded.sequences.length; + } else { + entry.points = decoded.overview; + entry.sequences = new Map(); + entry.total = decoded.overview.length; + } + attachPrimitive(entry); + state.coverage.tiles.set(key, entry); + state.coverage.lastError = null; + requestRender(); + } catch (error) { + if ( + controller.signal.aborted || + state.coverage.pending.get(key) !== controller + ) + return; + state.coverage.lastError = error?.message || 'Coverage tile failed'; + if (error?.keyRequired) state.keyRequired = true; + if (error?.keyRejected) { + // Every other tile would be refused too: stop asking. + state.keyRejected = true; + state.coverage.lastError = + messages.keyRejected || state.coverage.lastError; + } + if (error?.retryAfterSec) holdFor(error.retryAfterSec); + } finally { + // Only the request that still owns the key settles it: a superseded one + // must not drop a newer request's entry (which is what counts as loading). + if (state.coverage.pending.get(key) === controller) { + state.coverage.pending.delete(key); + if (!state.coverage.pending.size) purgeStale(); + } + notify(); + } + } + + /** + * The source is rate-limiting: keep what is drawn, request nothing until the + * wait is over, then refresh once. + */ + function holdFor(seconds) { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdUntil = Date.now() + seconds * 1000; + state.coverage.lastError = messages.rateLimited; + state.coverage.holdTimer = setTimeout(() => { + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + if (state.coverage.lastError === messages.rateLimited) + state.coverage.lastError = null; + refresh(); + }, seconds * 1000); + } + + /** Forget refusals when the provider goes off, so the next run asks again. */ + function resetErrors() { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + state.coverage.lastError = null; + state.keyRejected = false; + } + + /** Drop the previous zoom's tiles once the new ones are on screen. */ + function purgeStale() { + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = null; + if (!state.coverage.stale.size) return; + for (const entry of state.coverage.stale.values()) detachPrimitive(entry); + state.coverage.stale.clear(); + requestRender(); + } + + /** Move tiles to the stale set: the old zoom shows until the new one loads. */ + function retire() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + for (const [key, entry] of state.coverage.tiles) { + cancelCast(entry); + const previous = state.coverage.stale.get(key); + if (previous) detachPrimitive(previous); + state.coverage.stale.set(key, entry); + } + state.coverage.tiles.clear(); + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); + } + + function notify() { + state.context.notify(); + } + + /** + * Whether the screen centre meets the ground further ahead than the camera + * is high (a view tilted above 45°), or misses it. + */ + function isTilted({ nadir, ahead }, height) { + if (!ahead) return true; + return metresBetween(nadir, ahead) > Math.max(1, height); + } + + /** Recompute the tile set for the current camera and reconcile primitives. */ + function refresh() { + const viewer = state.viewer; + if (!viewer || !state.context.isActive() || state.keyRequired) return; + if (state.statusKnown === false) return; + if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; + const ground = groundUnderCamera(viewer, { + groundAt: state.context.groundCaster?.groundAt, + }); + const cameraHeight = viewer.camera?.positionCartographic?.height; + const height = Number.isFinite(cameraHeight) + ? cameraHeight - (ground ?? 0) + : null; + const level = zoomFor(height); + const zoom = level?.zoom ?? null; + const kind = level?.kind ?? null; + // At street zooms the rays meet the ground where it really is (1,600 m up + // in Denver; Google 3D hides the globe) and stop short of the horizon. + const ranged = + kind === 'sequence' + ? { + groundHeight: ground, + maxRange: Math.max( + tuning.viewRangeMinM, + height * tuning.viewRangePerHeight, + ), + } + : {}; + // Tilted street views rank tiles along the line of sight, from the ground + // under the camera to the ground at the centre of the screen. + const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; + let bbox = visibleBbox(viewer, { + ...ranged, + // A tilted view's screen rows jump from the first metres to the horizon: + // keep the ground around the camera too. Looking down needs no help. + nearRange: focus && isTilted(focus, height) ? tuning.viewNearM : null, + }); + if (kind === 'overview' && (!bbox || zoom <= 1)) + bbox = [-180, -85, 180, 85]; + if (zoom == null || !bbox) { + state.coverage.hint = + zoom == null && Number.isFinite(height) && messages.tooHigh + ? messages.tooHigh + : 'Point the camera at the globe for street-level coverage'; + clear(); + notify(); + return; + } + state.coverage.hint = ''; + if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { + retire(); + state.coverage.zoom = zoom; + state.coverage.kind = kind; + } + const { tiles } = tilesForBbox(bbox, zoom, { + limit: kind === 'sequence' ? tuning.maxTiles : tuning.overviewMaxTiles, + focus: focus && { from: focus.nadir, to: focus.ahead }, + }); + const wanted = new Set(tiles.map(tileKey)); + for (const key of [...state.coverage.tiles.keys()]) + if (!wanted.has(key)) removeTile(key); + for (const [key, controller] of [...state.coverage.pending]) + if (!wanted.has(key)) { + // The aborted request no longer owns its key, so its `finally` will + // not settle it: settle it here. + controller.abort(); + state.coverage.pending.delete(key); + } + for (const tile of tiles) loadTile(tile, kind); + if (!state.coverage.pending.size) purgeStale(); + // The camera moved: cells that were out of the sampler's range may not be, + // and lines left draped by a failed, partial or dropped cast get another try. + if (terrainMode()) + for (const entry of state.coverage.tiles.values()) { + if (entry.castRequested) requestMesh(entry); + if (entry.drapedLines) castTile(entry, { retry: true }); + } + notify(); + } + + function scheduleRefresh() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs); + } + + function attach(viewer) { + detach(); + const remove = viewer.camera.changed.addEventListener(scheduleRefresh); + const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); + state.coverage.removeCameraListener = () => { + remove(); + removeEnd(); + }; + refresh(); + } + + function detach() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = null; + state.coverage.removeCameraListener?.(); + state.coverage.removeCameraListener = null; + } + + function clear() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + for (const key of [...state.coverage.tiles.keys()]) removeTile(key); + purgeStale(); + state.coverage.zoom = null; + state.coverage.kind = null; + clearTimeout(state.coverage.remeshTimer); + state.coverage.remeshTimer = null; + state.coverage.remeshDirty.clear(); + stopSelectionWatch(); + horizon.stop(); + requestRender(); + } + + /** Rebuild every loaded tile from its decoded cache (after a filter change). */ + function rebuild() { + purgeStale(); + for (const entry of state.coverage.tiles.values()) { + cancelCast(entry); + entry.castRequested = false; + entry.meshParts = null; + detachPrimitive(entry); + attachPrimitive(entry); + } + requestRender(); + notify(); + } + + /** + * Load every tile again (a source that filters on its server had its + * filter changed); the lines drawn now show until the new ones are in. + */ + function reload() { + retire(); + refresh(); + } + + /** + * Redraw tiles draped or cast for the new surface mode, then re-pick the + * zoom, since the ground height under the camera may have changed. + */ + function setSurface() { + if (!state.context.isActive()) return; + rebuild(); + refresh(); + } + + /** Recolour every part of a sequence in one ready primitive. */ + function recolorInstances(primitive, sequence, selected) { + const value = sequenceColor({ selected }); + for (const instanceId of partIds(sequence, pickPrefix)) { + try { + const attributes = primitive.getGeometryInstanceAttributes(instanceId); + if (attributes) + attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( + value, + attributes.color, + ); + } catch { + /* instance not in this primitive */ + } + } + } + + /** Every tile on the globe: the current zoom's and the stale zoom's. */ + function drawnEntries() { + return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; + } + + /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ + function drawnRecords(entry) { + const records = entry.primitives || []; + return entry.swap ? [...records, ...entry.swap.old] : records; + } + + /** Recolour one sequence, every part of it, in place (selection highlight). */ + function recolorSequence(id, selected) { + for (const entry of drawnEntries()) { + const sequence = entry.sequences.get(id); + if (!sequence) continue; + for (const record of drawnRecords(entry)) { + // Still building: `syncSelection` catches it up once it is ready. + if (!record.primitive.ready) continue; + recolorInstances(record.primitive, sequence, selected); + if (selected) record.selectedId = id; + else if (record.selectedId === id) record.selectedId = null; + } + } + requestRender(); + } + + /** + * Bring a ready primitive's highlight up to the current selection, when + * it was built with another one (or none). True when it changed. + */ + function syncSelection(entry, record) { + const current = state.sequence.selectedId ?? null; + if (record.selectedId === current || !record.primitive.ready) return false; + const previous = + record.selectedId && entry.sequences.get(record.selectedId); + if (previous) recolorInstances(record.primitive, previous, false); + const next = current && entry.sequences.get(current); + if (next) recolorInstances(record.primitive, next, true); + record.selectedId = current; + return true; + } + + /** + * A primitive cannot be recoloured until it is ready, so while any is + * building, catch each one up with the selection as it becomes ready. + */ + function watchSelection() { + const scene = state.viewer?.scene; + if (state.coverage.stopSelectionWatch || !scene?.postRender) return; + const stop = scene.postRender.addEventListener(() => { + let building = false; + let changed = false; + for (const entry of drawnEntries()) { + for (const record of entry.primitives || []) { + if (!record.primitive.ready) building = true; + else if (syncSelection(entry, record)) changed = true; + } + // Lines a swap still shows were ready (or never show): catch up only. + for (const record of entry.swap?.old || []) + if (syncSelection(entry, record)) changed = true; + } + if (changed) requestRender(); + if (!building) stopSelectionWatch(); + }); + state.coverage.stopSelectionWatch = stop; + } + + function stopSelectionWatch() { + state.coverage.stopSelectionWatch?.(); + state.coverage.stopSelectionWatch = null; + } + + /** + * Sequences (or overview points) drawn. A sequence that crosses a tile + * edge is in both tiles' lists, so sequences count once by id. + */ + function sequenceCount() { + const ids = new Set(); + let points = 0; + for (const entry of state.coverage.tiles.values()) + if (entry.kind === 'sequence') + for (const id of entry.sequences.keys()) ids.add(id); + else points += entry.count; + return ids.size + points; + } + + return { + attach, + detach, + refresh, + clear, + resetErrors, + rebuild, + reload, + setSurface, + recolorSequence, + sequenceCount, + }; +} diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs new file mode 100644 index 000000000..88308673c --- /dev/null +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createLineCoverage } from './lineCoverage.js'; +import { + coverageViewer, + tileCentre, +} from '../../testSupport/streetLevelFakes.mjs'; +import { until } from '../../testSupport/kartaViewFakes.mjs'; + +const TUNING = Object.freeze({ + maxTiles: 9, + overviewMaxTiles: 0, + maxSequences: 900, + lineWidthPx: 2, + overviewPointPx: 2, + moveDebounceMs: 10, + viewRangeMinM: 2_500, + viewRangePerHeight: 10, + viewNearM: 1_000, +}); + +/** + * Line coverage over one tile holding one line, on a viewer looking down from + * `height`. `fetchTile` answers with that line unless `fail` says otherwise. + */ +function setup( + t, + { zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {}, +) { + const centre = tileCentre(-121.4944, 38.5816); + const viewer = coverageViewer( + { lon: centre.lon, lat: centre.lat, height: 900 }, + t, + ); + const state = { + viewer, + services: {}, + keyRequired: false, + keyRejected: false, + context: { + getFilter: () => ({ pano: 'all', sinceMs: null }), + isActive: () => true, + notify() {}, + getSurface: () => 'draped', + groundCaster: null, + meshSampler: null, + }, + coverage: { + zoom: null, + kind: null, + tiles: new Map(), + stale: new Map(), + staleTimer: null, + pending: new Map(), + lastError: null, + holdUntil: 0, + holdTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: Promise.resolve(), + hint: '', + }, + sequence: { selectedId: null }, + }; + const asked = []; + const coverage = createLineCoverage({ + state, + fetchTile: async (tile) => { + asked.push(tile); + if (fail) throw fail; + return { + sequences: [ + { + id: 'line-1', + parts: [ + [ + [centre.lon - 0.001, centre.lat], + [centre.lon + 0.001, centre.lat], + ], + ], + capturedAt: 0, + isPano: false, + }, + ], + overview: [], + }; + }, + zoomFor: + zoomFor ?? + ((height) => + Number.isFinite(height) && height <= 6_000 + ? { zoom: 14, kind: 'sequence' } + : null), + colors: { coverage: '#635bff', selected: '#00e5ff' }, + pickPrefix: 'test:line:', + renderKey: 'test-coverage', + tuning: TUNING, + messages, + ...(passes ? { passes } : {}), + }); + t.after(() => { + coverage.clear(); + coverage.resetErrors(); + }); + return { viewer, state, coverage, asked }; +} + +test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => { + const tooHigh = 'zoom in to see test coverage'; + const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } }); + high.viewer.view.height = 7_000; + high.coverage.refresh(); + assert.equal(high.state.coverage.hint, tooHigh); + assert.equal(high.state.coverage.zoom, null); + assert.equal(high.asked.length, 0, 'nothing is asked from up there'); + const generic = setup(t); + generic.viewer.view.height = 7_000; + generic.coverage.refresh(); + assert.notEqual(generic.state.coverage.hint, tooHigh); + assert.match(generic.state.coverage.hint, /Point the camera/); +}); + +test('a provider’s passes() decides which lines are drawn and counted', async (t) => { + const all = setup(t, { passes: () => true }); + all.coverage.refresh(); + assert.ok(await until(() => all.state.coverage.tiles.size === 1)); + assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn'); + const none = setup(t, { passes: () => false }); + none.coverage.refresh(); + assert.ok(await until(() => none.state.coverage.tiles.size === 1)); + assert.equal(none.coverage.sequenceCount(), 0, 'filtered out'); +}); + +test('a refused key without its own message keeps the error it came with', async (t) => { + const refused = Object.assign(new Error('the source refused the key'), { + keyRejected: true, + }); + const { state, coverage } = setup(t, { fail: refused }); + coverage.refresh(); + assert.ok(await until(() => state.keyRejected === true)); + assert.equal(state.coverage.lastError, 'the source refused the key'); +}); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 6f310274f..84f9e2fc7 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -8,6 +8,7 @@ export const POSITION_PICK_ID = 'sl:pos'; export const PROVIDER_COLORS = Object.freeze({ mapillary: '#05cb63', google: '#4285f4', + kartaview: '#635bff', }); /** Colours every provider shares: the selection highlight and the marker. */ diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js new file mode 100644 index 000000000..bee93d76c --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -0,0 +1,176 @@ +import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js'; + +/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */ +export const MAX_TILE_SEGMENTS = 20_000; +/** A chained line stops growing here and the next one starts. */ +export const MAX_LINE_POINTS = 5_000; + +/** + * The way segments of a KartaView coverage tile + * (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines + * carry no sequence, date or projection: a filter thins them through a mask + * (see `maskedSegments`). + * @param {object} payload + * @returns {Array>} + */ +export function tileSegments(payload) { + // Not cached as empty: an unreadable tile is asked for again. + if (!Array.isArray(payload?.lines)) + throw new Error('KartaView sent an unreadable coverage tile'); + const segments = []; + for (const line of payload.lines) { + if (segments.length >= MAX_TILE_SEGMENTS) break; + const points = []; + for (const point of Array.isArray(line?.track) ? line.track : []) { + const lon = Number(point?.lng); + const lat = Number(point?.lat); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; + const last = points[points.length - 1]; + if (last && last[0] === lon && last[1] === lat) continue; + points.push([lon, lat]); + } + if (points.length >= 2) segments.push(points); + } + return segments; +} + +/** + * Segments chained into line records for the shared coverage: segments + * that meet end to start are joined, which cuts the line count about + * fourfold. + * @param {Array>} segments + * @param {{x: number, y: number, z: number}} address + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + */ +export function chainSegments(segments, { x, y, z }) { + const key = ([lon, lat]) => `${lon},${lat}`; + const startingAt = new Map(); + segments.forEach((segment, index) => { + const at = key(segment[0]); + const list = startingAt.get(at); + if (list) list.push(index); + else startingAt.set(at, [index]); + }); + const used = new Uint8Array(segments.length); + // Per start point, how far its list has been used: each segment is looked + // at once, so a tile of segments sharing one point stays linear. + const cursor = new Map(); + const nextFrom = (point) => { + const at = key(point); + const list = startingAt.get(at); + if (!list) return undefined; + let i = cursor.get(at) ?? 0; + while (i < list.length && used[list[i]]) i++; + cursor.set(at, i); + return list[i]; + }; + const sequences = []; + segments.forEach((segment, index) => { + if (used[index]) return; + used[index] = 1; + const chain = [...segment]; + for (;;) { + const next = nextFrom(chain[chain.length - 1]); + if (next === undefined) break; + // Full: the segment starts the next line instead. + if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break; + used[next] = 1; + chain.push(...segments[next].slice(1)); + } + sequences.push({ + id: `${z}/${x}/${y}/${sequences.length}`, + parts: [chain], + capturedAt: 0, + isPano: false, + }); + }); + return { sequences, overview: [] }; +} + +/** Decode a KartaView coverage tile into the shared coverage's line records. */ +export function decodeCoverageTile(payload, address) { + return chainSegments(tileSegments(payload), address); +} + +/** + * The segments a filtered PNG tile of the same address draws over. The PNG + * draws the same ways a few pixels wide (on a transparent ground): a + * segment is kept when at least half the points sampled along it have a + * drawn pixel within one pixel. Segments either lie on it or miss it + * almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33 + * between). A segment with no point on the tile is not this tile's to keep. + * @param {Array>} segments + * @param {{width: number, height: number, alpha: ArrayLike}} mask one alpha byte per pixel + * @param {{x: number, y: number, z: number}} address + */ +export function maskedSegments(segments, mask, { x, y, z }) { + const { width, height, alpha } = mask; + const n = 2 ** z; + const toPixel = ([lon, lat]) => { + const sin = Math.sin((lat * Math.PI) / 180); + const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); + return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height]; + }; + const drawnNear = (px, py) => { + const cx = Math.floor(px); + const cy = Math.floor(py); + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) { + const X = cx + dx; + const Y = cy + dy; + if (X < 0 || Y < 0 || X >= width || Y >= height) continue; + if (alpha[Y * width + X] > 0) return true; + } + return false; + }; + return segments.filter((segment) => { + let on = 0; + let tested = 0; + let [ax, ay] = toPixel(segment[0]); + for (let i = 1; i < segment.length; i++) { + const [bx, by] = toPixel(segment[i]); + const steps = Math.max( + 1, + Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX), + ); + // Each point once: a segment's next stretch starts where this one ends. + for (let j = i === 1 ? 0 : 1; j <= steps; j++) { + const px = ax + ((bx - ax) * j) / steps; + const py = ay + ((by - ay) * j) / steps; + if (px < 0 || py < 0 || px >= width || py >= height) continue; + tested++; + if (drawnNear(px, py)) on++; + } + ax = bx; + ay = by; + } + return tested > 0 && on / tested >= MASK_KEEP_SHARE; + }); +} + +/** + * A PNG mask's alpha, one byte per pixel, decoded off the main thread + * where the browser can. + * @param {Uint8Array} bytes the PNG + * @returns {Promise<{width: number, height: number, alpha: Uint8Array}>} + */ +export async function readMaskImage(bytes) { + const bitmap = await createImageBitmap( + new Blob([bytes], { type: 'image/png' }), + ); + try { + const { width, height } = bitmap; + const canvas = + typeof OffscreenCanvas === 'function' + ? new OffscreenCanvas(width, height) + : Object.assign(document.createElement('canvas'), { width, height }); + const context = canvas.getContext('2d', { willReadFrequently: true }); + context.drawImage(bitmap, 0, 0); + const rgba = context.getImageData(0, 0, width, height).data; + const alpha = new Uint8Array(width * height); + for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3]; + return { width, height, alpha }; + } finally { + bitmap.close?.(); + } +} diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs new file mode 100644 index 000000000..619719008 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MAX_LINE_POINTS, + MAX_TILE_SEGMENTS, + chainSegments, + decodeCoverageTile, + maskedSegments, + tileSegments, +} from './decode.js'; + +const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); +const TILE = { x: 4412, y: 6061, z: 14 }; + +test('segments that meet end to start chain into one line', () => { + const { sequences, overview } = decodeCoverageTile( + { + lines: [ + { track: [at(1, 1), at(2, 1)], matched: '1' }, + { track: [at(5, 5), at(6, 6)], matched: '1' }, + { track: [at(2, 1), at(3, 1), at(4, 2)], matched: '1' }, + ], + }, + TILE, + ); + assert.deepEqual(overview, []); + assert.equal(sequences.length, 2); + assert.deepEqual(sequences[0].parts, [ + [ + [1, 1], + [2, 1], + [3, 1], + [4, 2], + ], + ]); + assert.equal(sequences[0].id, '14/4412/6061/0'); + assert.equal(sequences[1].id, '14/4412/6061/1'); + // Nothing to date or project: every line passes as flat and undated. + assert.equal(sequences[0].capturedAt, 0); + assert.equal(sequences[0].isPano, false); +}); + +test('a junction continues one way only, and every segment is drawn exactly once', () => { + const { sequences } = decodeCoverageTile( + { + lines: [ + { track: [at(0, 0), at(1, 0)] }, + { track: [at(1, 0), at(2, 0)] }, + { track: [at(1, 0), at(1, 1)] }, + // A loop back to its own start must end, not spin. + { track: [at(9, 9), at(9, 8)] }, + { track: [at(9, 8), at(9, 9)] }, + ], + }, + TILE, + ); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + assert.equal(sequences.length, 3); + // 5 segments of 2 points, 2 joins in chains: 10 - 2 = 8 points. + assert.equal(points, 8); +}); + +test('bad points, repeats and one-point tracks are skipped; an empty tile has no lines', () => { + const { sequences } = decodeCoverageTile( + { + lines: [ + { track: [at(1, 1), at(1, 1), { lat: 'x', lng: '2' }, at(2, 2)] }, + { track: [at(3, 3)] }, + { track: null }, + null, + ], + }, + TILE, + ); + assert.equal(sequences.length, 1); + assert.deepEqual(sequences[0].parts[0], [ + [1, 1], + [2, 2], + ]); + assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []); +}); + +test('a tile that is not a line list fails, so it is asked for again rather than kept empty', () => { + for (const payload of [ + null, + {}, + { lines: 'nope' }, + { status: { apiCode: 690 } }, + ]) + assert.throws( + () => decodeCoverageTile(payload, TILE), + /unreadable coverage tile/, + ); +}); + +test('a tile whose segments all share one point decodes in linear time', () => { + // [P, Q_i, P]: every chain step looks at the same start point's list. + // 60k: the old quadratic chaining took over a second; capped and linear + // decoding takes tens of milliseconds. + const lines = Array.from({ length: 60_000 }, (_, i) => ({ + track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)], + })); + const started = performance.now(); + const { sequences } = decodeCoverageTile({ lines }, TILE); + const ms = performance.now() - started; + assert.ok( + ms < 1_000, + `${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`, + ); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS)); + assert.equal( + points, + MAX_TILE_SEGMENTS * 2 + sequences.length, + 'every segment drawn once', + ); +}); + +test('a tile is capped in segments and in points per line', () => { + const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({ + track: [at(i, 0), at(i + 1, 0)], + })); + const { sequences } = decodeCoverageTile({ lines }, TILE); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + // One long chain, cut into lines of at most MAX_LINE_POINTS. + assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS)); + assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length); + assert.ok( + sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS), + ); +}); + +/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */ +function lonLatAt(px, py, { x, y, z } = TILE, size = 512) { + const n = 2 ** z; + const lon = ((x + px / size) / n) * 360 - 180; + const my = Math.PI * (1 - (2 * (y + py / size)) / n); + const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI; + return [lon, lat]; +} + +/** A 512 px mask drawn on the given rows. */ +function rowsMask(rows) { + const alpha = new Uint8Array(512 * 512); + for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512); + return { width: 512, height: 512, alpha }; +} + +test('a mask keeps the segments it draws over, chained as before', () => { + const along = (row, from = 40, to = 470) => [ + lonLatAt(from, row), + lonLatAt(to, row), + ]; + const segments = [ + along(100), // drawn at row 100 + along(101.6), // a pixel off: still drawn within one pixel + along(300), // not drawn + // Two thirds along the drawn row, then off it: kept (at least half). + [lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)], + // A third along it, then off: dropped. + [lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)], + // Off the tile entirely: not this tile's to keep. + [lonLatAt(-200, 100), lonLatAt(-20, 100)], + ]; + const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE); + assert.deepEqual(kept, [segments[0], segments[1], segments[3]]); + assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []); + // A full mask keeps every segment that is on the tile. + assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5); +}); + +test('decoding is segments then chaining: the same lines either way', () => { + const payload = { + lines: [ + { track: [at(1, 1), at(2, 1)] }, + { track: [at(2, 1), at(3, 1)] }, + { track: [at(5, 5), at(6, 6)] }, + ], + }; + const segments = tileSegments(payload); + assert.equal(segments.length, 3); + assert.deepEqual( + chainSegments(segments, TILE), + decodeCoverageTile(payload, TILE), + ); + assert.throws(() => tileSegments({}), /unreadable coverage tile/); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js new file mode 100644 index 000000000..ae05983a4 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -0,0 +1,649 @@ +import { createLineCoverage } from '../../lineCoverage.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { metresBetween } from '../../view.js'; +import { + chainSegments, + maskedSegments, + readMaskImage, + tileSegments, +} from './decode.js'; +import { createKartaViewSource } from './source.js'; +import { createKartaViewViewer } from './viewer.js'; +import { + ARCHIVED_MESSAGE, + COLORS, + COVERAGE_MAX_HEIGHT_M, + COVERAGE_NOUN, + COVERAGE_STREET_ZOOM_BELOW_M, + COVERAGE_TUNING, + FILTERED_OUT_MESSAGE, + KARTAVIEW_CREDIT_HTML, + KARTAVIEW_LABEL, + KARTAVIEW_NAME, + KARTAVIEW_PROVIDER_ID, + LINE_CLICK_HINT, + NEAREST_PROBES, + NEAREST_RADIUS_M, + NO_COVERAGE_HINT, + PICK_PREFIX, + RATE_LIMITED_MESSAGE, + SEQUENCE_PAGE_EDGE, + SEQUENCE_PAGE_SIZE, + TOO_HIGH_HINT, + assignCourses, + cameraBearing, + normalizePhoto, + passesKartaViewFilter, + tileFilterQuery, +} from './policy.js'; + +/** Photos kept in memory by id (sequence pages fill it). */ +const PHOTO_CACHE_SIZE = 4000; +/** Sequence pages kept in memory. */ +const PAGE_CACHE_SIZE = 12; +/** Decoded coverage tiles kept, and for how long (masks too). */ +const TILE_CACHE_SIZE = 24; +const TILE_CACHE_TTL_MS = 10 * 60_000; + +/** z14 near the street, z13 a little higher, nothing above. */ +export function kartaViewZoomFor(heightM) { + if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null; + return { + zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14, + kind: 'sequence', + }; +} + +/** Least-recently-used map: reading an entry renews it. */ +function createLru(limit) { + const map = new Map(); + return { + get(key) { + if (!map.has(key)) return undefined; + const value = map.get(key); + map.delete(key); + map.set(key, value); + return value; + }, + set(key, value) { + map.delete(key); + map.set(key, value); + while (map.size > limit) map.delete(map.keys().next().value); + }, + delete: (key) => map.delete(key), + clear: () => map.clear(), + }; +} + +/** + * Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView + * sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so + * a pan back or a zoom across z13/z14 would fetch them all again. + */ +export function createTileCache({ + load, + size = TILE_CACHE_SIZE, + ttlMs = TILE_CACHE_TTL_MS, + now = Date.now, + keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`, +}) { + const kept = createLru(size); + return { + async get(tile, options) { + const key = keyOf(tile); + const entry = kept.get(key); + if (entry && now() - entry.at < ttlMs) return entry.tile; + const loaded = await load(tile, options); + kept.set(key, { at: now(), tile: loaded }); + return loaded; + }, + /** Drop every tile (the provider was switched off). */ + clear: () => kept.clear(), + }; +} + +/** The 1-based page of a sequence that holds `index`, if no photo is missing. */ +function pageOf(index) { + return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1; +} + +/** + * KartaView Street Level provider: keyless coverage lines from KartaView's + * tile API, the photos nearest a clicked line, cones along the photo's + * sequence, and a flat or 360° viewer with ‹ › along the sequence. + * @param {{source?: ReturnType, viewerOptions?: object, readMask?: typeof readMaskImage}} [options] + * @returns {import('../../registry.js').StreetLevelProvider} + */ +export function createKartaViewProvider({ + source = createKartaViewSource(), + viewerOptions = {}, + readMask = readMaskImage, +} = {}) { + return Object.freeze({ + id: KARTAVIEW_PROVIDER_ID, + name: KARTAVIEW_NAME, + label: KARTAVIEW_LABEL, + requiresKeyId: null, + pickPrefix: PICK_PREFIX.root, + colors: COLORS, + // Its own marker, as Street View has: a purple cone that turns with the view. + marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), + credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }), + defaultOn: false, + // 360° / flat and SINCE, as Mapillary: KartaView filters the lines on + // its server (see tileFilterQuery), the photos by their own record. + filters: Object.freeze(['pano', 'since']), + // SINCE is the upload date for KartaView: the panel notes it. + sinceBasis: 'upload', + // Its lines are streets, not sequences: a selected drive shows as cones. + highlightsSequence: false, + coverageLabel: Object.freeze({ + noun: COVERAGE_NOUN, + action: LINE_CLICK_HINT, + }), + + create(context) { + const state = { + context, + services: context.services, + viewer: null, + keyRequired: false, + keyRejected: false, + statusKnown: true, + coverage: { + zoom: null, + tiles: new Map(), + stale: new Map(), + staleTimer: null, + pending: new Map(), + lastError: null, + holdUntil: 0, + holdTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', + kind: null, + }, + sequence: { + selectedId: null, + images: [], + cache: new Map(), + collection: null, + loading: false, + abort: null, + }, + }; + const photos = createLru(PHOTO_CACHE_SIZE); + const pages = createLru(PAGE_CACHE_SIZE); + /** The photo last opened in each sequence: its cones centre on it. */ + const anchors = new Map(); + /** The sequence page the cones were drawn from. */ + let drawnPage = null; + + /** A record as a photo; the one already known keeps its identity (and course). */ + function remember(record) { + const fresh = normalizePhoto(record); + if (!fresh) return null; + const known = photos.get(fresh.id); + const photo = known ? Object.assign(known, fresh) : fresh; + photos.set(photo.id, photo); + return photo; + } + + async function getPhoto(photoId, { signal } = {}) { + const cached = photos.get(photoId); + if (cached) return cached; + const photo = remember(await source.getPhoto(photoId, { signal })); + if (!photo) throw new Error('KartaView has no position for this photo'); + return photo; + } + + /** A page of a sequence's photos, in index order (shared while in flight). */ + function loadPage(sequenceId, page) { + const key = `${sequenceId}:${page}`; + let entry = pages.get(key); + if (!entry) { + entry = source + .getSequencePhotos(sequenceId, { + page, + itemsPerPage: SEQUENCE_PAGE_SIZE, + }) + .then(({ photos: records, hasMore }) => { + const list = assignCourses( + records + .map(remember) + .filter(Boolean) + .sort( + (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), + ), + metresBetween, + ); + // The open photo may have just learnt which way it looked. The + // page is good whatever the viewer is doing. + try { + viewer.refreshPose?.(); + } catch { + /* the next pose carries the direction */ + } + return { page, hasMore, photos: list }; + }); + pages.set(key, entry); + entry.catch(() => pages.delete(key)); + } + return entry; + } + + /** + * The page holding `photo`. Pages follow photo ids, which only roughly + * follow the index, and photos go missing, so the page the index + * suggests is a first guess: step towards the photo until a page holds + * it, or the pages straddle where it would be. + */ + async function homePage(photo) { + let number = pageOf(photo.sequenceIndex ?? 0); + const index = photo.sequenceIndex ?? 0; + let page = await loadPage(photo.sequenceId, number); + if (!page.photos.length && number > 1) { + // Past the end (many photos deleted): find the last page by halving. + let low = 1; + let high = number - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ((await loadPage(photo.sequenceId, middle)).photos.length) + low = middle; + else high = middle - 1; + } + number = low; + } + const seen = new Set(); + while (!seen.has(number) && seen.size < 8) { + seen.add(number); + page = await loadPage(photo.sequenceId, number); + if (page.photos.some((p) => p.id === photo.id)) return page; + const first = page.photos[0]?.sequenceIndex; + const last = page.photos.at(-1)?.sequenceIndex; + // Past the last page, or before this page's photos: go back. + if ((first === undefined || index < first) && number > 1) number--; + else if (last !== undefined && index > last && page.hasMore) number++; + else break; + } + return page; + } + + /** + * The photos around `photo` in index order: its page, plus a + * neighbour page when it sits near an edge (pages overlap in index). + * @returns {Promise<{home: number, photos: Array}>} + */ + async function windowOf(photo) { + const home = await homePage(photo); + const list = home.photos; + const found = list.findIndex((p) => p.id === photo.id); + // A photo no page lists sits where its index would. + const at = + found !== -1 + ? found + : list.filter( + (p) => (p.sequenceIndex ?? 0) < (photo.sequenceIndex ?? 0), + ).length; + const parts = [list]; + if (at < SEQUENCE_PAGE_EDGE && home.page > 1) + parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos); + if (at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore) + parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos); + if (parts.length === 1) return { home: home.page, photos: list }; + const byId = new Map(parts.flat().map((p) => [p.id, p])); + return { + home: home.page, + photos: [...byId.values()].sort( + (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), + ), + }; + } + + /** + * Photo id → whether its file is there (archived ones are not). Only + * a definite answer is kept: a storage hiccup is asked again next + * time, and meanwhile the photo is tried. A probe is shared, so it + * runs to its own timeout rather than any one caller's abort. + */ + const probes = createLru(PHOTO_CACHE_SIZE); + function showable(photo) { + if (!photo?.urls?.view) return Promise.resolve(false); + let probe = probes.get(photo.id); + if (!probe) { + probe = source.probeImage(photo.urls.view).then( + (answer) => { + if (answer === null) probes.delete(photo.id); + return answer !== false; + }, + () => { + probes.delete(photo.id); + return true; + }, + ); + probes.set(photo.id, probe); + } + return probe; + } + + // A tile's way segments, kept a while: panning back, or a filter + // change, asks KartaView nothing more for them. + const tiles = createTileCache({ + load: async (tile, { signal }) => + tileSegments(await source.getTile(tile, { signal })), + }); + // A filtered tile's mask, by filter and tile. + const masks = createTileCache({ + load: async ({ query, ...tile }, { signal }) => + readMask(await source.getTileMask(tile, query, { signal })), + keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`, + }); + /** The tile's lines under the filter now: all, or the masked ones. */ + async function fetchTile(tile, options) { + const query = tileFilterQuery(context.getFilter()); + if (!query) return chainSegments(await tiles.get(tile, options), tile); + const [segments, mask] = await Promise.all([ + tiles.get(tile, options), + masks.get({ ...tile, query }, options), + ]); + return chainSegments(maskedSegments(segments, mask, tile), tile); + } + /** The query the drawn lines were loaded under. */ + let drawnQuery = tileFilterQuery(context.getFilter()); + + /** + * The next showable photo `delta` along the sequence, or null at its + * end. Archived photos are skipped, a few at most. + */ + async function step(photo, delta) { + if (!photo?.sequenceId) return null; + let from = photo; + for (let skipped = 0; skipped <= NEAREST_PROBES; skipped++) { + const { photos: list } = await windowOf(from); + let at = list.findIndex((p) => p.id === from.id); + if (at === -1) { + // Not found anywhere: the nearest index on the right side. + const index = from.sequenceIndex ?? 0; + const later = list.findIndex((p) => (p.sequenceIndex ?? 0) > index); + if (delta > 0 && later === -1) return null; + at = + delta > 0 + ? later - 1 + : later === -1 + ? list.length + : list.findIndex((p) => (p.sequenceIndex ?? 0) >= index); + } + const next = list[at + delta]; + if (!next) return null; + if (await showable(next)) return next.id; + from = next; + } + return null; + } + + const coverage = createLineCoverage({ + state, + fetchTile, + zoomFor: kartaViewZoomFor, + colors: COLORS, + pickPrefix: PICK_PREFIX.line, + renderKey: 'kartaview-coverage', + tuning: COVERAGE_TUNING, + messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT }, + // Filtered on the way in (fetchTile): a line has no date to test. + passes: () => true, + }); + + const cones = createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + let anchor = anchors.get(sequenceId); + if (!anchor) return []; + let window = await windowOf(anchor); + signal?.throwIfAborted(); + // Another photo of the drive opened while that page loaded (its + // re-centre found nothing drawn yet): centre on the newest one. + while (anchors.get(sequenceId) !== anchor) { + anchor = anchors.get(sequenceId); + window = await windowOf(anchor); + signal?.throwIfAborted(); + } + drawnPage = { sequenceId, page: window.home }; + countPhotos(sequenceId); + return thinImages( + window.photos.map((photo) => ({ + id: photo.id, + lon: photo.lon, + lat: photo.lat, + compassAngle: cameraBearing(photo) ?? 0, + capturedAt: photo.capturedAt ?? 0, + uploadedAt: photo.uploadedAt, + isPano: photo.isPano, + })), + ); + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:kartaview-cones', + renderKey: 'kartaview-sequence', + // By projection and upload date, as the lines. + passes: passesKartaViewFilter, + }); + + /** Sequence id → its photo count from KartaView (asked once). */ + const counts = new Map(); + function countPhotos(sequenceId) { + if (counts.has(sequenceId) || !source.getSequenceCount) return; + counts.set(sequenceId, undefined); + source.getSequenceCount(sequenceId).then( + (count) => { + if (count === null) counts.delete(sequenceId); + else { + counts.set(sequenceId, count); + context.notify(); + } + }, + () => counts.delete(sequenceId), + ); + } + + /** ‹ › open through the core (loading, errors, cones), camera still. */ + function openStepped(photoId) { + context.actions.openImage(photoId, { frame: false }); + } + + /** + * The cones show one page of a sequence: once a photo of the selected + * sequence opens on another page (‹ ›, a cone, a line), re-centre them. + */ + async function recentreCones(photo) { + if (!photo?.sequenceId) return; + if (state.sequence.selectedId !== photo.sequenceId) return; + const drawn = drawnPage; + if (drawn?.sequenceId !== photo.sequenceId) return; + const window = await windowOf(photo).catch(() => null); + // Still the drawn page and the latest photo once the page is in. + if ( + window && + drawnPage === drawn && + window.home !== drawn.page && + anchors.get(photo.sequenceId) === photo + ) + cones.select(photo.sequenceId, { force: true }); + } + + const viewer = createKartaViewViewer({ + getPhoto: async (photoId) => { + const photo = await getPhoto(photoId); + if (photo.sequenceId) anchors.set(photo.sequenceId, photo); + return photo; + }, + step, + onStep: openStepped, + onShown: recentreCones, + // After a GPU reset the same photo is drawn again by the viewer + // itself: going through the core would cancel a lookup the user + // started. A failure still shows. + onReopen: (photoId) => + viewer + .open(photoId) + .catch((error) => + context.actions.reportError( + error?.message || 'The KartaView photo could not be loaded', + ), + ), + render: context.services?.render, + ...viewerOptions, + }); + + return { + async status() { + return { configured: true }; + }, + + init(cesiumViewer) { + state.viewer = cesiumViewer; + cones.ensureCollections(cesiumViewer); + cones.setVisible(false); + }, + + activate(cesiumViewer) { + state.viewer = cesiumViewer; + cones.setVisible(true); + coverage.attach(cesiumViewer); + }, + + deactivate() { + coverage.detach(); + coverage.clear(); + coverage.resetErrors(); + cones.clearSelection(); + cones.setVisible(false); + drawnPage = null; + // Tens of MB of decoded lines are not kept for a source left off. + tiles.clear(); + masks.clear(); + }, + + destroy(cesiumViewer) { + coverage.detach(); + coverage.clear(); + coverage.resetErrors(); + cones.destroy(cesiumViewer); + tiles.clear(); + masks.clear(); + }, + + refreshCoverage: () => coverage.refresh(), + + /** + * The filter changed: the lines come from KartaView again under it + * (the old ones show meanwhile), the cones are drawn again. + */ + setFilter() { + const query = tileFilterQuery(context.getFilter()); + if (query !== drawnQuery) { + drawnQuery = query; + coverage.reload(); + } + cones.rerender(); + }, + + setSurface() { + coverage.setSurface(); + cones.rerender(); + }, + + coverageStats() { + const { hint } = state.coverage; + const count = coverage.sequenceCount(); + const loading = state.coverage.pending.size > 0; + const empty = + state.coverage.zoom !== null && + !loading && + count === 0 && + !state.coverage.lastError; + return { + count, + zoom: state.coverage.zoom, + kind: state.coverage.kind, + loading, + hint: hint || (empty ? NO_COVERAGE_HINT : ''), + error: state.coverage.lastError, + keyRequired: false, + keyRejected: false, + }; + }, + + /** A line opens the photo nearest the click; a cone opens its photo. */ + handlePick(pickId, { groundPoint } = {}) { + if (pickId.startsWith(PICK_PREFIX.image)) { + context.actions.openImage(pickId.slice(PICK_PREFIX.image.length)); + return true; + } + if (pickId.startsWith(PICK_PREFIX.line)) { + const point = groundPoint?.(); + if (point) context.actions.openNearest(point); + return true; + } + return false; + }, + + // Never from the cache: it is keyed by sequence, the cones by page. + selectSequence: (sequenceId) => + cones.select(sequenceId, { force: true }), + clearSequence: () => { + cones.clearSelection(); + drawnPage = null; + }, + // The drive's own count, not the thinned page of it the cones show. + sequenceStats: () => ({ + selectedId: state.sequence.selectedId, + images: + counts.get(state.sequence.selectedId) ?? + state.sequence.images.length, + loading: state.sequence.loading, + }), + + /** + * The nearest photo within the search radius that can be shown, or + * null. Many photos are archived (their files answer 409) or have no + * file at all, so the nearest few are tried in turn. + */ + async nearestImage({ lat, lon }, { signal } = {}) { + const filter = context.getFilter(); + const records = await source.nearestPhotos( + { lat, lon, filter }, + { signal }, + ); + const near = []; + let filteredOut = 0; + for (const record of records) { + const photo = remember(record); + if (!photo?.urls.view) continue; + const metres = metresBetween({ lat, lon }, photo); + if (metres > NEAREST_RADIUS_M) continue; + if (!passesKartaViewFilter(photo, filter)) filteredOut++; + else near.push({ photo, metres }); + } + near.sort((a, b) => a.metres - b.metres); + // Probed side by side (photo storage is not the API host): the + // nearest one that answers wins. + const candidates = near.slice(0, NEAREST_PROBES); + const answers = await Promise.all( + candidates.map(({ photo }) => showable(photo, { signal })), + ); + const hit = candidates.find((_, i) => answers[i]); + if (hit) return hit.photo.id; + if (near.length) throw new Error(ARCHIVED_MESSAGE); + if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE); + return null; + }, + + viewer, + }; + }, + }); +} diff --git a/src/layers/streetLevel/providers/kartaview/panorama.js b/src/layers/streetLevel/providers/kartaview/panorama.js new file mode 100644 index 000000000..50f348a9b --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/panorama.js @@ -0,0 +1,243 @@ +/** + * A minimal equirectangular panorama renderer on WebGL: one full-screen + * triangle whose fragment shader turns each pixel's view ray into texture + * coordinates. The image centre is the camera's forward direction; yaw turns + * right from it, pitch looks up. + */ + +export const PANO_FOV_DEFAULT_DEG = 80; +export const PANO_FOV_MIN_DEG = 30; +export const PANO_FOV_MAX_DEG = 100; +export const PANO_PITCH_LIMIT_DEG = 85; + +const VERTEX = ` +attribute vec2 a_position; +varying vec2 v_ndc; +void main() { + v_ndc = a_position; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + +const FRAGMENT = ` +precision highp float; +uniform sampler2D u_image; +uniform float u_yaw; +uniform float u_pitch; +uniform float u_tanHalfFov; +uniform float u_aspect; +varying vec2 v_ndc; +const float PI = 3.141592653589793; +void main() { + vec3 ray = normalize(vec3(v_ndc.x * u_tanHalfFov * u_aspect, v_ndc.y * u_tanHalfFov, -1.0)); + float cp = cos(u_pitch), sp = sin(u_pitch); + ray = vec3(ray.x, ray.y * cp - ray.z * sp, ray.y * sp + ray.z * cp); + float cy = cos(u_yaw), sy = sin(u_yaw); + ray = vec3(ray.x * cy - ray.z * sy, ray.y, ray.x * sy + ray.z * cy); + float lon = atan(ray.x, -ray.z); + float lat = asin(clamp(ray.y, -1.0, 1.0)); + gl_FragColor = texture2D(u_image, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI)); +}`; + +/** Wrap degrees into [0, 360). */ +export function wrapDegrees(value) { + return ((value % 360) + 360) % 360; +} + +export function clampPitch(degrees) { + return Math.max( + -PANO_PITCH_LIMIT_DEG, + Math.min(PANO_PITCH_LIMIT_DEG, degrees), + ); +} + +export function clampFov(degrees) { + return Math.max(PANO_FOV_MIN_DEG, Math.min(PANO_FOV_MAX_DEG, degrees)); +} + +/** + * Texture coordinates the shader samples for a view ray, for tests and + * picking: `yaw`/`pitch` in degrees, the ray in camera space (x right, y up, + * z backward). + */ +export function rayToUv(ray, { yaw = 0, pitch = 0 } = {}) { + const p = (pitch * Math.PI) / 180; + const w = (yaw * Math.PI) / 180; + const length = Math.hypot(ray[0], ray[1], ray[2]) || 1; + let [x, y, z] = ray.map((v) => v / length); + [y, z] = [ + y * Math.cos(p) - z * Math.sin(p), + y * Math.sin(p) + z * Math.cos(p), + ]; + [x, z] = [ + x * Math.cos(w) - z * Math.sin(w), + x * Math.sin(w) + z * Math.cos(w), + ]; + const lon = Math.atan2(x, -z); + const lat = Math.asin(Math.max(-1, Math.min(1, y))); + return [0.5 + lon / (2 * Math.PI), 0.5 - lat / Math.PI]; +} + +function compile(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Panorama shader failed: ${log}`); + } + return shader; +} + +const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0; + +/** + * A panorama view on `canvas`. Throws when WebGL is unavailable, so the + * caller can fall back to a flat picture. + * @param {HTMLCanvasElement} canvas + * @returns {{setImage: (image: TexImageSource) => void, setView: (view: {yaw?: number, pitch?: number, fov?: number}) => void, getView: () => {yaw: number, pitch: number, fov: number}, resize: () => void, render: () => void, hasImage: () => boolean, destroy: () => void}} + */ +export function createPanoramaRenderer(canvas) { + const gl = + canvas.getContext('webgl', { + antialias: false, + depth: false, + preserveDrawingBuffer: false, + }) || canvas.getContext('experimental-webgl'); + if (!gl) throw new Error('WebGL is unavailable'); + + const program = gl.createProgram(); + gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX)); + gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) + throw new Error('Panorama program failed to link'); + gl.useProgram(program); + + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + // One triangle covering the screen. + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + gl.STATIC_DRAW, + ); + const position = gl.getAttribLocation(program, 'a_position'); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + const uniforms = Object.fromEntries( + ['u_image', 'u_yaw', 'u_pitch', 'u_tanHalfFov', 'u_aspect'].map((name) => [ + name, + gl.getUniformLocation(program, name), + ]), + ); + const texture = gl.createTexture(); + let loaded = false; + let frame = 0; + const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }; + + /** Match the drawing buffer to the canvas's laid-out size. */ + function syncSize() { + const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); + const width = Math.max(1, Math.round(canvas.clientWidth * ratio)); + const height = Math.max(1, Math.round(canvas.clientHeight * ratio)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + } + + function resize() { + syncSize(); + scheduleRender(); + } + + function draw() { + frame = 0; + if (gl.isContextLost()) return; + // Every frame: the canvas may have been laid out since the last one. + syncSize(); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + if (!loaded) return; + gl.uniform1i(uniforms.u_image, 0); + gl.uniform1f(uniforms.u_yaw, (view.yaw * Math.PI) / 180); + gl.uniform1f(uniforms.u_pitch, (view.pitch * Math.PI) / 180); + gl.uniform1f(uniforms.u_tanHalfFov, Math.tan((view.fov * Math.PI) / 360)); + gl.uniform1f(uniforms.u_aspect, canvas.width / Math.max(1, canvas.height)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + function scheduleRender() { + if (frame) return; + frame = requestAnimationFrame(draw); + } + + return { + setImage(image) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + // RGBA: the format a decoded ImageBitmap uploads without conversion. + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + image, + ); + // The pixels are on the GPU now; a bitmap's copy can go. + image.close?.(); + // Linear only: a mipmapped lookup draws a seam where longitude wraps. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + const repeat = + isPowerOfTwo(image.width) && isPowerOfTwo(image.height) + ? gl.REPEAT + : gl.CLAMP_TO_EDGE; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + loaded = true; + resize(); + scheduleRender(); + }, + setView(next = {}) { + if (Number.isFinite(next.yaw)) view.yaw = wrapDegrees(next.yaw); + if (Number.isFinite(next.pitch)) view.pitch = clampPitch(next.pitch); + if (Number.isFinite(next.fov)) view.fov = clampFov(next.fov); + scheduleRender(); + }, + getView: () => ({ ...view }), + resize, + render: scheduleRender, + hasImage: () => loaded, + clear() { + loaded = false; + // Hand the 32 MB texture back: a closed viewer keeps only 1×1. + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array(4), + ); + scheduleRender(); + }, + destroy() { + if (frame) cancelAnimationFrame(frame); + frame = 0; + loaded = false; + gl.deleteTexture(texture); + gl.deleteBuffer(buffer); + gl.deleteProgram(program); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + }, + }; +} diff --git a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs new file mode 100644 index 000000000..5bb6380d1 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + PANO_FOV_MAX_DEG, + PANO_FOV_MIN_DEG, + PANO_PITCH_LIMIT_DEG, + clampFov, + clampPitch, + createPanoramaRenderer, + rayToUv, + wrapDegrees, +} from './panorama.js'; + +const close = (actual, expected) => + actual.forEach((value, i) => + assert.ok(Math.abs(value - expected[i]) < 1e-9, `${actual} ≉ ${expected}`), + ); + +test('straight ahead samples the image centre; yaw turns right, pitch looks up', () => { + close(rayToUv([0, 0, -1]), [0.5, 0.5]); + close(rayToUv([0, 0, -1], { yaw: 90 }), [0.75, 0.5]); + close(rayToUv([0, 0, -1], { yaw: -90 }), [0.25, 0.5]); + close(rayToUv([0, 0, -1], { pitch: 45 }), [0.5, 0.25]); + close(rayToUv([0, 0, -1], { pitch: -90 }), [0.5, 1]); + // Pitch first, then yaw: looking up-right stays up. + close(rayToUv([0, 0, -1], { yaw: 90, pitch: 45 }), [0.75, 0.25]); + // A ray to the right of the screen is the same as turning right. + close(rayToUv([1, 0, 0]), [0.75, 0.5]); +}); + +test('view limits: pitch stops short of the poles, field of view stays usable', () => { + assert.equal(clampPitch(120), PANO_PITCH_LIMIT_DEG); + assert.equal(clampPitch(-120), -PANO_PITCH_LIMIT_DEG); + assert.equal(clampFov(5), PANO_FOV_MIN_DEG); + assert.equal(clampFov(170), PANO_FOV_MAX_DEG); + assert.equal(wrapDegrees(-30), 330); + assert.equal(wrapDegrees(725), 5); +}); + +/** + * A WebGL stand-in that records draw calls and uniform values; `lost` makes + * it report a lost context. + */ +function stubGl() { + const gl = { + lost: false, + calls: [], + uniforms: {}, + isContextLost: () => gl.lost, + getShaderParameter: () => true, + getProgramParameter: () => true, + getUniformLocation: (program, name) => name, + getExtension: () => null, + uniform1f: (name, value) => (gl.uniforms[name] = value), + }; + return new Proxy(gl, { + get(target, key) { + if (key in target) return target[key]; + if (typeof key === 'string' && /^[A-Z_0-9]+$/.test(key)) return key; + return (...args) => { + target.calls.push(key); + return { key, args }; + }; + }, + }); +} + +/** A panorama on a stub canvas, with frames run when the test says. */ +function stubPanorama(t, { width = 400, height = 250 } = {}) { + const gl = stubGl(); + const canvas = { + clientWidth: width, + clientHeight: height, + width: 300, + height: 150, + getContext: () => gl, + }; + const frames = []; + const saved = { + request: globalThis.requestAnimationFrame, + cancel: globalThis.cancelAnimationFrame, + }; + globalThis.requestAnimationFrame = (callback) => frames.push(callback); + globalThis.cancelAnimationFrame = () => {}; + t.after(() => { + globalThis.requestAnimationFrame = saved.request; + globalThis.cancelAnimationFrame = saved.cancel; + }); + const renderer = createPanoramaRenderer(canvas); + const runFrames = () => { + while (frames.length) frames.shift()(); + }; + return { gl, canvas, renderer, runFrames }; +} + +const IMAGE = { width: 1024, height: 512 }; + +test('a panorama draws once it has an image; looking up and right are positive angles', (t) => { + const { gl, renderer, runFrames } = stubPanorama(t); + renderer.render(); + runFrames(); + assert.equal(gl.calls.includes('drawArrays'), false, 'nothing to draw yet'); + renderer.setImage(IMAGE); + renderer.setView({ yaw: 30, pitch: 30 }); + runFrames(); + assert.ok(gl.calls.includes('drawArrays')); + assert.ok(gl.uniforms.u_pitch > 0, `u_pitch ${gl.uniforms.u_pitch}`); + assert.ok(Math.abs(gl.uniforms.u_pitch - Math.PI / 6) < 1e-9); + assert.ok(Math.abs(gl.uniforms.u_yaw - Math.PI / 6) < 1e-9); + renderer.setView({ pitch: -20 }); + runFrames(); + assert.ok(gl.uniforms.u_pitch < 0, 'looking down is negative'); +}); + +test('a lost WebGL context draws nothing', (t) => { + const { gl, renderer, runFrames } = stubPanorama(t); + renderer.setImage(IMAGE); + gl.lost = true; + gl.calls.length = 0; + runFrames(); + assert.deepEqual( + gl.calls.filter((call) => + ['viewport', 'clear', 'drawArrays'].includes(call), + ), + [], + ); +}); + +test('the canvas is measured again when it draws, not only when its image arrived', (t) => { + // The image arrives while the canvas is still hidden (0×0)… + const { canvas, renderer, runFrames } = stubPanorama(t, { + width: 0, + height: 0, + }); + renderer.setImage(IMAGE); + assert.deepEqual([canvas.width, canvas.height], [1, 1]); + // …and is laid out before the frame draws. + canvas.clientWidth = 400; + canvas.clientHeight = 250; + runFrames(); + assert.deepEqual([canvas.width, canvas.height], [400, 250]); +}); + +test('an image uploads as RGBA and its bitmap is released; closing hands the texture back as 1×1', (t) => { + const { gl, renderer } = stubPanorama(t); + const uploads = []; + gl.texImage2D = (...args) => uploads.push(args); + let closed = false; + renderer.setImage({ + width: 2048, + height: 1024, + close: () => (closed = true), + }); + assert.equal(uploads.length, 1); + assert.equal( + uploads[0][2], + 'RGBA', + 'the format a decoded bitmap uploads without conversion', + ); + assert.equal(closed, true, 'the decoded copy is freed once on the GPU'); + renderer.clear(); + const last = uploads.at(-1); + assert.deepEqual( + [last[3], last[4]], + [1, 1], + 'a closed viewer keeps a 1×1 texture', + ); + assert.equal(renderer.hasImage(), false); +}); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js new file mode 100644 index 000000000..8d2c955ae --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -0,0 +1,397 @@ +import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; + +export { NEAREST_RADIUS_M } from '../../policy.js'; + +/** Identity and tuning for the KartaView street-level provider (keyless). */ +export const KARTAVIEW_PROVIDER_ID = 'kartaview'; +export const KARTAVIEW_NAME = 'KartaView'; +export const KARTAVIEW_LABEL = 'KARTAVIEW'; + +/** + * The public API. Its documented twin, api.openstreetcam.org, still answers + * but serves empty coverage tiles. + */ +export const KARTAVIEW_API_HOST = 'https://api.kartaview.org'; +/** KartaView's image proxy: resizes a photo given its base64url address. */ +export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; + +/** Stable id prefixes for picked primitives; every one starts with `kv:`. */ +export const PICK_PREFIX = Object.freeze({ + root: 'kv:', + line: 'kv:line:', + image: 'kv:img:', +}); + +export const COLORS = Object.freeze({ + coverage: PROVIDER_COLORS.kartaview, + selected: SHARED_COLORS.selected, +}); + +/** + * Coverage lines are OSM ways with photos, from z13–14 JSON tiles. Lower + * zooms take seconds per tile (z10 does not answer), so higher up the panel + * asks the user to zoom in. + */ +export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500; +export const COVERAGE_MAX_HEIGHT_M = 6_000; +export const COVERAGE_TUNING = Object.freeze({ + maxTiles: 9, + overviewMaxTiles: 0, + // Chained ways are short: a dense downtown z13 tile holds ~1,500, so each + // tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn). + maxSequences: 14_400, + lineWidthPx: 2.5, + overviewPointPx: 2.5, + moveDebounceMs: 320, + viewRangeMinM: 2_500, + viewRangePerHeight: 10, + viewNearM: 1_000, +}); +export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage'; +export const NO_COVERAGE_HINT = 'no KartaView photos in view'; +export const RATE_LIMITED_MESSAGE = + 'KartaView is rate-limiting requests — they resume on their own'; + +/** + * The photo API answers 429 to more than about three requests at once, so + * at most two are in flight; a 429 is retried after a short wait. + */ +export const PHOTO_API_CONCURRENCY = 2; +export const PHOTO_API_RETRIES = 3; +export const PHOTO_API_RETRY_BASE_MS = 800; +/** A Retry-After longer than this is not waited out. */ +export const PHOTO_API_RETRY_MAX_MS = 10_000; +/** The API is HTTP/1.1 (six connections): tiles 3, lookups 2, pages 1. */ +export const TILE_CONCURRENCY = 3; +export const PAGE_CONCURRENCY = 1; +export const TILE_TIMEOUT_MS = 20_000; +export const PHOTO_API_TIMEOUT_MS = 15_000; +/** + * The first try of a lookup: KartaView sometimes sits on a lookup for 11 s + * and more, then answers the same one again in 3 s. + */ +export const PHOTO_API_FIRST_TIMEOUT_MS = 8_000; +/** A sequence page is about 650 kB of JSON. */ +export const PAGE_TIMEOUT_MS = 30_000; +/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */ +export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; +/** A photo download (tens of kilobytes to a few megabytes). */ +export const IMAGE_TIMEOUT_MS = 15_000; +/** Whether a photo file is there (not archived): headers only. */ +export const IMAGE_PROBE_TIMEOUT_MS = 8_000; +/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */ +export const NEAREST_PROBES = 6; +export const ARCHIVED_MESSAGE = + 'KartaView’s photos here are archived and cannot be shown'; + +/** Photos asked for around a point; the API returns them nearest first. */ +export const NEAREST_LIMIT = 10; +/** The photo API's search zoom: 18 searches the few tens of metres around the point. */ +export const NEAREST_SEARCH_ZOOM = 18; + +/** + * Photos of a sequence fetched at once, around the open one. Records are + * ~2.6 kB each, so a page is ~650 kB; the cones and ‹ › use it. Pages are in + * photo-id order, which only roughly follows the sequence index. + */ +export const SEQUENCE_PAGE_SIZE = 250; +/** Within this many photos of a page's edge, its neighbour page joins it. */ +export const SEQUENCE_PAGE_EDGE = 12; + +/** Equirectangular panorama sizes: a quick preview, then the sharp one. */ +export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]); +/** Enough for a docked viewer; an expanded one gets the full size. */ +export const PANO_MID_SIZE = Object.freeze([2048, 1024]); +export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); +/** A flat photo's first version, then its sharp one. */ +export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]); +export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]); + +/** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */ +export const KARTAVIEW_CREDIT_HTML = + 'Street Level: imagery © KartaView contributors, CC BY-SA 4.0'; + +/** + * The imagery filter (360° or flat, SINCE). KartaView filters coverage on + * its server only in its PNG tiles: its JSON tiles come back empty once a + * filter is asked for. So a filtered PNG tile is a mask, and a JSON line is + * kept where it lies on the mask's drawn pixels. + * + * KartaView's date filter is the upload date, and SINCE goes by it + * throughout (lines, the photo a click opens, the cones), as KartaView's own + * map does; the panel says so under the slider. + */ +export const MASK_TIMEOUT_MS = 20_000; +/** + * A filtered PNG tile is drawn on demand and a dense one can outlast + * KartaView's own 60 s (504); it keeps drawing, and has the tile a few + * seconds later. A timeout or a 5xx is asked again this many times in all. + */ +export const MASK_ATTEMPTS = 4; +export const MASK_RETRY_WAIT_MS = 4_000; +/** Real masks are tens of kilobytes. */ +export const MASK_MAX_BYTES = 2 * 1024 * 1024; +/** A segment is kept when this share of the points along it lies on the mask. */ +export const MASK_KEEP_SHARE = 0.5; +/** Points along a segment are tested this many mask pixels apart. */ +export const MASK_SAMPLE_PX = 2; +export const MASK_SLOW_MESSAGE = + 'KartaView did not filter this area in time — move the map to ask again'; +export const FILTERED_OUT_MESSAGE = + 'None of KartaView’s photos here pass the imagery filter'; +/** Under the SINCE slider while KartaView is lit. */ +export const SINCE_NOTE = 'KartaView: by upload date'; + +/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ +function utcDay(ms) { + return new Date(ms).toISOString().slice(0, 10); +} + +const filtersSince = (filter) => + Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0; + +const DAY_MS = 86_400_000; + +/** + * Whether a photo passes the resolved imagery filter by its projection and + * upload date, the cut-off taken as its UTC day, as KartaView's startDate. + * @param {{isPano: boolean, uploadedAt: number|null}} photo + * @param {{pano: string, sinceMs: number|null}|null} filter + */ +export function passesKartaViewFilter(photo, filter) { + if (!filter) return true; + if (filter.pano === 'pano' && !photo.isPano) return false; + if (filter.pano === 'flat' && photo.isPano) return false; + if (!filtersSince(filter)) return true; + const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS; + return (photo.uploadedAt ?? 0) >= day; +} + +/** + * The query of a filtered PNG coverage tile, or '' when nothing is + * filtered. The day, not the instant, so it is stable for a day (and keys + * the mask cache). + * @param {{pano: string, sinceMs: number|null}|null} filter resolved + */ +export function tileFilterQuery(filter) { + const params = new URLSearchParams(); + if (filter?.pano === 'pano') params.set('fieldOfView', '360'); + else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane'); + if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs)); + return params.toString(); +} + +/** + * The photo search's own filter: projection and upload date (its + * `fieldOfView` is ignored). + * @param {{pano: string, sinceMs: number|null}|null} filter resolved + * @returns {Record} + */ +export function photoFilterParams(filter) { + const params = {}; + if (filter?.pano === 'pano') params.projection = 'SPHERE'; + else if (filter?.pano === 'flat') params.projection = 'PLANE'; + if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs); + return params; +} + +/** The cone and coverage noun: a line is a street with photos, not a sequence. */ +export const COVERAGE_NOUN = 'streets'; +export const LINE_CLICK_HINT = 'click a line for its nearest photo'; + +/** Base64url of a string, as the image proxy reads addresses. */ +function base64Url(text) { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** A photo resized by KartaView's image proxy to fit `width`×`height`. */ +export function resizedImageUrl(url, [width, height]) { + if (!url) return null; + return `${KARTAVIEW_CDN_HOST}/pr:sharp/rs:fit:${width}:${height}/${base64Url(url)}`; +} + +/** Epoch ms for a KartaView timestamp ('2016-02-19 15:28:18.000', UTC), or null. */ +export function kartaViewTimeMs(value) { + const match = + /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec( + String(value ?? ''), + ); + if (!match) return null; + const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; + if (Number(y) < 1990) return null; + const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + return Number.isFinite(ms) ? ms : null; +} + +/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */ +export function bearingBetween(a, b) { + const rad = Math.PI / 180; + const dLon = (b.lon - a.lon) * rad; + const y = Math.sin(dLon) * Math.cos(b.lat * rad); + const x = + Math.cos(a.lat * rad) * Math.sin(b.lat * rad) - + Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon); + return (Math.atan2(y, x) / rad + 360) % 360; +} + +/** Photos closer than this say nothing about the direction of travel. */ +const COURSE_MIN_STEP_M = 2; +/** How many photos along to look for one far enough away. */ +const COURSE_SEARCH = 5; + +/** + * Give each photo of a drive (in index order) its `course`: the direction + * of travel from it to the next photo a few metres on (from the previous one + * at the end). KartaView is dashcam imagery, so this is where the camera + * looks; its `heading` is the device compass, which reads 0 without one and + * can sit 110° off inside a car. + * @param {Array<{lon: number, lat: number, course?: number|null}>} photos + * @param {(a: object, b: object) => number} metres + */ +export function assignCourses(photos, metres) { + photos.forEach((photo, i) => { + let course = null; + for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++) + if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) { + course = bearingBetween(photo, photos[j]); + break; + } + for ( + let k = i - 1; + course === null && k >= i - COURSE_SEARCH && k >= 0; + k-- + ) + if (metres(photos[k], photo) >= COURSE_MIN_STEP_M) + course = bearingBetween(photos[k], photo); + photo.course = course; + }); + return photos; +} + +/** Where the camera looked: the direction of travel, else the compass, else unknown. */ +export function cameraBearing(photo) { + return photo?.course ?? photo?.heading ?? null; +} + +/** A coordinate pair as numbers; the road-matched one when KartaView has it. */ +function positionOf(record) { + const matchLat = Number(record.matchLat); + const matchLon = Number(record.matchLng); + if ( + Number.isFinite(matchLat) && + Number.isFinite(matchLon) && + (matchLat !== 0 || matchLon !== 0) + ) + return { lat: matchLat, lon: matchLon }; + return { lat: Number(record.lat), lon: Number(record.lng) }; +} + +const STORAGE_HOST = /^storage\d+$/; + +/** A KartaView photo-storage address, or null for anything else. */ +function storageUrl(value) { + try { + const url = new URL(String(value)); + return url.protocol === 'https:' && + /^storage\d+\.openstreetcam\.org$/.test(url.hostname) + ? url.href + : null; + } catch { + return null; + } +} + +/** + * A photo file's address. The API's `fileurl*` fields always name storage1, + * but a photo whose `storage` is another host (storage2…) is only there, so + * the address is built from `storage` and the relative `filepath*`. A + * `fileurl*` fallback must itself be on KartaView's storage. + */ +function fileUrl(record, pathKey, urlKey) { + const path = record[pathKey]; + if (STORAGE_HOST.test(record.storage || '') && path) + return storageUrl( + `https://${record.storage}.openstreetcam.org/files/photo/${path}`, + ); + return storageUrl(record[urlKey]); +} + +/** A storage URL with its `{{sizeprefix}}` placeholder filled. */ +function sized(record, prefix) { + const fill = (value) => + typeof value === 'string' && value.includes('{{sizeprefix}}') + ? value.replace('{{sizeprefix}}', prefix) + : null; + const path = fill(record.filepath); + return fileUrl( + { ...record, filepath: path, fileurl: fill(record.fileurl) }, + 'filepath', + 'fileurl', + ); +} + +/** + * A photo API record in the shape the provider uses, or null without an id + * or a position. + * @returns {{id: string, sequenceId: string|null, sequenceIndex: number|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, urls: {view: string|null, full: string|null, pano: string|null}}|null} + */ +export function normalizePhoto(record) { + if (!record || record.id == null) return null; + const { lat, lon } = positionOf(record); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + // 0,0 is a missing fix, not a photo in the Gulf of Guinea. + if (lat === 0 && lon === 0) return null; + if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + // An exact 0 is a device without a compass (whole drives read 0), not north. + const heading = + record.heading === '' || record.heading == null + ? NaN + : Number(record.heading) || NaN; + const isPano = + record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360; + const full = + fileUrl(record, 'filepathProc', 'fileurlProc') || sized(record, 'proc'); + const index = Number(record.sequenceIndex); + return { + id: String(record.id), + sequenceId: record.sequenceId != null ? String(record.sequenceId) : null, + sequenceIndex: Number.isFinite(index) ? index : null, + lon, + lat, + heading: Number.isFinite(heading) ? heading : null, + isPano, + capturedAt: + kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded), + // KartaView's SINCE goes by this (its server filters by upload date). + uploadedAt: kartaViewTimeMs(record.dateAdded), + urls: { + // A 1280 px rendition; for a 360° photo it is the front view only. + view: + fileUrl(record, 'filepathLTh', 'fileurlLTh') || + sized(record, 'lth') || + full, + full, + // A 360° photo's equirectangular image ('wrapped_proc'). + pano: isPano && /wrapped/.test(full || '') ? full : null, + }, + }; +} + +/** + * Where the image link goes. KartaView's photo pages fail for signed-out + * visitors ("Sequence cannot be loaded"), so it is the full-size photo + * through KartaView's image proxy, which also serves photos whose storage + * host answers 502 directly. + */ +export function kartaViewPhotoUrl(photo) { + const url = photo?.urls?.full || photo?.urls?.view; + return url + ? `${KARTAVIEW_CDN_HOST}/pr:sharp/${base64Url(url)}` + : 'https://kartaview.org'; +} diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs new file mode 100644 index 000000000..82a68555e --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assignCourses, + bearingBetween, + cameraBearing, + kartaViewPhotoUrl, + kartaViewTimeMs, + normalizePhoto, + passesKartaViewFilter, + photoFilterParams, + resizedImageUrl, + tileFilterQuery, +} from './policy.js'; + +/** A photo record as /2.0/photo returns it (trimmed to what is read). */ +function record(overrides = {}) { + return { + id: '7102491', + sequenceId: '14010', + sequenceIndex: '950', + lat: '42.335712', + lng: '-83.048301', + matchLat: '42.335731506347656', + matchLng: '-83.04828643798828', + heading: '299.58', + fieldOfView: '0', + projection: 'PLANE', + shotDate: '2016-08-23 20:17:47.000', + dateAdded: '2016-08-24 04:01:07', + storage: 'storage1', + filepath: '2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg', + filepathLTh: '2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + filepathProc: '2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + fileurl: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg', + fileurlLTh: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + fileurlProc: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ...overrides, + }; +} + +test('a photo record normalizes to numbers, the road-matched position and UTC capture time', () => { + const photo = normalizePhoto(record()); + assert.equal(photo.id, '7102491'); + assert.equal(photo.sequenceId, '14010'); + assert.equal(photo.sequenceIndex, 950); + assert.equal(photo.lat, 42.335731506347656); + assert.equal(photo.lon, -83.04828643798828); + assert.equal(photo.heading, 299.58); + assert.equal(photo.isPano, false); + assert.equal(photo.capturedAt, Date.UTC(2016, 7, 23, 20, 17, 47)); + assert.equal(photo.urls.pano, null); +}); + +test('photo files come from the storage host the record names, not the one its URLs say', () => { + // The API writes storage1 into every URL; this photo lives on storage2. + const photo = normalizePhoto(record({ storage: 'storage2' })); + assert.equal( + photo.urls.view, + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + ); + assert.equal( + photo.urls.full, + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ); + // The link goes through the image proxy, which serves photos whose + // storage host answers 502 directly. + const link = kartaViewPhotoUrl(photo); + assert.match(link, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/[\w-]+$/); + assert.equal( + Buffer.from(link.split('/').at(-1), 'base64url').toString(), + photo.urls.full, + ); + // A fallback address must be on KartaView's storage, over https. + for (const fileurlLTh of [ + 'https://tracker.example/x.jpg', + 'http://storage1.openstreetcam.org/a.jpg', + 'https://storage1.openstreetcam.org.evil.example/a.jpg', + 'javascript:alert(1)', + ]) { + const hostile = normalizePhoto( + record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }), + ); + assert.notEqual(hostile.urls.view, fileurlLTh); + assert.doesNotMatch( + String(hostile.urls.view), + /tracker|javascript|evil|^http:/, + ); + } + // An unexpected storage value falls back to the URLs as given. + const odd = normalizePhoto(record({ storage: 'https://evil.example' })); + assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//); + // No large thumbnail path: the size prefix fills in. + const bare = normalizePhoto( + record({ filepathLTh: '', fileurlLTh: '', storage: 'storage3' }), + ); + assert.equal( + bare.urls.view, + 'https://storage3.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + ); +}); + +test('a 360° photo keeps its equirectangular image; the unmatched position is the fallback', () => { + const photo = normalizePhoto( + record({ + projection: 'SPHERE', + fieldOfView: '360', + matchLat: '0', + matchLng: '0', + storage: 'storage13', + filepathProc: '2022/10/6/wrapped_proc/6187609_626b8.jpg', + }), + ); + assert.equal(photo.isPano, true); + assert.equal(photo.lat, 42.335712); + assert.equal( + photo.urls.pano, + 'https://storage13.openstreetcam.org/files/photo/2022/10/6/wrapped_proc/6187609_626b8.jpg', + ); + // A sphere without a wrapped image has nothing to project. + assert.equal( + normalizePhoto(record({ projection: 'SPHERE', fieldOfView: '360' })).urls + .pano, + null, + ); +}); + +test('records without an id or a position are dropped; a missing heading is null', () => { + assert.equal(normalizePhoto(null), null); + // 0,0 is a missing fix. + assert.equal( + normalizePhoto( + record({ lat: '0', lng: '0', matchLat: '0', matchLng: '0' }), + ), + null, + ); + assert.equal(normalizePhoto(record({ lat: '95', matchLat: '95' })), null); + assert.equal(normalizePhoto(record({ id: undefined })), null); + assert.equal( + normalizePhoto( + record({ lat: 'x', lng: 'y', matchLat: undefined, matchLng: undefined }), + ), + null, + ); + assert.equal(normalizePhoto(record({ heading: '' })).heading, null); + // A device without a compass reads 0 for a whole drive: unknown, not north. + assert.equal(normalizePhoto(record({ heading: '0' })).heading, null); + assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5); + assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null); +}); + +test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => { + assert.equal(kartaViewTimeMs('2022-03-21'), Date.UTC(2022, 2, 21)); + assert.equal( + kartaViewTimeMs('2022-03-21T06:42:59'), + Date.UTC(2022, 2, 21, 6, 42, 59), + ); + assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); + assert.equal(kartaViewTimeMs(''), null); + assert.equal(kartaViewTimeMs(null), null); + const fallback = normalizePhoto(record({ shotDate: null })); + assert.equal(fallback.capturedAt, Date.UTC(2016, 7, 24, 4, 1, 7)); +}); + +test('resized images go through the proxy with a base64url address', () => { + const url = resizedImageUrl( + 'https://storage13.openstreetcam.org/files/photo/a?b=c+d', + [1024, 512], + ); + const [, encoded] = /\/rs:fit:1024:512\/(.+)$/.exec(url); + assert.match(url, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:/); + assert.doesNotMatch(encoded, /[+/=]/); + const decoded = Buffer.from( + encoded.replace(/-/g, '+').replace(/_/g, '/'), + 'base64', + ).toString(); + assert.equal( + decoded, + 'https://storage13.openstreetcam.org/files/photo/a?b=c+d', + ); + assert.equal(resizedImageUrl(null, [1, 1]), null); +}); + +test('a storage value that only contains a storage host name builds no address from it', () => { + for (const storage of ['evil.example/#storage1', 'storage1.evil.example']) { + const photo = normalizePhoto(record({ storage })); + // Not a storage host: the API's own storage URLs serve. + assert.equal( + photo.urls.view, + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + storage, + ); + assert.equal( + photo.urls.full, + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + storage, + ); + } +}); + +test('a 360° field of view makes a panorama whatever the projection says', () => { + assert.equal( + normalizePhoto(record({ projection: 'PLANE', fieldOfView: '360' })).isPano, + true, + ); + assert.equal( + normalizePhoto(record({ projection: 'PLANE', fieldOfView: '359' })).isPano, + false, + ); +}); + +test('a longitude past ±180 is not a position', () => { + assert.equal(normalizePhoto(record({ lng: '181', matchLng: '181' })), null); + assert.equal(normalizePhoto(record({ lng: '-181', matchLng: '-181' })), null); + assert.equal( + normalizePhoto(record({ lng: '180', matchLng: '180' })).lon, + 180, + ); +}); + +test('with no processed-photo path the size prefix gives the full photo', () => { + const photo = normalizePhoto( + record({ filepathProc: '', fileurlProc: '', storage: 'storage3' }), + ); + assert.equal( + photo.urls.full, + 'https://storage3.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ); + // Only the large thumbnail, no path at all: the thumbnail still shows. + const thumbOnly = normalizePhoto( + record({ + filepathProc: '', + fileurlProc: '', + filepath: '', + fileurl: '', + storage: 'storage3', + }), + ); + assert.equal(thumbOnly.urls.full, null); + assert.match( + thumbOnly.urls.view, + /^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//, + ); +}); + +test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => { + // North along a meridian, 5 m apart, then a repeat of the last position. + const step = 5 / 110_540; + const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step })); + photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place + const metres = (a, b) => + Math.hypot( + (b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180), + (b.lat - a.lat) * 110_540, + ); + assignCourses(photos, metres); + for (const photo of photos) + assert.ok( + Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5, + `${photo.course}`, + ); + // Eastward: 90°. + const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 })); + assignCourses(east, metres); + assert.ok( + Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5, + ); + // A lone photo has no course; the compass is the fallback, then unknown. + const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres); + assert.equal(lone.course, null); + assert.equal(cameraBearing({ ...lone, heading: 42 }), 42); + assert.equal(cameraBearing({ ...lone, heading: null }), null); + assert.equal( + cameraBearing({ course: 90, heading: 200 }), + 90, + 'the drive beats the compass', + ); + assert.ok( + Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) < + 1e-9, + ); +}); + +test('the imagery filter becomes KartaView’s tile query and photo-search params', () => { + // 2026-03-05 23:30 UTC: the cut-off day is the UTC one. + const sinceMs = Date.UTC(2026, 2, 5, 23, 30); + assert.equal(tileFilterQuery(null), ''); + assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), ''); + assert.equal( + tileFilterQuery({ pano: 'pano', sinceMs: null }), + 'fieldOfView=360', + ); + assert.equal( + tileFilterQuery({ pano: 'flat', sinceMs }), + 'fieldOfView=plane&startDate=2026-03-05', + ); + assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), ''); + // The search ignores fieldOfView; its projection filters instead. + assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {}); + assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), { + projection: 'SPHERE', + startDate: '2026-03-05', + }); + assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), { + projection: 'PLANE', + }); +}); + +test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => { + const photo = normalizePhoto( + record({ + shotDate: '2016-08-23 20:17:47.000', + dateAdded: '2026-03-05 00:10:00', + }), + ); + assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10)); + const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) }); + // Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's + // startDate=2026-03-05 keeps it. + assert.equal( + passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')), + true, + ); + assert.equal( + passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }), + true, + ); + assert.equal(passesKartaViewFilter(photo, null), true); + assert.equal( + passesKartaViewFilter( + { ...photo, uploadedAt: null }, + since('2020-01-01T00:00:00Z'), + ), + false, + 'no upload date passes no SINCE', + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs new file mode 100644 index 000000000..706988904 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -0,0 +1,1108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createKartaViewProvider, + createTileCache, + kartaViewZoomFor, +} from './index.js'; +import { + FILTERED_OUT_MESSAGE, + NO_COVERAGE_HINT, + SEQUENCE_PAGE_SIZE, + TOO_HIGH_HINT, +} from './policy.js'; +import { validateProviders } from '../../registry.js'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; +import { + coverageViewer, + tileCentre, +} from '../../../../testSupport/streetLevelFakes.mjs'; + +/** A photo record `metres` east of (42.3314, -83.0458), in sequence `seq` at `index`. */ +function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { + const lon = -83.0458 + metres / 82_300; + return { + id: String(id), + sequenceId: seq, + sequenceIndex: String(index), + lat: '42.3314', + lng: String(lon), + heading: '90', + projection: 'PLANE', + fieldOfView: '0', + shotDate: '2016-08-23 20:17:47.000', + storage: 'storage2', + filepathLTh: `2016/8/24/lth/${id}.jpg`, + filepathProc: `2016/8/24/proc/${id}.jpg`, + }; +} + +/** KartaView's shotDate for `ms` ago. */ +const shotAgo = (ms) => + new Date(Date.now() - ms).toISOString().replace('T', ' ').slice(0, 23); + +/** + * A source over `sequences` (id → indexes present, in page order: KartaView + * pages follow photo ids, which only roughly follow the index), recording + * calls. Photos whose id is in `archived` answer 409. + */ +function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { + const calls = { + nearest: 0, + nearestFilters: [], + photo: [], + pages: [], + probes: [], + }; + return { + calls, + probeImage: async (url) => { + calls.probes.push(url); + return !archived.some((id) => url.includes(`/${id}.jpg`)); + }, + getTile: async () => ({ lines: [] }), + nearestPhotos: async ({ filter }) => { + calls.nearest++; + calls.nearestFilters.push(filter); + return nearest; + }, + getPhoto: async (id) => { + calls.photo.push(id); + for (const [seq, indexes] of Object.entries(sequences)) + for (const index of indexes) + if (`${seq}-${index}` === id) + return photoRecord(id, { seq, index, metres: index * 5 }); + throw new Error('KartaView has no such photo'); + }, + getSequencePhotos: async (seq, { page, itemsPerPage }) => { + calls.pages.push(`${seq}:${page}`); + const indexes = sequences[seq] || []; + const rows = indexes.slice( + (page - 1) * itemsPerPage, + page * itemsPerPage, + ); + return { + photos: rows.map((index) => + photoRecord(`${seq}-${index}`, { seq, index, metres: index * 5 }), + ), + hasMore: page * itemsPerPage < indexes.length, + }; + }, + }; +} + +function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { + // `actions.filter` is the resolved filter now; a test may change it. + const actions = { opened: [], nearest: [], instance: null, filter }; + return { + actions, + context: { + services: {}, + getFilter: () => actions.filter, + isActive: () => true, + groundCaster: null, + meshSampler: null, + getSurface: () => 'draped', + notify() {}, + actions: { + // As the core does: the provider's own viewer opens it. + openImage: async (id, options) => { + actions.opened.push({ id, options }); + await actions.instance?.viewer.open(id); + return true; + }, + openNearest: (point) => actions.nearest.push(point), + reportError() {}, + }, + }, + }; +} + +function setup(sourceOptions, contextOptions, { readMask } = {}) { + const source = fakeSource(sourceOptions); + const loads = []; + const definition = createKartaViewProvider({ + source, + readMask, + viewerOptions: { + load: async (url) => { + loads.push(url); + return { src: url }; + }, + createRenderer: () => { + throw new Error('no WebGL in Node'); + }, + }, + }); + const { context, actions } = fakeContext(contextOptions); + const instance = definition.create(context); + actions.instance = instance; + return { definition, instance, source, actions, loads }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('KartaView registers keyless, off by default, with Mapillary’s filters and its own wording', () => { + const { definition } = setup(); + assert.equal(definition.id, 'kartaview'); + assert.equal(definition.requiresKeyId, null); + assert.equal(definition.defaultOn, false); + assert.deepEqual(definition.filters, ['pano', 'since']); + assert.equal( + definition.sinceBasis, + 'upload', + 'SINCE is KartaView’s upload date', + ); + assert.equal( + definition.highlightsSequence, + false, + 'its lines are streets: a selected drive shows as cones', + ); + assert.match( + definition.credit.html, + /KartaView<\/a> contributors, CC BY-SA 4\.0/, + ); + assert.equal(definition.colors.coverage, '#635bff'); + assert.deepEqual(definition.coverageLabel, { + noun: 'streets', + action: 'click a line for its nearest photo', + }); + // Its prefix fits beside the others. + validateProviders([ + { ...definition, id: 'mapillary', pickPrefix: 'mly:' }, + { ...definition, id: 'google', pickPrefix: 'gsv:' }, + definition, + ]); +}); + +test('coverage is drawn from z14 near the street and z13 a little higher; never from orbit', () => { + assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' }); + assert.deepEqual( + kartaViewZoomFor(6_000), + { zoom: 13, kind: 'sequence' }, + 'the top of the range still draws', + ); + assert.equal(kartaViewZoomFor(6_001), null); + assert.equal(kartaViewZoomFor(NaN), null); +}); + +test('the nearest photo is the closest within 50 m, and opening it asks nothing more', async () => { + const { instance, source, loads } = setup({ + nearest: [ + photoRecord('far', { metres: 80 }), + photoRecord('mid', { metres: 30 }), + photoRecord('near', { metres: 12 }), + ], + }); + const id = await instance.nearestImage({ lat: 42.3314, lon: -83.0458 }); + assert.equal(id, 'near'); + const host = fakeHost(); + await instance.viewer.mount(host); + await instance.viewer.open(id); + assert.deepEqual(source.calls.photo, [], 'the lookup filled the cache'); + assert.equal( + Buffer.from(loads[0].split('/').at(-1), 'base64url').toString(), + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/near.jpg', + 'the proxy shrinks the photo on its real storage host', + ); + const none = setup({ nearest: [photoRecord('far', { metres: 80 })] }); + assert.equal( + await none.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + null, + ); +}); + +test('the nearest photo passes the imagery filter by its upload date, as KartaView filters; none passing says so', async () => { + const since = Date.now() - 86_400_000; + const filter = { pano: 'pano', sinceMs: since }; + const pano = (id, metres, uploaded) => ({ + ...photoRecord(id, { metres }), + projection: 'SPHERE', + fieldOfView: '360', + shotDate: '2016-08-23 20:17:47.000', + dateAdded: uploaded, + }); + // Both taken in 2016; only the second was uploaded since yesterday. + const { instance, source } = setup( + { + nearest: [ + pano('stale', 5, '2016-08-24 04:01:07'), + pano('fresh', 20, shotAgo(3_600_000).slice(0, 19)), + ], + }, + { filter }, + ); + const at = { lat: 42.3314, lon: -83.0458 }; + assert.equal(await instance.nearestImage(at), 'fresh'); + assert.deepEqual(source.calls.nearestFilters, [filter]); + const stale = setup( + { nearest: [pano('stale', 5, '2016-08-24 04:01:07')] }, + { filter }, + ); + await assert.rejects(stale.instance.nearestImage(at), { + message: FILTERED_OUT_MESSAGE, + }); + // Uploaded earlier on the cut-off's UTC day: KartaView's startDate keeps + // it, and so does the check. + const day = new Date(since).toISOString().slice(0, 10); + const sameDay = setup( + { nearest: [pano('early', 5, `${day} 00:00:01`)] }, + { filter }, + ); + assert.equal(await sameDay.instance.nearestImage(at), 'early'); +}); + +test('a cone opens its photo; a line opens the photo nearest the click', () => { + const { instance, actions } = setup(); + actions.instance = null; + assert.equal(instance.handlePick('kv:img:123'), true); + assert.deepEqual(actions.opened, [{ id: '123', options: undefined }]); + const point = { lon: -83.0458, lat: 42.3314 }; + assert.equal( + instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => point }), + true, + ); + assert.deepEqual(actions.nearest, [point]); + // Over the sky the line is still ours, but there is nowhere to look. + assert.equal( + instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => null }), + true, + ); + assert.equal(actions.nearest.length, 1); + assert.equal(instance.handlePick('mly:seq:1'), false); +}); + +test('coverage stats report KartaView as configured and key-free', async () => { + const { instance } = setup(); + assert.deepEqual(await instance.status(), { configured: true }); + const stats = instance.coverageStats(); + assert.equal(stats.keyRequired, false); + assert.equal(stats.keyRejected, false); + assert.equal(stats.count, 0); +}); + +/** Press a step button and wait until it has opened a photo (`opened` grew). */ +const press = async (button, opened) => { + const before = opened.length; + button.fire('click'); + await until(() => opened.length > before); + await settle(); +}; + +async function mounted(sourceOptions, contextOptions) { + const env = setup(sourceOptions, contextOptions); + const host = fakeHost(); + await env.instance.viewer.mount(host); + const poses = []; + env.instance.viewer.onPose((pose) => poses.push(pose.imageId)); + const [, , prev, next] = host.children[0].children; + return { ...env, poses, prev, next }; +} + +test('› walks across sequence pages without moving the camera; ‹ walks back; the ends stop', async () => { + const size = SEQUENCE_PAGE_SIZE; + const indexes = Array.from({ length: size + 5 }, (_, i) => i); + const { instance, actions, source, poses, prev, next } = await mounted({ + sequences: { s: indexes }, + }); + await instance.viewer.open(`s-${size - 1}`); + await press(next, poses); + assert.equal(poses.at(-1), `s-${size}`, 'onto the second page'); + assert.deepEqual(actions.opened.at(-1), { + id: `s-${size}`, + options: { frame: false }, + }); + assert.ok(source.calls.pages.includes('s:2')); + await press(prev, poses); + assert.equal(poses.at(-1), `s-${size - 1}`); + await instance.viewer.open('s-0'); + assert.ok(await until(() => prev.disabled), 'nothing before the first photo'); + // ‹ at the start opens nothing: presses run in turn, so once a later › + // has opened its photo, a ‹ that opened one would show before it. + const before = actions.opened.length; + prev.fire('click'); + await press(next, poses); + assert.deepEqual( + actions.opened.slice(before).map((open) => open.id), + ['s-1'], + ); +}); + +test('pages that overlap in index (id order) step one photo at a time across the edge', async () => { + const size = SEQUENCE_PAGE_SIZE; + // As KartaView pages by id: page 1 holds 0…size+2 less two, page 2 starts + // with those two (size-2, size) before the rest. + const first = Array.from({ length: size + 3 }, (_, i) => i).filter( + (i) => i !== size - 2 && i !== size, + ); + const second = [ + size - 2, + size, + ...Array.from({ length: 50 }, (_, i) => size + 3 + i), + ]; + const { instance, poses, next } = await mounted({ + sequences: { s: [...first, ...second] }, + }); + await instance.viewer.open(`s-${size - 5}`); + for (let k = 1; k <= 8; k++) await press(next, poses); + assert.deepEqual( + poses.slice(-9).map((id) => Number(id.slice(2))), + Array.from({ length: 9 }, (_, i) => size - 5 + i), + 'no photo skipped at the page edge', + ); +}); + +test('a photo whose index outruns the page count is found, and its neighbours are real', async () => { + // 1,000 photos with 500–1499 deleted: index 1500 is on page 2, not 4. + const indexes = [ + ...Array.from({ length: 500 }, (_, i) => i), + ...Array.from({ length: 500 }, (_, i) => 1500 + i), + ]; + const { instance, poses, prev, next } = await mounted({ + sequences: { s: indexes }, + }); + await instance.viewer.open('s-1500'); + await press(next, poses); + assert.equal(poses.at(-1), 's-1501'); + await press(prev, poses); + await press(prev, poses); + assert.equal(poses.at(-1), 's-499', 'back across the gap'); + await instance.viewer.open('s-1999'); + assert.ok(await until(() => next.disabled), 'the last photo'); +}); + +test('archived photos are skipped: by the nearest lookup, and by › and ‹', async () => { + const { instance, poses, next } = await mounted({ + nearest: [ + photoRecord('gone', { metres: 3 }), + photoRecord('kept', { metres: 9 }), + ], + archived: ['gone', 's-2', 's-3'], + sequences: { s: [0, 1, 2, 3, 4] }, + }); + assert.equal( + await instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + 'kept', + ); + await instance.viewer.open('s-1'); + await press(next, poses); + assert.equal(poses.at(-1), 's-4', 'two archived photos stepped over'); + const allGone = setup({ + nearest: [photoRecord('gone', { metres: 3 })], + archived: ['gone'], + }); + await assert.rejects( + allGone.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + /archived and cannot be shown/, + ); + const noFiles = setup({ + nearest: [ + { + ...photoRecord('bare', { metres: 2 }), + filepathLTh: '', + filepathProc: '', + }, + ], + }); + assert.equal( + await noFiles.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + null, + 'a record with no file is not a photo', + ); +}); + +/** A stand-in Cesium viewer the cones can draw on; `cones()` lists their photo indexes. */ +async function withCones(t, env) { + const Cesium = await import('cesium'); + const drawing = new Proxy({}, { get: () => () => {}, set: () => true }); + const saved = globalThis.document; + globalThis.document = { + createElement: () => ({ getContext: () => drawing }), + }; + t.after(() => (globalThis.document = saved)); + let collection = null; + env.instance.init({ + scene: { + frameState: { mode: Cesium.SceneMode.SCENE3D }, + updateHeight: () => () => {}, + getHeight: () => undefined, + primitives: { add: (c) => (collection = c), remove() {} }, + preRender: { addEventListener: () => () => {} }, + }, + camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, + }); + const cones = () => { + const indexes = []; + for (let i = 0; i < collection.length; i++) + indexes.push(Number(collection.get(i).id.split('-').at(-1))); + return [Math.min(...indexes), Math.max(...indexes)]; + }; + cones.count = () => collection?.length ?? 0; + return cones; +} + +test('the cones follow the open photo to another page of a long sequence, cached or not', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const drawnAround = (index) => + until( + () => + !env.instance.sequenceStats().loading && + cones.count() > 0 && + cones()[0] <= index && + cones()[1] >= index, + ); + await env.instance.viewer.open('s-10'); + await env.instance.selectSequence('s'); + assert.ok(await drawnAround(10)); + assert.ok(cones()[1] < size, `first page: ${cones()}`); + // A line click further along the same drive: the core does not reselect. + await env.instance.viewer.open(`s-${size * 4 + 10}`); + assert.ok(await drawnAround(size * 4 + 10), `re-centred: ${cones()}`); + assert.ok(cones()[0] >= size * 3, `re-centred: ${cones()}`); + // Closed and reopened on another page: not the sequence's cached cones. + env.instance.clearSequence(); + await env.instance.viewer.open(`s-${size * 2 + 10}`); + await env.instance.selectSequence('s'); + assert.ok(await drawnAround(size * 2 + 10), `around the photo: ${cones()}`); +}); + +test('a storage hiccup is not remembered as an archived photo', async () => { + const env = setup({ nearest: [photoRecord('near', { metres: 3 })] }); + let answer = null; + env.source.probeImage = async () => answer; + // No answer: the photo is tried rather than called archived. + assert.equal( + await env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + 'near', + ); + answer = false; + await assert.rejects( + env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + /archived/, + 'asked again, and now it is gone', + ); +}); + +test('a photo far past a run of deleted photos still finds its page and neighbours', async () => { + const indexes = [ + ...Array.from({ length: 1000 }, (_, i) => i), + ...Array.from({ length: 20 }, (_, i) => 6000 + i), + ]; + const { instance, poses, next, prev, source } = await mounted({ + sequences: { s: indexes }, + }); + await instance.viewer.open('s-6005'); + // Both ends checks have found a neighbour (and probed it) before the look. + const probed = (id) => + source.calls.probes.some((url) => url.endsWith(`/${id}.jpg`)); + assert.ok(await until(() => probed('s-6004') && probed('s-6006'))); + await settle(); + assert.equal(next.disabled, false); + assert.equal(prev.disabled, false); + await press(next, poses); + assert.equal(poses.at(-1), 's-6006'); +}); + +test('coverage tiles are kept for a while: panning back asks KartaView nothing', async () => { + let now = 0; + const asked = []; + const tiles = createTileCache({ + load: async (tile) => { + asked.push(`${tile.z}/${tile.x}/${tile.y}`); + return { sequences: [], overview: [] }; + }, + size: 2, + ttlMs: 1000, + now: () => now, + }); + const a = { x: 1, y: 2, z: 14 }; + const b = { x: 2, y: 2, z: 14 }; + const c = { x: 3, y: 2, z: 14 }; + const first = await tiles.get(a, {}); + assert.equal(await tiles.get(a, {}), first, 'the same decoded tile'); + await tiles.get(b, {}); + await tiles.get(c, {}); + await tiles.get(a, {}); + assert.deepEqual( + asked, + ['14/1/2', '14/2/2', '14/3/2', '14/1/2'], + 'oldest dropped', + ); + now = 2000; + await tiles.get(a, {}); + assert.equal(asked.length, 5, 'stale after the TTL'); + // The same x/y at another zoom is another tile. + await tiles.get({ ...a, z: 13 }, {}); + assert.deepEqual(asked.at(-1), '13/1/2'); + assert.equal(asked.length, 6); + // A failed load is not kept. + const failing = createTileCache({ + load: async () => { + throw new Error('KartaView did not answer in time — try again'); + }, + size: 2, + ttlMs: 1000, + }); + await assert.rejects(failing.get(a, {}), /did not answer/); +}); + +test('a page in id order, its indexes out of order, still steps in index order', async () => { + const { instance, poses, next, prev } = await mounted({ + sequences: { s: [0, 2, 1, 3, 4] }, + }); + await instance.viewer.open('s-0'); + await press(next, poses); + assert.equal(poses.at(-1), 's-1'); + await press(next, poses); + assert.equal(poses.at(-1), 's-2'); + await press(prev, poses); + assert.equal(poses.at(-1), 's-1'); +}); + +test('a sequence page that failed once is asked again: › then reaches the next photo', async () => { + const env = await mounted({ sequences: { s: [0, 1, 2, 3] } }); + const real = env.source.getSequencePhotos; + let fail = true; + env.source.getSequencePhotos = async (...args) => { + env.source.calls.pages.push('failing'); + if (fail) throw new Error('KartaView did not answer in time — try again'); + return real(...args); + }; + await env.instance.viewer.open('s-1'); + // The ends checks ask for the page and get no answer. + assert.ok(await until(() => env.source.calls.pages.includes('failing'))); + await settle(); + fail = false; + await press(env.next, env.poses); + assert.equal(env.poses.at(-1), 's-2'); +}); + +test('a lookup overtaken while it probes shares the probe with the next one, which finds the photo', async () => { + const env = setup({ nearest: [photoRecord('kept', { metres: 9 })] }); + let answer; + env.source.probeImage = (url) => { + env.source.calls.probes.push(url); + return new Promise((resolve) => (answer = resolve)); + }; + env.source.nearestPhotos = async (point, { signal } = {}) => { + signal?.throwIfAborted(); + return [photoRecord('kept', { metres: 9 })]; + }; + const point = { lat: 42.3314, lon: -83.0458 }; + const controller = new AbortController(); + const first = env.instance.nearestImage(point, { signal: controller.signal }); + assert.ok(await until(() => env.source.calls.probes.length === 1)); + controller.abort(new Error('overtaken')); + const second = env.instance.nearestImage(point); + await settle(); + answer(true); + assert.equal(await second, 'kept'); + await first.catch(() => {}); + assert.equal(env.source.calls.probes.length, 1, 'one probe, shared'); + // A definite answer is kept: the next lookup asks storage nothing. + assert.equal(await env.instance.nearestImage(point), 'kept'); + assert.equal(env.source.calls.probes.length, 1); + // A lookup aborted before KartaView answers gives up. + const late = new AbortController(); + late.abort(new Error('closed')); + await assert.rejects( + env.instance.nearestImage(point, { signal: late.signal }), + /closed/, + ); +}); + +test('a probe that fails outright is tried, and asked again next time', async () => { + const env = setup({ nearest: [photoRecord('near', { metres: 3 })] }); + let probe = async () => { + throw new TypeError('Failed to fetch'); + }; + env.source.probeImage = (url) => probe(url); + const point = { lat: 42.3314, lon: -83.0458 }; + assert.equal(await env.instance.nearestImage(point), 'near'); + probe = async () => false; + await assert.rejects(env.instance.nearestImage(point), /archived/); +}); + +test('a photo past the last page asks for no page beyond it', async () => { + const env = await mounted({ + sequences: { s: Array.from({ length: 10 }, (_, i) => i) }, + }); + // KartaView knows photo 12, but no page lists it (moved or re-indexed). + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => + id === 's-12' + ? photoRecord('s-12', { seq: 's', index: 12, metres: 60 }) + : getPhoto(id); + await env.instance.viewer.open('s-12'); + await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), 's-9'); + assert.deepEqual([...new Set(env.source.calls.pages)], ['s:1']); +}); + +test('the cones re-centre when ‹ › walk off the page they were drawn from', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 3 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const settled = () => + until(() => !env.instance.sequenceStats().loading && cones.count() > 0); + // Near the start of page 2: the cones show page 2 alone. + await env.instance.viewer.open(`s-${size + 12}`); + await env.instance.selectSequence('s'); + assert.ok(await settled()); + assert.deepEqual(cones(), [size, size * 2 - 1]); + // ‹ back across the edge onto page 1. + for (let k = 0; k < 13; k++) await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), `s-${size - 1}`); + assert.ok( + await until(() => cones()[0] < size), + `drawn around page 1 now: ${cones()}`, + ); + // › forward, off the far end of page 2. + await env.instance.viewer.open(`s-${size * 2 - 13}`); + assert.ok(await until(() => cones()[0] >= size), `${cones()}`); + for (let k = 0; k < 13; k++) await press(env.next, env.poses); + assert.equal(env.poses.at(-1), `s-${size * 2}`); + assert.ok( + await until(() => cones()[1] >= size * 2), + `drawn around page 3 now: ${cones()}`, + ); +}); + +test('the imagery filter thins the cones by each photo’s upload date and projection', async (t) => { + const env = await mounted( + { sequences: { s: [0, 1, 2, 3] } }, + { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, + ); + // 0 and 1: 360° photos uploaded an hour ago (taken in 2016, as all); + // 2 and 3: flat ones uploaded in 2016. + const shaped = (record) => + Number(record.sequenceIndex) < 2 + ? { + ...record, + projection: 'SPHERE', + fieldOfView: '360', + dateAdded: shotAgo(3_600_000).slice(0, 19), + } + : { ...record, dateAdded: '2016-08-24 04:01:07' }; + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => shaped(await getPhoto(id)); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { ...page, photos: page.photos.map(shaped) }; + }; + const cones = await withCones(t, env); + await env.instance.viewer.open('s-1'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole'); + assert.equal(cones.count(), 2, 'the 360° photos uploaded since yesterday'); + assert.deepEqual(cones(), [0, 1]); + env.actions.filter = { pano: 'flat', sinceMs: null }; + env.instance.setFilter(); + assert.deepEqual(cones(), [2, 3], 'the flat ones'); + env.actions.filter = { pano: 'all', sinceMs: null }; + env.instance.setFilter(); + assert.equal(cones.count(), 4, 'every cone, unfiltered'); +}); + +/** + * The provider drawing coverage on a stand-in viewer above one z14 tile. + * `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held. + */ +function coverageSetup( + t, + { lines = [], filter, height = 900, drawnRows = () => [] } = {}, +) { + // A filtered tile's mask: 512 px square, drawn on the rows + // `drawnRows(query)` lists as [from, to]. + const env = setup( + {}, + { filter }, + { + readMask: async ({ query }) => { + const alpha = new Uint8Array(512 * 512); + for (const [from, to] of drawnRows(query)) + alpha.fill(255, from * 512, (to + 1) * 512); + return { width: 512, height: 512, alpha }; + }, + }, + ); + const masks = []; + let maskHold = null; + env.source.getTileMask = async (tile, query) => { + masks.push(query); + await maskHold; + return { query }; + }; + const centre = tileCentre(-83.0458, 42.3314); + const viewer = coverageViewer( + { lon: centre.lon, lat: centre.lat, height }, + t, + ); + const tiles = []; + let hold = null; + env.source.getTile = (tile) => { + tiles.push(`${tile.z}/${tile.x}/${tile.y}`); + const answer = { + lines: lines.map((track) => ({ + track: track.map(([lon, lat]) => ({ + lat: String(lat), + lng: String(lon), + })), + })), + }; + return hold ? hold.then(() => answer) : Promise.resolve(answer); + }; + env.instance.init(viewer); + t.after(() => env.instance.destroy(viewer)); + return { + ...env, + viewer, + centre, + tiles, + masks, + holdMasks() { + let release; + maskHold = new Promise((resolve) => (release = resolve)); + return () => { + maskHold = null; + release(); + }; + }, + holdTiles() { + let release; + hold = new Promise((resolve) => (release = resolve)); + return () => { + hold = null; + release(); + }; + }, + }; +} + +test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => { + const { lon, lat } = tileCentre(-83.0458, 42.3314); + // Across the tile's middle row (mask row ~256), and a quarter up (~130). + const across = (at) => [ + [lon - 0.001, at], + [lon + 0.001, at], + ]; + const env = coverageSetup(t, { + filter: { pano: 'pano', sinceMs: null }, + lines: [across(lat), across(lat + 0.004)], + // 360° photos lie on the middle line only. + drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []), + }); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.deepEqual(env.masks, ['fieldOfView=360']); + // Unfiltered: every line, and the tile's lines are not asked for again. + env.actions.filter = { pano: 'all', sinceMs: null }; + env.instance.setFilter(); + assert.ok(await until(() => env.instance.coverageStats().count === 2)); + assert.equal(env.tiles.length, 1, 'the segments are kept'); + // SINCE a day: uploaded since yesterday (UTC) — none here. The lines + // drawn now stay on the globe while KartaView draws the filtered tile. + const since = Date.now() - 86_400_000; + const release = env.holdMasks(); + env.actions.filter = { pano: 'all', sinceMs: since }; + env.instance.setFilter(); + assert.ok(await until(() => env.instance.coverageStats().loading)); + assert.ok( + env.viewer.scene.groundPrimitives.items.size > 0, + 'the unfiltered lines show meanwhile', + ); + release(); + assert.ok( + await until( + () => + !env.instance.coverageStats().loading && + env.instance.coverageStats().count === 0, + ), + ); + assert.equal( + env.masks.at(-1), + `startDate=${new Date(since).toISOString().slice(0, 10)}`, + ); + assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); + assert.ok( + await until(() => env.viewer.scene.groundPrimitives.items.size === 0), + 'and go once it is in', + ); + // The same filter again asks KartaView for nothing. + env.instance.setFilter(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(env.masks.length, 2); +}); + +test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => { + const env = coverageSetup(t, { height: 7_000 }); + env.instance.activate(env.viewer); + assert.equal(env.instance.coverageStats().hint, TOO_HIGH_HINT); + assert.deepEqual(env.tiles, [], 'nothing asked from up there'); + const release = env.holdTiles(); + env.viewer.view.height = 900; + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().hint, '', 'not while it loads'); + release(); + assert.ok(await until(() => !env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().count, 0); + assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); +}); + +test('coverage tiles are kept while KartaView is on and dropped once it is switched off', async (t) => { + const env = coverageSetup(t, { + lines: [ + [ + [-83.0458 - 0.001, 42.3314], + [-83.0458 + 0.001, 42.3314], + ], + ], + }); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(env.tiles.length, 1, 'kept while on'); + // Switched off: the decoded lines are not kept, so on again asks again. + env.instance.deactivate(); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(env.tiles.length, 2); +}); + +test('a photo no page lists steps to the photos either side of its index', async () => { + const env = await mounted({ sequences: { s: [0, 1, 2, 3, 4, 6, 7, 8, 9] } }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => + id === 's-5' + ? photoRecord('s-5', { seq: 's', index: 5, metres: 25 }) + : getPhoto(id); + await env.instance.viewer.open('s-5'); + await press(env.next, env.poses); + assert.equal(env.poses.at(-1), 's-6'); + await env.instance.viewer.open('s-5'); + await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), 's-4'); +}); + +test('the panel counts the drive’s photos, not the thinned page of them the cones show', async (t) => { + const env = await mounted({ + sequences: { s: Array.from({ length: 40 }, (_, i) => i) }, + }); + const asked = []; + env.source.getSequenceCount = async (id) => { + asked.push(id); + return 2385; + }; + await withCones(t, env); + await env.instance.viewer.open('s-3'); + await env.instance.selectSequence('s'); + assert.ok( + await until(() => env.instance.sequenceStats().images === 2385), + `images: ${env.instance.sequenceStats().images}`, + ); + // Asked once per drive. + env.instance.clearSequence(); + await env.instance.viewer.open('s-30'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.instance.sequenceStats().images === 2385)); + assert.deepEqual(asked, ['s']); +}); + +test('after a GPU reset the photo is drawn again by the viewer, not through the core; a failure is reported', async () => { + const source = fakeSource(); + const record = { + ...photoRecord('pano', { seq: 'p', index: 0 }), + projection: 'SPHERE', + fieldOfView: '360', + filepathProc: '2022/10/6/wrapped_proc/pano.jpg', + }; + source.getPhoto = async () => record; + const view = { yaw: 0, pitch: 0, fov: 80 }; + let failLoads = false; + const definition = createKartaViewProvider({ + source, + viewerOptions: { + load: async (url) => { + if (failLoads) + throw new Error('The KartaView photo could not be loaded'); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView: (next) => Object.assign(view, next), + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }), + }, + }); + const { context, actions } = fakeContext(); + const errors = []; + context.actions.reportError = (message) => errors.push(message); + const instance = definition.create(context); + actions.instance = instance; + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + await instance.viewer.open('pano'); + const canvas = () => host.children[0].children[1]; + const kind = () => host.children[0].dataset.kind; + canvas().fire('webglcontextlost'); + assert.equal(kind(), '', 'cleared while it is drawn again'); + assert.ok(await until(() => kind() === 'pano'), 'drawn again'); + assert.ok(poses.length >= 2); + assert.deepEqual(actions.opened, [], 'the core (and its lookup) left alone'); + failLoads = true; + canvas().fire('webglcontextlost'); + assert.ok(await until(() => errors.length === 1)); + assert.match(errors[0], /could not be loaded/); +}); + +test('the marker is KartaView’s own cone, and it points along the drive, not at a wrong compass', async () => { + const env = setup({ + sequences: { s: Array.from({ length: 10 }, (_, i) => i) }, + }); + assert.deepEqual(env.definition.marker, { + kind: 'view-cone', + color: '#635bff', + }); + // Photos run east (photoRecord puts index i at 5·i m east); the compass says 200°. + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => ({ + ...(await getPhoto(id)), + heading: '200', + }); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { + ...page, + photos: page.photos.map((record) => ({ ...record, heading: '200' })), + }; + }; + const host = fakeHost(); + await env.instance.viewer.mount(host); + const bearings = []; + env.instance.viewer.onPose((pose) => bearings.push(pose.bearing)); + await env.instance.viewer.open('s-4'); + assert.equal(bearings[0], 200, 'the compass until the drive is known'); + assert.ok( + await until(() => Math.abs(bearings.at(-1) - 90) < 1), + `then along the drive: ${bearings}`, + ); +}); + +/** Hold `seq`'s page `page` until the returned function is called. */ +function holdPage(source, seq, page) { + const getPage = source.getSequencePhotos; + let release; + const gate = new Promise((resolve) => (release = resolve)); + source.getSequencePhotos = async (id, options) => { + if (id === seq && options.page === page) await gate; + return getPage(id, options); + }; + return () => release(); +} + +test('a photo opened while the cones first load is the one they centre on', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const release = holdPage(env.source, 's', 1); + await env.instance.viewer.open('s-10'); + const selected = env.instance.selectSequence('s'); + // A line click further along the drive while page 1 is still loading: + // nothing is drawn yet for its re-centre to move. + await env.instance.viewer.open(`s-${size * 4 + 10}`); + release(); + await selected; + assert.ok( + await until( + () => !env.instance.sequenceStats().loading && cones.count() > 0, + ), + ); + assert.ok( + cones()[0] <= size * 4 + 10 && cones()[1] >= size * 4 + 10, + `around the newer photo, not the first: ${cones()}`, + ); +}); + +test('a sequence page that lands while a lost GPU context recovers still draws its cones', async (t) => { + const env = setup({ sequences: { p: [0, 1, 2, 3] } }); + const sphere = (record) => ({ + ...record, + projection: 'SPHERE', + fieldOfView: '360', + filepathProc: `2022/10/6/wrapped_proc/${record.id}.jpg`, + }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => sphere(await getPhoto(id)); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { ...page, photos: page.photos.map(sphere) }; + }; + const release = holdPage(env.source, 'p', 1); + let holdImages = false; + let releaseImage = null; + const view = { yaw: 0, pitch: 0, fov: 80 }; + const definition = createKartaViewProvider({ + source: env.source, + viewerOptions: { + load: async (url) => { + if (holdImages) await new Promise((r) => (releaseImage = r)); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView: (next) => Object.assign(view, next), + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }), + }, + }); + const { context, actions } = fakeContext(); + const errors = []; + context.actions.reportError = (message) => errors.push(message); + const instance = definition.create(context); + actions.instance = instance; + const cones = await withCones(t, { instance }); + const host = fakeHost(); + await instance.viewer.mount(host); + await instance.viewer.open('p-0'); + // › to the next panorama; the GPU resets while it loads. + holdImages = true; + const opening = instance.viewer.open('p-1'); + assert.ok(await until(() => releaseImage !== null)); + host.children[0].children[1].fire('webglcontextlost'); + const selected = instance.selectSequence('p'); + release(); + await selected; + assert.ok( + await until(() => !instance.sequenceStats().loading && cones.count() > 0), + 'the page is drawn, not dropped', + ); + releaseImage(); + await opening; + assert.equal(host.children[0].dataset.kind, 'pano', 'p-1 shows'); + // reportError(null) clears; nothing was reported. + assert.deepEqual(errors.filter(Boolean), []); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js new file mode 100644 index 000000000..ddf21817f --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -0,0 +1,459 @@ +import { + IMAGE_PROBE_TIMEOUT_MS, + MAX_RESPONSE_BYTES, + KARTAVIEW_API_HOST, + MASK_ATTEMPTS, + MASK_MAX_BYTES, + MASK_RETRY_WAIT_MS, + MASK_SLOW_MESSAGE, + MASK_TIMEOUT_MS, + NEAREST_LIMIT, + NEAREST_SEARCH_ZOOM, + PAGE_CONCURRENCY, + PAGE_TIMEOUT_MS, + PHOTO_API_CONCURRENCY, + PHOTO_API_RETRIES, + PHOTO_API_RETRY_BASE_MS, + PHOTO_API_RETRY_MAX_MS, + PHOTO_API_FIRST_TIMEOUT_MS, + PHOTO_API_TIMEOUT_MS, + TILE_CONCURRENCY, + TILE_TIMEOUT_MS, + photoFilterParams, +} from './policy.js'; + +/** + * An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits, + * `timedOut` when it did not answer in time (the one failure worth asking + * again at once). + */ +export class KartaViewSourceError extends Error { + constructor( + message, + { status = 0, retryAfterSec = null, timedOut = false } = {}, + ) { + super(message); + this.name = 'KartaViewSourceError'; + this.status = status; + this.retryAfterSec = retryAfterSec; + this.timedOut = timedOut; + } +} + +const NO_ANSWER = 'KartaView did not answer in time — try again'; +const UNREACHABLE = 'KartaView could not be reached — check the network'; +const UNREADABLE = 'KartaView sent an answer that could not be read'; +/** Statuses that mean a photo file is gone, not that storage hiccupped. */ +const GONE_STATUSES = Object.freeze([403, 404, 409, 410]); + +/** Resolves after `ms`, or rejects when `signal` aborts first. */ +function wait(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const timer = setTimeout(done, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + function done() { + signal?.removeEventListener('abort', onAbort); + resolve(); + } + }); +} + +/** Seconds from a Retry-After header, or null. */ +function retryAfterSeconds(response) { + const header = response.headers?.get?.('retry-after'); + const value = header == null || header === '' ? NaN : Number(header); + return Number.isFinite(value) && value >= 0 ? value : null; +} + +/** + * A gate letting at most `limit` tasks run at once, first in first out, or + * newest first with `lifo` (the last request is the one the user waits on). + */ +export function createLimiter(limit, { lifo = false } = {}) { + let running = 0; + const queue = []; + function next() { + while (running < limit && queue.length) { + const { task, resolve, reject, signal } = lifo + ? queue.pop() + : queue.shift(); + if (signal?.aborted) { + reject(signal.reason); + continue; + } + running++; + Promise.resolve() + .then(task) + .then(resolve, reject) + .finally(() => { + running--; + next(); + }); + } + } + return { + run(task, { signal } = {}) { + return new Promise((resolve, reject) => { + const item = { task, resolve, reject, signal }; + queue.push(item); + signal?.addEventListener( + 'abort', + () => { + const at = queue.indexOf(item); + if (at === -1) return; + queue.splice(at, 1); + reject(signal.reason); + }, + { once: true }, + ); + next(); + }); + }, + get pending() { + return running + queue.length; + }, + }; +} + +/** + * KartaView's public API, browser-side and keyless (it allows any origin). + * It speaks HTTP/1.1, so the browser opens six connections to it: tiles take + * at most three, photo lookups two and sequence pages one, so a lookup never + * waits behind slow tiles or pages. The photo API answers 429 to more than + * about three requests at once; a 429 (and a lookup that timed out) is + * retried. + * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number, maskRetryMs?: number}} [options] + */ +export function createKartaViewSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + apiHost = KARTAVIEW_API_HOST, + retryBaseMs = PHOTO_API_RETRY_BASE_MS, + maskRetryMs = MASK_RETRY_WAIT_MS, +} = {}) { + const limiter = createLimiter(PHOTO_API_CONCURRENCY); + const tileLimiter = createLimiter(TILE_CONCURRENCY); + const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true }); + + /** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */ + function getJson(url, { signal, timeoutMs }) { + return fetchBody(url, { signal, timeoutMs, as: 'json' }); + } + + /** + * GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON + * or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the + * timeout. + */ + async function fetchBody( + url, + { signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES }, + ) { + const controller = new AbortController(); + const onAbort = () => controller.abort(signal.reason); + if (signal?.aborted) throw signal.reason; + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout( + () => + controller.abort( + new KartaViewSourceError(NO_ANSWER, { timedOut: true }), + ), + timeoutMs, + ); + let response = null; + let read = false; + try { + response = await fetchImpl(url, { signal: controller.signal }); + if (response.status === 429) + throw new KartaViewSourceError('KartaView is rate-limiting requests', { + status: 429, + retryAfterSec: retryAfterSeconds(response) ?? 2, + }); + if (!response.ok) + throw new KartaViewSourceError( + `KartaView answered HTTP ${response.status}`, + { status: response.status }, + ); + const length = Number(response.headers?.get?.('content-length')); + const tooMuch = () => + new KartaViewSourceError('KartaView sent too much data', { + status: response.status, + }); + if (length > maxBytes) throw tooMuch(); + // KartaView answers chunked, without a length: count while reading. + const reader = response.body?.getReader?.(); + if (!reader) { + read = true; + try { + if (as === 'bytes') + return new Uint8Array(await response.arrayBuffer()); + return await response.json(); + } catch (error) { + if (controller.signal.aborted) throw error; + throw new KartaViewSourceError(UNREADABLE, { + status: response.status, + }); + } + } + read = true; + const chunks = []; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + reader.cancel().catch(() => {}); + throw tooMuch(); + } + chunks.push(value); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + if (as === 'bytes') return body; + try { + return JSON.parse(new TextDecoder().decode(body)); + } catch { + throw new KartaViewSourceError(UNREADABLE, { + status: response.status, + }); + } + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (controller.signal.aborted) throw controller.signal.reason; + if (error instanceof KartaViewSourceError) throw error; + // fetch rejects on no network, a blocked request or a refused CORS check. + throw new KartaViewSourceError(UNREACHABLE); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + // An answer turned away unread (429, an error, too long) still holds + // its connection, one of the six the browser has: let it go. + if (response && !read) { + controller.abort(); + if (response.body && !response.body.locked) + response.body.cancel?.().catch?.(() => {}); + } + } + } + + /** + * A photo-API call through `gate`, retrying 429s with backoff and, once, a + * call that timed out. + */ + async function photoApi( + path, + params, + { signal } = {}, + { + gate = limiter, + timeoutMs = PHOTO_API_TIMEOUT_MS, + firstTimeoutMs = PHOTO_API_FIRST_TIMEOUT_MS, + } = {}, + ) { + const query = params ? `?${new URLSearchParams(params)}` : ''; + const url = `${apiHost}${path}${query}`; + let timeouts = 0; + for (let attempt = 0; ; attempt++) { + try { + const payload = await gate.run( + // A slow first answer is usually quick when asked again. + () => + getJson(url, { + signal, + timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs, + }), + { signal }, + ); + const code = Number(payload?.status?.apiCode); + // 600 ok, 601 empty, 602 partial: anything else is a refusal. + if (Number.isFinite(code) && (code < 600 || code > 603)) + throw new KartaViewSourceError( + `KartaView refused the request (code ${code})`, + { status: Number(payload?.status?.httpCode) || 0 }, + ); + return payload?.result ?? {}; + } catch (error) { + if (signal?.aborted) throw error; + const timedOut = error?.timedOut === true; + if (timedOut && timeouts++ < 1) continue; + if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) { + if (error?.status === 429) + throw new KartaViewSourceError( + 'KartaView is rate-limiting requests — try again in a moment', + { status: 429, retryAfterSec: error.retryAfterSec }, + ); + throw error; + } + await wait( + Math.min( + PHOTO_API_RETRY_MAX_MS, + Math.max( + retryBaseMs * 2 ** attempt, + (error.retryAfterSec ?? 0) * 1000, + ), + ), + signal, + ); + } + } + } + + return { + /** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */ + async getTile({ x, y, z }, { signal } = {}) { + const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`; + const once = () => + tileLimiter.run( + () => getJson(url, { signal, timeoutMs: TILE_TIMEOUT_MS }), + { signal }, + ); + try { + return await once(); + } catch (error) { + // A cold dense tile can outlast the timeout; KartaView has it ready + // by the second ask. + if (signal?.aborted || error?.timedOut !== true) throw error; + return once(); + } + }, + + /** + * A filtered coverage tile as PNG bytes, `query` from tileFilterQuery + * (KartaView filters only its PNG tiles). It draws a filtered tile on + * demand; a dense one can outlast its own 60 s (504) and still be drawn, + * so a timeout or a 5xx is asked again a few seconds later. + */ + async getTileMask({ x, y, z }, query, { signal } = {}) { + const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`; + for (let attempt = 1; ; attempt++) { + try { + // Outside the gate between tries: a wait holds no connection. + return await tileLimiter.run( + () => + fetchBody(url, { + signal, + timeoutMs: MASK_TIMEOUT_MS, + as: 'bytes', + maxBytes: MASK_MAX_BYTES, + }), + { signal }, + ); + } catch (error) { + if (signal?.aborted) throw error; + const drawing = + error?.timedOut === true || [502, 503, 504].includes(error?.status); + if (!drawing) throw error; + if (attempt >= MASK_ATTEMPTS) + throw new KartaViewSourceError(MASK_SLOW_MESSAGE, { + timedOut: true, + }); + await wait(maskRetryMs, signal); + } + } + }, + + /** + * Whether a photo file is there: true, false when it is gone (archived + * photos answer 409, and a HEAD says 200 regardless), or null when the + * storage did not say (timeout, network, 5xx). It GETs and stops at the + * headers. + */ + async probeImage(url, { signal } = {}) { + if (!url) return false; + const controller = new AbortController(); + const onAbort = () => controller.abort(signal.reason); + if (signal?.aborted) throw signal.reason; + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout( + () => controller.abort(), + IMAGE_PROBE_TIMEOUT_MS, + ); + try { + const response = await fetchImpl(url, { signal: controller.signal }); + if (response.ok) return true; + return GONE_STATUSES.includes(response.status) ? false : null; + } catch { + if (signal?.aborted) throw signal.reason; + return null; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + // The body is not needed: drop it. + controller.abort(); + } + }, + + /** + * Photo records around a point, nearest first; with a resolved imagery + * `filter`, only those of its projection uploaded since its cut-off. + */ + async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) { + const result = await photoApi( + '/2.0/photo/', + { + lat: lat.toFixed(7), + lng: lon.toFixed(7), + zoomLevel: String(NEAREST_SEARCH_ZOOM), + itemsPerPage: String(NEAREST_LIMIT), + ...photoFilterParams(filter), + }, + { signal }, + ); + return Array.isArray(result.data) ? result.data : []; + }, + + /** One photo record. */ + async getPhoto(photoId, { signal } = {}) { + const result = await photoApi( + `/2.0/photo/${encodeURIComponent(photoId)}`, + null, + { signal }, + ); + if (!result.data || typeof result.data !== 'object') + throw new KartaViewSourceError('KartaView has no such photo'); + return result.data; + }, + + /** How many photos a sequence has, or null if KartaView does not say. */ + async getSequenceCount(sequenceId, { signal } = {}) { + const result = await photoApi( + `/2.0/sequence/${encodeURIComponent(sequenceId)}`, + null, + { signal }, + ); + const count = Number(result.data?.countActivePhotos); + return Number.isFinite(count) && count > 0 ? count : null; + }, + + /** A page of a sequence's photos in capture order (1-based `page`). */ + async getSequencePhotos( + sequenceId, + { page, itemsPerPage }, + { signal } = {}, + ) { + const result = await photoApi( + `/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`, + { page: String(page), itemsPerPage: String(itemsPerPage) }, + { signal }, + { + gate: pageLimiter, + timeoutMs: PAGE_TIMEOUT_MS, + firstTimeoutMs: PAGE_TIMEOUT_MS, + }, + ); + return { + photos: Array.isArray(result.data) ? result.data : [], + hasMore: result.hasMoreData === true, + }; + }, + }; +} diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs new file mode 100644 index 000000000..6dcd393a4 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -0,0 +1,728 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + KartaViewSourceError, + createKartaViewSource, + createLimiter, +} from './source.js'; +import { + MASK_ATTEMPTS, + MASK_SLOW_MESSAGE, + NEAREST_LIMIT, + NEAREST_SEARCH_ZOOM, + PHOTO_API_CONCURRENCY, + PHOTO_API_RETRIES, + PHOTO_API_FIRST_TIMEOUT_MS, + PHOTO_API_TIMEOUT_MS, + PAGE_TIMEOUT_MS, + TILE_CONCURRENCY, +} from './policy.js'; + +/** A body streamed as browsers do, in `size`-byte chunks. */ +function streamed(text, size = 16_384) { + const bytes = new TextEncoder().encode(text); + let at = 0; + return { + getReader: () => ({ + read: async () => { + if (at >= bytes.length) return { done: true }; + const value = bytes.slice(at, at + size); + at += size; + return { done: false, value }; + }, + cancel: async () => {}, + }), + }; +} + +// A 200 as browsers deliver it: streamed, so the code under test reads it +// the way it does in production (`json` is there only to fail loudly). +const ok = (body) => ({ + ok: true, + status: 200, + headers: new Map(), + body: streamed(JSON.stringify(body)), + json: async () => { + throw new Error('a browser answer is read from its stream'); + }, +}); +const status = (code, headers = new Map()) => ({ + ok: false, + status: code, + headers, + json: async () => ({}), +}); +const photoPayload = (data) => ({ + status: { apiCode: 600, httpCode: 200 }, + result: { data, hasMoreData: false }, +}); + +test('coverage tiles come from api.kartaview.org as JSON', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(url); + return ok({ lines: [] }); + }, + }); + assert.deepEqual(await source.getTile({ x: 4412, y: 6061, z: 14 }), { + lines: [], + }); + assert.deepEqual(urls, [ + 'https://api.kartaview.org/2.0/sequence/tiles/4412/6061/14.json', + ]); +}); + +test('photo lookups run at most two at a time', async () => { + let running = 0; + let peak = 0; + const source = createKartaViewSource({ + fetchImpl: async () => { + running++; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running--; + return ok(photoPayload({ id: '1' })); + }, + }); + await Promise.all( + Array.from({ length: 7 }, (_, i) => source.getPhoto(String(i))), + ); + assert.equal(peak, PHOTO_API_CONCURRENCY); +}); + +test('a 429 is retried after a wait, then answered', async () => { + let calls = 0; + const source = createKartaViewSource({ + retryBaseMs: 1, + fetchImpl: async () => { + calls++; + return calls < 3 + ? status(429, new Map([['retry-after', '0']])) + : ok(photoPayload([{ id: '9' }])); + }, + }); + const records = await source.nearestPhotos({ lat: 42.3, lon: -83.04 }); + assert.deepEqual(records, [{ id: '9' }]); + assert.equal(calls, 3); +}); + +test('rate-limiting that outlasts the retries is reported with a wait', async () => { + let calls = 0; + const source = createKartaViewSource({ + retryBaseMs: 1, + fetchImpl: async () => { + calls++; + return status(429, new Map([['retry-after', '0.001']])); + }, + }); + await assert.rejects(source.getPhoto('1'), (error) => { + assert.ok(error instanceof KartaViewSourceError); + assert.equal(error.status, 429); + assert.ok(error.retryAfterSec > 0); + assert.match(error.message, /try again in a moment/); + return true; + }); + assert.equal(calls, PHOTO_API_RETRIES + 1); +}); + +test('an API refusal inside a 200 is a readable error with its code; 601 is just empty', async () => { + const refusing = createKartaViewSource({ + fetchImpl: async () => + ok({ + status: { apiCode: 612, apiMessage: ' out of range ', httpCode: 400 }, + result: {}, + }), + }); + await assert.rejects( + refusing.getPhoto('1'), + /KartaView refused the request \(code 612\)/, + ); + const empty = createKartaViewSource({ + fetchImpl: async () => + ok({ status: { apiCode: 601 }, result: { data: [] } }), + }); + assert.deepEqual(await empty.nearestPhotos({ lat: 0, lon: 0 }), []); + const page = await empty.getSequencePhotos('2008', { + page: 2, + itemsPerPage: 1000, + }); + assert.deepEqual(page, { photos: [], hasMore: false }); +}); + +test('HTTP errors, network failures and non-JSON become readable errors', async () => { + const http = createKartaViewSource({ fetchImpl: async () => status(500) }); + await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/); + let asked = 0; + const offline = createKartaViewSource({ + fetchImpl: async () => { + asked++; + throw new TypeError('Failed to fetch'); + }, + }); + // Not a timeout: no network is said as much, and not asked again at once. + await assert.rejects( + offline.getPhoto('1'), + /could not be reached — check the network/, + ); + assert.equal(asked, 1); + const garbled = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + }), + }); + await assert.rejects( + garbled.getTile({ x: 0, y: 0, z: 14 }), + /could not be read/, + ); + const torn = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + body: streamed('{"lines": [', 4), + }), + }); + await assert.rejects( + torn.getTile({ x: 0, y: 0, z: 14 }), + /could not be read/, + ); +}); + +test('an abort stops a queued lookup without fetching, and wins over a retry wait', async () => { + let calls = 0; + let release; + const gate = new Promise((resolve) => (release = resolve)); + const source = createKartaViewSource({ + retryBaseMs: 10_000, + fetchImpl: async (url) => { + calls++; + if (url.includes('/slow')) await gate; + return status(429); + }, + }); + // Two slow lookups fill the queue; the third waits for a slot. + const first = new AbortController(); + const slow = [ + source.getPhoto('slow', { signal: first.signal }), + source.getPhoto('slow', { signal: first.signal }), + ]; + const controller = new AbortController(); + const queued = source.getPhoto('queued', { signal: controller.signal }); + controller.abort(new Error('overtaken')); + await assert.rejects(queued, /overtaken/); + assert.equal(calls, 2, 'the queued lookup never fetched'); + release(); + // Each slow one is now waiting 10 s to retry; nothing else is asked. + const retrying = new AbortController(); + const waiting = source.getPhoto('slow', { signal: retrying.signal }); + await new Promise((resolve) => setTimeout(resolve, 20)); + retrying.abort(new Error('closed')); + await assert.rejects(waiting, /closed/); + // The two first lookups are still waiting out their backoff: drop them. + first.abort(new Error('done')); + for (const promise of slow) await assert.rejects(promise, /done/); +}); + +test('the limiter runs tasks in order and keeps going after a failure', async () => { + const limiter = createLimiter(1); + const order = []; + const failing = limiter.run(async () => { + order.push('a'); + throw new Error('a failed'); + }); + const second = limiter.run(async () => { + order.push('b'); + return 'b'; + }); + await assert.rejects(failing, /a failed/); + assert.equal(await second, 'b'); + assert.deepEqual(order, ['a', 'b']); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(limiter.pending, 0); +}); + +test('tiles, lookups and sequence pages queue separately, so a lookup never waits behind them', async () => { + const running = { tile: 0, photo: 0, page: 0 }; + const peak = { tile: 0, photo: 0, page: 0 }; + const release = []; + const kind = (url) => + url.includes('/tiles/') + ? 'tile' + : url.includes('/photos') + ? 'page' + : 'photo'; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + const k = kind(url); + running[k]++; + peak[k] = Math.max(peak[k], running[k]); + if (k !== 'photo') await new Promise((resolve) => release.push(resolve)); + running[k]--; + return k === 'tile' ? ok({ lines: [] }) : ok(photoPayload([])); + }, + }); + const tiles = Array.from({ length: 9 }, (_, i) => + source.getTile({ x: i, y: 0, z: 14 }), + ); + const pages = [1, 2].map((page) => + source.getSequencePhotos('1', { page, itemsPerPage: 500 }), + ); + // Every slow tile and page is in flight or queued; a lookup still answers. + assert.deepEqual(await source.nearestPhotos({ lat: 1, lon: 1 }), []); + assert.equal(peak.tile, TILE_CONCURRENCY); + assert.equal(peak.page, 1); + while (release.length) { + release.shift()(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + await Promise.all([...tiles, ...pages]); + assert.equal(peak.tile, TILE_CONCURRENCY); + assert.equal(peak.page, 1); +}); + +test('a lookup that times out is asked once more, then reported as no answer in time', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const outcome = await onMockClock(t, () => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + calls++; + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getPhoto('1'), + ); + assert.match( + String(outcome?.error?.message), + /did not answer in time — try again/, + ); + assert.equal(outcome.error.timedOut, true); + assert.equal(calls, 2); +}); + +test('an image probe reads only the status: an archived photo (409) is not showable', async () => { + const seen = []; + const source = createKartaViewSource({ + fetchImpl: async (url, { signal }) => { + seen.push(signal); + return { + ok: !url.includes('archived'), + status: url.includes('archived') ? 409 : 200, + }; + }, + }); + assert.equal( + await source.probeImage('https://storage13.openstreetcam.org/ok.jpg'), + true, + ); + assert.equal( + await source.probeImage('https://storage13.openstreetcam.org/archived.jpg'), + false, + ); + assert.equal(await source.probeImage(null), false); + assert.ok( + seen.every((signal) => signal.aborted), + 'the body is dropped', + ); + const offline = createKartaViewSource({ + fetchImpl: async () => { + throw new TypeError('Failed to fetch'); + }, + }); + // No answer is not an answer: the caller may try again later. + assert.equal( + await offline.probeImage('https://x.openstreetcam.org/a.jpg'), + null, + ); + const busy = createKartaViewSource({ + fetchImpl: async () => ({ ok: false, status: 502 }), + }); + assert.equal( + await busy.probeImage('https://x.openstreetcam.org/a.jpg'), + null, + ); +}); + +test('a cold tile that times out is asked once more; an unreachable one is not', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const outcome = await onMockClock(t, () => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + calls++; + if (calls === 1) + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + return Promise.resolve(ok({ lines: [] })); + }, + }).getTile({ x: 1, y: 2, z: 13 }), + ); + assert.deepEqual(outcome.value, { lines: [] }); + assert.equal(calls, 2); + let asked = 0; + const offline = createKartaViewSource({ + fetchImpl: async () => { + asked++; + throw new TypeError('Failed to fetch'); + }, + }); + await assert.rejects( + offline.getTile({ x: 1, y: 2, z: 13 }), + /could not be reached/, + ); + assert.equal(asked, 1); +}); + +test('an absurdly large answer is refused before it is parsed', async () => { + let parsed = false; + const source = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map([['content-length', String(64 * 1024 * 1024)]]), + json: async () => { + parsed = true; + return { lines: [] }; + }, + }), + }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/); + assert.equal(parsed, false); +}); + +test('sequence pages run one at a time, the newest first', async () => { + const order = []; + let release; + const gate = new Promise((resolve) => (release = resolve)); + const source = createKartaViewSource({ + fetchImpl: async (url) => { + const page = new URL(url).searchParams.get('page'); + order.push(page); + if (page === '1') await gate; + return ok(photoPayload([])); + }, + }); + const pages = ['1', '2', '3'].map((page) => + source.getSequencePhotos('9', { page, itemsPerPage: 250 }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + release(); + await Promise.all(pages); + assert.deepEqual(order, ['1', '3', '2']); +}); + +/** Every pending promise callback, without moving the mocked clock. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** + * Run `call` on the mocked clock in 100 ms steps until it settles (60 s at + * most). `stamp()` reads the clock, for a fetch to record when it was asked. + */ +async function onMockClock(t, call, { until = 60_000 } = {}) { + let now = 0; + const stamp = () => now; + let outcome = null; + const promise = call(stamp); + promise.then( + (value) => (outcome = { value, at: now }), + (error) => (outcome = { error, at: now }), + ); + while (!outcome && now < until) { + await flush(); + if (outcome) break; + t.mock.timers.tick(100); + now += 100; + } + await flush(); + return outcome; +} + +test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and 2 s without one', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + async function waits(retryAfter) { + const asked = []; + const headers = new Map( + retryAfter === undefined ? [] : [['retry-after', retryAfter]], + ); + const outcome = await onMockClock(t, (stamp) => + createKartaViewSource({ + fetchImpl: async () => { + asked.push(stamp()); + return asked.length <= PHOTO_API_RETRIES + ? status(429, headers) + : ok(photoPayload({ id: '1' })); + }, + }).getPhoto('1'), + ); + assert.deepEqual(outcome?.value, { id: '1' }, `answered (${retryAfter})`); + return asked.slice(1).map((at, i) => at - asked[i]); + } + assert.deepEqual(await waits('0'), [800, 1600, 3200], 'doubling backoff'); + assert.deepEqual(await waits('5'), [5000, 5000, 5000], 'Retry-After wins'); + assert.deepEqual(await waits('30'), [10_000, 10_000, 10_000], 'capped'); + assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s'); +}); + +test('a lookup that hangs is given up after a short first try, then the full timeout, then reported', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const asked = []; + const outcome = await onMockClock(t, (stamp) => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + asked.push(stamp()); + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getPhoto('1'), + ); + assert.deepEqual(asked, [0, PHOTO_API_FIRST_TIMEOUT_MS]); + assert.match(String(outcome?.error?.message), /did not answer in time/); + assert.equal(outcome.at, PHOTO_API_FIRST_TIMEOUT_MS + PHOTO_API_TIMEOUT_MS); +}); + +test('the nearest lookup asks for the few photos around the point at the search zoom', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(new URL(url)); + return ok(photoPayload([])); + }, + }); + await source.nearestPhotos({ lat: 42.33, lon: -83.04 }); + assert.equal(urls[0].pathname, '/2.0/photo/'); + assert.deepEqual(Object.fromEntries(urls[0].searchParams), { + lat: '42.3300000', + lng: '-83.0400000', + zoomLevel: String(NEAREST_SEARCH_ZOOM), + itemsPerPage: String(NEAREST_LIMIT), + }); + assert.equal(NEAREST_SEARCH_ZOOM, 18); + assert.equal(NEAREST_LIMIT, 10); +}); + +test('a chunked answer with no length is cut off once it grows past the cap', async () => { + let reads = 0; + let cancelled = false; + const chunk = new Uint8Array(6 * 1024 * 1024); + const source = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => ({ + read: async () => + ++reads > 10 ? { done: true } : { done: false, value: chunk }, + cancel: async () => { + cancelled = true; + }, + }), + }, + }), + }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/); + assert.equal(reads, 3, 'stopped at the third 6 MB chunk'); + assert.equal(cancelled, true); + const small = createKartaViewSource({ + fetchImpl: async () => { + const bytes = new TextEncoder().encode('{"lines":[]}'); + let given = false; + return { + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => ({ + read: async () => + given + ? { done: true } + : ((given = true), { done: false, value: bytes }), + cancel: async () => {}, + }), + }, + }; + }, + }); + assert.deepEqual(await small.getTile({ x: 1, y: 2, z: 13 }), { lines: [] }); +}); + +test('a chunked answer split mid-character is reassembled in order', async () => { + const payload = { + lines: [ + { + track: [{ lat: '1', lng: '2' }], + name: 'Strada Memorandumului — Cluj-Napoca 😀', + }, + ], + note: 'ăîşţ'.repeat(200), + }; + const source = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + // 3-byte chunks cut multi-byte characters in two. + body: streamed(JSON.stringify(payload), 3), + }), + }); + assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), payload); +}); + +test('a sequence page gets the full 30 s on its first try, then 30 s once more', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const asked = []; + const outcome = await onMockClock( + t, + (stamp) => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + asked.push(stamp()); + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getSequencePhotos('9', { page: 1, itemsPerPage: 250 }), + { until: 120_000 }, + ); + assert.deepEqual(asked, [0, PAGE_TIMEOUT_MS]); + assert.match(String(outcome?.error?.message), /did not answer in time/); + assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS); +}); + +test('an answer turned away unread (an error, a 429, a length past the cap) lets its connection go', async () => { + /** A response whose body is never read: records its cancel and the abort. */ + const unread = (answer) => { + const seen = { cancelled: false, signal: null }; + const fetchImpl = async (url, { signal }) => { + seen.signal = signal; + return { + ...answer, + body: { + locked: false, + getReader: () => { + throw new Error('not read'); + }, + cancel: async () => { + seen.cancelled = true; + }, + }, + }; + }; + return { seen, fetchImpl }; + }; + const cases = [ + [{ ok: false, status: 503, headers: new Map() }, /HTTP 503/], + [ + { + ok: true, + status: 200, + headers: new Map([['content-length', String(64 * 1024 * 1024)]]), + }, + /too much data/, + ], + ]; + for (const [answer, message] of cases) { + const { seen, fetchImpl } = unread(answer); + const source = createKartaViewSource({ fetchImpl }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), message); + assert.equal(seen.signal.aborted, true, `${message}: request aborted`); + assert.equal(seen.cancelled, true, `${message}: body cancelled`); + } + // A 429 is let go before its retry is asked. + const { seen, fetchImpl } = unread({ + ok: false, + status: 429, + headers: new Map([['retry-after', '0']]), + }); + const signals = []; + const source = createKartaViewSource({ + fetchImpl: async (url, init) => { + signals.push(init.signal); + if (signals.length === 1) return fetchImpl(url, init); + return ok(photoPayload([])); + }, + }); + await source.getPhoto('1'); + assert.equal(signals[0].aborted, true); + assert.equal(seen.cancelled, true); + assert.equal(signals[1].aborted, false, 'a read answer is left alone'); +}); + +test('a filtered tile comes as PNG bytes; KartaView still drawing it (504) is asked again, then given up', async () => { + const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]); + const bytesOf = () => ({ + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => { + let given = false; + return { + read: async () => + given + ? { done: true } + : ((given = true), { done: false, value: png }), + cancel: async () => {}, + }; + }, + }, + }); + const urls = []; + let answers = [status(504), status(503), bytesOf()]; + const source = createKartaViewSource({ + maskRetryMs: 0, + fetchImpl: async (url) => { + urls.push(url); + return answers.shift(); + }, + }); + const tile = { x: 13054, y: 8474, z: 14 }; + assert.deepEqual(await source.getTileMask(tile, 'fieldOfView=360'), png); + assert.deepEqual(urls, [ + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + ]); + // Still drawing after every try: given up, saying so. + urls.length = 0; + answers = Array.from({ length: 10 }, () => status(504)); + await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), { + message: MASK_SLOW_MESSAGE, + timedOut: true, + }); + assert.equal(urls.length, MASK_ATTEMPTS); + // A refusal is not asked again. + urls.length = 0; + answers = [status(400)]; + await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), /HTTP 400/); + assert.equal(urls.length, 1); +}); + +test('a filtered nearest search asks KartaView for the projection and upload date', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(new URL(url)); + return ok(photoPayload([])); + }, + }); + await source.nearestPhotos({ + lat: 42.3314, + lon: -83.0458, + filter: { pano: 'pano', sinceMs: Date.UTC(2026, 0, 2, 12) }, + }); + await source.nearestPhotos({ lat: 42.3314, lon: -83.0458 }); + assert.equal(urls[0].searchParams.get('projection'), 'SPHERE'); + assert.equal(urls[0].searchParams.get('startDate'), '2026-01-02'); + assert.equal(urls[1].searchParams.has('projection'), false); + assert.equal(urls[1].searchParams.has('startDate'), false); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js new file mode 100644 index 000000000..9aa4639c5 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -0,0 +1,698 @@ +import { + createPanoramaRenderer, + PANO_FOV_DEFAULT_DEG, + wrapDegrees, +} from './panorama.js'; +import { + FLAT_FULL_SIZE, + FLAT_VIEW_SIZE, + IMAGE_TIMEOUT_MS, + KARTAVIEW_PROVIDER_ID, + PANO_FULL_SIZE, + PANO_MID_SIZE, + PANO_PREVIEW_SIZE, + cameraBearing, + kartaViewPhotoUrl, + resizedImageUrl, +} from './policy.js'; + +const LOAD_FAILED = 'The KartaView photo could not be loaded'; + +/** + * Load an image within the timeout. A CORS-clean one (for WebGL) is decoded + * off the main thread into an ImageBitmap where the browser can: uploading + * a decoded bitmap costs a fraction of uploading an (90 ms → 15 ms for + * 4096×2048 on a phone-class CPU). + */ +export function loadImage( + url, + { crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {}, +) { + if ( + crossOrigin && + url && + typeof globalThis.createImageBitmap === 'function' && + typeof globalThis.fetch === 'function' + ) + return loadBitmap(url, timeoutMs); + return new Promise((resolve, reject) => { + if (!url) return reject(new Error(LOAD_FAILED)); + const image = new Image(); + if (crossOrigin) image.crossOrigin = 'anonymous'; + image.decoding = 'async'; + const timer = setTimeout(() => { + image.src = ''; + reject(new Error(LOAD_FAILED)); + }, timeoutMs); + image.onload = () => { + clearTimeout(timer); + resolve(image); + }; + image.onerror = () => { + clearTimeout(timer); + reject(new Error(LOAD_FAILED)); + }; + image.src = url; + }); +} + +async function loadBitmap(url, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + mode: 'cors', + signal: controller.signal, + }); + if (!response.ok) throw new Error(LOAD_FAILED); + return await createImageBitmap(await response.blob()); + } catch { + throw new Error(LOAD_FAILED); + } finally { + clearTimeout(timer); + } +} + +/** Free a decoded bitmap nobody will draw (an has nothing to free). */ +function release(image) { + image?.close?.(); +} + +/** The panorama size a canvas this wide can show sharply at this field of view. */ +export function panoSizeFor(canvasPixels, fovDeg) { + const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg); + return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE; +} + +/** + * Viewer adapter for KartaView photos: a flat photo in an , a 360° + * photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step + * along the sequence (also ← →). Poses follow the view direction. + * + * @param {object} options + * @param {(photoId: string) => Promise} options.getPhoto A normalized photo (policy.js normalizePhoto). + * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. + * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). + * @param {(photo: object) => void} [options.onShown] A photo is on screen. + * @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`). + * @param {object} [options.render] Render governor. + * @param {(url: string, options?: object) => Promise} [options.load] + * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] + * @returns {import('../../registry.js').ViewerAdapter} + */ +export function createKartaViewViewer({ + getPhoto, + step, + onStep, + onShown = () => {}, + onReopen = onStep, + render, + load = loadImage, + createRenderer = createPanoramaRenderer, +}) { + const listeners = new Set(); + let root = null; + let picture = null; + let canvas = null; + let prevButton = null; + let nextButton = null; + let renderer = null; + /** WebGL failed once: 360° photos show their front view instead. */ + let rendererFailed = false; + let photo = null; + /** What shows: 'flat', 'pano' or null. */ + let showing = null; + let openSeq = 0; + let renderMode = 'letterbox'; + let drag = null; + let poseFrame = 0; + let removeListeners = []; + /** The document the viewer was built in (new canvases come from it). */ + let documentOf = null; + /** A context loss's look direction, for the reopen of that photo. */ + let restoreView = null; + /** Width of the panorama texture on screen (0: the preview). */ + let panoWidth = 0; + /** The flat photo on screen is the 2,560 px version. */ + let flatSharp = false; + /** The sharper version loading ({seq, kind}): a resize starts no second one. */ + let upgrading = null; + /** A resize came while it loaded: look again once it lands. */ + let upgradeAgain = false; + /** The photo an open is fetching: ‹ › step on from it, not the old one. */ + let openingId = null; + /** ‹ › chose this photo: keep the panorama's look direction for it. */ + let keepViewFor = null; + /** ‹ › presses run one after another, each from where the last one went. */ + let steps = Promise.resolve(); + + function requestRender() { + render?.governorRequestRender?.('kartaview-viewer'); + } + + function emit() { + if (!photo || !showing) return; + // A lost GPU context, still being recovered: the open that redraws the + // panorama poses once it is on screen. + if (showing === 'pano' && !renderer) return; + const view = showing === 'pano' ? renderer.getView() : null; + const heading = cameraBearing(photo); + const pose = { + providerId: KARTAVIEW_PROVIDER_ID, + imageId: photo.id, + position: { lon: photo.lon, lat: photo.lat }, + bearing: + heading === null + ? null + : view + ? wrapDegrees(heading + view.yaw) + : heading, + tilt: view ? view.pitch : 0, + altitude: null, + isPano: showing === 'pano', + capturedAt: photo.capturedAt, + capturedAtPrecision: 'day', + // KartaView's only place name is the sequence's, where the drive began. + title: null, + creator: null, + sequenceId: photo.sequenceId, + externalUrl: kartaViewPhotoUrl(photo), + }; + for (const listener of [...listeners]) { + try { + listener(pose); + } catch { + /* listener errors are the core's to log */ + } + } + requestRender(); + } + + /** Re-emit on the next frame: a drag moves the view many times a frame. */ + function emitSoon() { + if (poseFrame) return; + poseFrame = requestAnimationFrame(() => { + poseFrame = 0; + emit(); + }); + } + + function syncButtons() { + const enabled = Boolean(photo?.sequenceId) && showing !== null; + if (prevButton) prevButton.disabled = !enabled; + if (nextButton) nextButton.disabled = !enabled; + if (enabled) checkEnds(photo); + } + + /** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */ + function checkEnds(target) { + for (const [delta, button] of [ + [-1, prevButton], + [1, nextButton], + ]) + step(target, delta).then( + (id) => { + if (photo === target && button && !id) disable(button); + }, + () => {}, + ); + } + + /** Disable a step button, keeping keyboard focus in the viewer. */ + function disable(button) { + if (button.ownerDocument?.activeElement === button) root?.focus?.(); + button.disabled = true; + } + + function show(kind) { + showing = kind; + if (picture) picture.hidden = kind !== 'flat'; + if (canvas) canvas.hidden = kind !== 'pano'; + if (root) root.dataset.kind = kind || ''; + syncButtons(); + } + + function applyRenderMode() { + if (picture) + picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain'; + } + + function ensureRenderer() { + if (renderer || rendererFailed || !canvas) return renderer; + try { + renderer = createRenderer(canvas); + } catch { + rendererFailed = true; + renderer = null; + } + return renderer; + } + + /** + * A lost WebGL context (GPU reset, memory pressure) takes the panorama + * with it: drop the renderer, and show the photo again on a new one. + */ + function onContextLost(event) { + event.preventDefault?.(); + const lost = renderer; + renderer = null; + // The new renderer starts facing the photo's heading: keep the user's look. + if (showing === 'pano' && photo && lost) + restoreView = { id: photo.id, view: lost.getView() }; + try { + lost?.destroy(); + } catch { + /* already gone */ + } + // A lost canvas hands back its lost context: draw on a new element. + const fresh = makeCanvas(documentOf); + fresh.hidden = canvas.hidden; + canvas.replaceWith?.(fresh); + canvas = fresh; + // Show the photo again through the core, unless a newer one is opening. + if (showing === 'pano' && photo && openingId === null) { + const again = photo.id; + show(null); + openingId = again; + onReopen(again); + } + } + + /** The panorama canvas, wired for drag, pinch, wheel and context loss. */ + function makeCanvas(doc) { + const element = doc.createElement('canvas'); + element.className = 'sl-kv-pano'; + element.hidden = true; + listen(element, 'pointerdown', onPointerDown); + listen(element, 'pointermove', onPointerMove); + listen(element, 'pointerup', onPointerUp); + listen(element, 'pointercancel', onPointerUp); + listen(element, 'wheel', onWheel, { passive: false }); + listen(element, 'webglcontextlost', onContextLost); + return element; + } + + /** + * Step along the sequence from the photo showing, or from the one on its + * way after an earlier press, and open it through the core. + */ + function stepBy(delta) { + steps = steps.then(async () => { + const fromId = openingId ?? photo?.id; + if (!fromId || !root) return; + let from; + let nextId = null; + try { + from = await getPhoto(fromId); + if (from.sequenceId) nextId = await step(from, delta); + } catch { + // Not the end of the sequence, just no answer: the press is lost. + return; + } + // Closed, or another photo was opened meanwhile. + if ((openingId ?? photo?.id) !== fromId) return; + if (!nextId) { + const button = delta < 0 ? prevButton : nextButton; + if (button && photo?.id === fromId) disable(button); + return; + } + openingId = nextId; + keepViewFor = nextId; + onStep(nextId); + }); + } + + /** Pointers on the panorama: one drags the view, two pinch the zoom. */ + const pointers = new Map(); + let pinch = null; + + function pinchSpan() { + const [a, b] = [...pointers.values()]; + return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)); + } + + function onPointerDown(event) { + if (showing !== 'pano' || !renderer) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + canvas.setPointerCapture?.(event.pointerId); + if (pointers.size === 2) { + drag = null; + pinch = { span: pinchSpan(), fov: renderer.getView().fov }; + return; + } + drag = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + view: renderer.getView(), + }; + canvas.classList.add('sl-kv-dragging'); + } + + function onPointerMove(event) { + if (pointers.has(event.pointerId)) + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (pinch && pointers.size === 2 && renderer) { + renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) }); + return; + } + if (!drag || event.pointerId !== drag.id || !renderer) return; + const { fov } = drag.view; + const width = Math.max(1, canvas.clientWidth); + const height = Math.max(1, canvas.clientHeight); + const vertical = fov; + const horizontal = fov * (width / height); + renderer.setView({ + yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal, + pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical, + }); + emitSoon(); + } + + function onPointerUp(event) { + pointers.delete(event.pointerId); + if (pointers.size < 2) pinch = null; + if (!drag || event.pointerId !== drag.id) return; + drag = null; + canvas.classList.remove('sl-kv-dragging'); + } + + function onWheel(event) { + if (showing !== 'pano' || !renderer) return; + event.preventDefault(); + const { fov } = renderer.getView(); + renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) }); + } + + function onKeyDown(event) { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey + ) + return; + const pano = showing === 'pano' && renderer; + const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key]; + // Shift+← → look around a panorama; ← → step along the sequence. + if (turn && event.shiftKey && pano) { + event.preventDefault(); + const { yaw, fov } = renderer.getView(); + renderer.setView({ yaw: yaw + (turn * fov) / 4 }); + emitSoon(); + } else if (turn) { + event.preventDefault(); + stepBy(turn); + } else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) { + event.preventDefault(); + const { pitch, fov } = renderer.getView(); + renderer.setView({ + pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6, + }); + emitSoon(); + } else if (['+', '=', '-', '_'].includes(event.key) && pano) { + event.preventDefault(); + const { fov } = renderer.getView(); + renderer.setView({ + fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85), + }); + } + } + + function listen(target, type, handler, options) { + target.addEventListener(type, handler, options); + removeListeners.push(() => + target.removeEventListener(type, handler, options), + ); + } + + function build(host) { + const doc = host.ownerDocument; + documentOf = doc; + root = doc.createElement('div'); + root.className = 'sl-kv-viewer'; + root.tabIndex = 0; + root.setAttribute('role', 'group'); + root.setAttribute( + 'aria-label', + 'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom', + ); + picture = doc.createElement('img'); + picture.className = 'sl-kv-photo'; + picture.alt = 'KartaView street-level photo'; + picture.draggable = false; + picture.hidden = true; + canvas = makeCanvas(doc); + const nav = (label, glyph, delta) => { + const button = doc.createElement('button'); + button.type = 'button'; + button.className = `sl-kv-step sl-kv-step-${delta < 0 ? 'prev' : 'next'}`; + button.textContent = glyph; + button.title = label; + button.setAttribute('aria-label', label); + button.disabled = true; + listen(button, 'click', (event) => { + event.stopPropagation(); + stepBy(delta); + }); + return button; + }; + prevButton = nav('Previous photo in this sequence (←)', '‹', -1); + nextButton = nav('Next photo in this sequence (→)', '›', 1); + root.append(picture, canvas, prevButton, nextButton); + host.append(root); + listen(root, 'keydown', onKeyDown); + applyRenderMode(); + } + + function teardown() { + pointers.clear(); + pinch = null; + for (const remove of removeListeners) remove(); + removeListeners = []; + if (poseFrame) cancelAnimationFrame(poseFrame); + poseFrame = 0; + drag = null; + renderer?.destroy(); + renderer = null; + root?.remove(); + root = picture = canvas = prevButton = nextButton = null; + showing = null; + photo = null; + } + + /** A sharper version once the first is on screen, if nothing replaced it. */ + async function upgrade(target, seq, kind) { + // A 360° photo shown flat (no WebGL) keeps its front view. + if (kind === 'flat' && target.isPano) return; + const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE; + if (kind === 'pano' && size[0] <= panoWidth) return; + // A docked viewer shows the 1,280 px version in full: the 2,560 px one + // (~0.6 MB a photo) waits until the viewer is bigger than that. + if (kind === 'flat' && (flatSharp || flatPixels() <= FLAT_VIEW_SIZE[0])) + return; + const url = + kind === 'pano' + ? resizedImageUrl(target.urls.pano, size) + : resizedImageUrl(target.urls.full, size); + if (!url) return; + // Resizes come in bursts while the panel animates: one load at a time + // for this photo, and one more look once it lands. + if (upgrading?.seq === seq && upgrading.kind === kind) { + upgradeAgain = true; + return; + } + const pending = (upgrading = { seq, kind }); + upgradeAgain = false; + let image = null; + try { + image = await load(url, { crossOrigin: kind === 'pano' }); + if (seq !== openSeq || photo !== target || showing !== kind) return; + if (kind === 'pano') { + if (!renderer || size[0] <= panoWidth) return; + renderer.setImage(image); + image = null; + panoWidth = size[0]; + } else { + picture.src = image.src; + image = null; + flatSharp = true; + } + requestRender(); + } catch { + /* the first version stays */ + } finally { + release(image); + if (upgrading === pending) { + upgrading = null; + if (upgradeAgain && seq === openSeq && photo === target) { + upgradeAgain = false; + upgrade(target, seq, showing); + } + } + } + } + + /** Device pixels across the flat photo's frame. */ + function flatPixels() { + const ratio = globalThis.devicePixelRatio || 1; + return ( + Math.max(picture?.clientWidth || 0, picture?.clientHeight || 0) * ratio + ); + } + + /** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */ + function panoSizeNow() { + const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); + const width = (canvas?.clientWidth || 0) * ratio; + return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG); + } + + async function showPano(target, seq, keepView) { + // Only the proxy can shrink a 13,000 px panorama: if it fails, the + // caller shows the front view rather than waiting on it twice. + const image = await load( + resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE), + { crossOrigin: true }, + ); + if (seq !== openSeq) { + release(image); + return false; + } + // The context may have been lost while this loaded: build on the new + // canvas, or let the caller show the front view. + if (!ensureRenderer()) { + release(image); + throw new Error(LOAD_FAILED); + } + const restore = restoreView?.id === target.id ? restoreView.view : null; + restoreView = null; + if (restore) renderer.setView(restore); + else if (!keepView || showing !== 'pano') + renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }); + photo = target; + // Shown first: a hidden canvas measures 0 and would be sized 1×1. + show('pano'); + renderer.setImage(image); + panoWidth = 0; + return true; + } + + async function showFlat(target, seq) { + // The proxy's 1,280 px version is a few hundred kB; KartaView's own + // "large thumbnail" can be 1.7 MB. Either serves when the other fails. + // A 360° photo's front view is only on storage. + const proxied = target.isPano + ? null + : resizedImageUrl(target.urls.full, FLAT_VIEW_SIZE); + const sources = [proxied, target.urls.view].filter(Boolean); + let image; + let failure = null; + for (const url of sources) { + try { + image = await load(url, { crossOrigin: false }); + break; + } catch (error) { + failure = error; + if (seq !== openSeq) return false; + } + } + if (!image) throw failure || new Error(LOAD_FAILED); + if (seq !== openSeq) return false; + picture.src = image.src; + flatSharp = false; + photo = target; + show('flat'); + return true; + } + + async function openPhoto(id, seq, keepView) { + const target = await getPhoto(id); + if (seq !== openSeq) return; + let shown = false; + if (target.isPano && target.urls.pano && ensureRenderer()) { + try { + shown = await showPano(target, seq, keepView); + } catch { + // The panorama failed: its front view is better than nothing. + if (seq !== openSeq) return; + shown = await showFlat(target, seq); + } + } else shown = await showFlat(target, seq); + if (!shown) return; + emit(); + onShown(target); + upgrade(target, seq, showing); + } + + const adapter = { + async mount(host) { + if (!host) throw new Error('Street Level viewer has no host element'); + if (root && root.parentNode === host) return; + teardown(); + rendererFailed = false; + build(host); + }, + + /** Resolves once the photo is on screen and its pose was emitted. */ + async open(imageId) { + if (!root) throw new Error('KartaView viewer is not mounted'); + const id = String(imageId); + const seq = ++openSeq; + const keepView = keepViewFor === id; + keepViewFor = null; + openingId = id; + try { + await openPhoto(id, seq, keepView); + } finally { + if (openingId === id && seq === openSeq) openingId = null; + } + }, + + close() { + openSeq++; + openingId = null; + keepViewFor = null; + photo = null; + show(null); + if (picture) picture.removeAttribute('src'); + renderer?.clear?.(); + }, + + unmount() { + openSeq++; + openingId = null; + keepViewFor = null; + teardown(); + }, + + resize() { + renderer?.resize(); + // Expanded: a docked viewer's first version is now too soft. + if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth) + upgrade(photo, openSeq, 'pano'); + if (showing === 'flat' && photo && !flatSharp) + upgrade(photo, openSeq, 'flat'); + }, + + /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */ + supportsRenderModes: () => showing !== 'pano', + + setRenderMode(mode) { + renderMode = mode === 'fill' ? 'fill' : 'letterbox'; + applyRenderMode(); + }, + + /** The photo's direction became known (its drive loaded): pose again. */ + refreshPose() { + emit(); + }, + + onPose(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return adapter; +} diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs new file mode 100644 index 000000000..1391b727c --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -0,0 +1,984 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createKartaViewViewer, loadImage, panoSizeFor } from './viewer.js'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; + +const PHOTOS = { + flat1: { + id: 'flat1', + sequenceId: 's1', + sequenceIndex: 1, + lon: -83.04, + lat: 42.33, + heading: 90, + isPano: false, + capturedAt: Date.UTC(2016, 7, 23), + urls: { + view: 'https://storage2.openstreetcam.org/lth/a.jpg', + full: 'https://storage2.openstreetcam.org/proc/a.jpg', + pano: null, + }, + }, + flat2: { + id: 'flat2', + sequenceId: 's1', + sequenceIndex: 2, + lon: -83.0401, + lat: 42.33, + heading: 92, + isPano: false, + capturedAt: Date.UTC(2016, 7, 23), + urls: { + view: 'https://storage2.openstreetcam.org/lth/b.jpg', + full: 'https://storage2.openstreetcam.org/proc/b.jpg', + pano: null, + }, + }, + pano1: { + id: 'pano1', + sequenceId: 's2', + sequenceIndex: 7, + lon: 106.84, + lat: -6.19, + heading: 150, + isPano: true, + capturedAt: Date.UTC(2022, 2, 21), + urls: { + view: 'https://storage13.openstreetcam.org/lth/p.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', + }, + }, + pano2: { + id: 'pano2', + sequenceId: 's2', + sequenceIndex: 8, + lon: 106.8401, + lat: -6.19, + heading: 152, + isPano: true, + capturedAt: Date.UTC(2022, 2, 21), + urls: { + view: 'https://storage13.openstreetcam.org/lth/q.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg', + }, + }, +}; + +function fakeRenderer(canvas) { + const view = { yaw: 0, pitch: 0, fov: 80 }; + return { + canvas, + images: [], + hiddenAtSetImage: [], + setImage(image) { + this.images.push(image.src); + // A hidden canvas measures 0×0: sizing it then gives a 1×1 buffer. + this.hiddenAtSetImage.push(canvas?.hidden); + }, + setView(next) { + Object.assign(view, next); + }, + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() { + this.destroyed = true; + }, + }; +} + +PHOTOS.pano2 = { + ...PHOTOS.pano1, + id: 'pano2', + sequenceIndex: 8, + urls: { + view: 'https://storage13.openstreetcam.org/lth/p2.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg', + }, +}; + +function setup({ + failLoads = [], + rendererThrows = false, + steps = {}, + failSteps = false, + hold = () => false, +} = {}) { + const loads = []; + const held = []; + const closed = []; + const renderers = []; + const stepped = []; + const asked = []; + const attempts = { renderer: 0 }; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => { + if (!PHOTOS[id]) throw new Error('no such photo'); + return PHOTOS[id]; + }, + step: async (photo, delta) => { + asked.push(`${photo.id}:${delta}`); + if (failSteps) throw new Error('KartaView did not answer in time'); + return steps[`${photo.id}:${delta}`] ?? null; + }, + // The core opens what ‹ › chose; here the adapter is opened directly. + onStep: (id) => { + stepped.push(id); + viewer.open(id).catch(() => {}); + }, + load: async (url, options) => { + loads.push({ url, crossOrigin: options?.crossOrigin }); + if (failLoads.some((part) => url.includes(part))) + throw new Error('The KartaView photo could not be loaded'); + // A decoded bitmap: close() frees it. + const image = { src: url, close: () => closed.push(url) }; + if (!hold(url)) return image; + return new Promise((resolve) => held.push(() => resolve(image))); + }, + createRenderer: (canvas) => { + attempts.renderer++; + if (rendererThrows) throw new Error('WebGL is unavailable'); + const renderer = fakeRenderer(canvas); + renderers.push(renderer); + return renderer; + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose)); + const host = fakeHost(); + return { + viewer, + poses, + host, + loads, + renderers, + stepped, + asked, + attempts, + held, + closed, + }; +} + +const parts = (host) => { + const root = host.children[0]; + const [picture, canvas, prev, next] = root.children; + return { root, picture, canvas, prev, next }; +}; +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('a flat photo shows its 1280 px version, then the sharp one, with a pose facing its heading', async () => { + const { viewer, poses, host, loads } = setup({ + steps: { 'flat1:1': 'flat2', 'flat1:-1': 'flat0' }, + }); + await viewer.mount(host); + const { picture, canvas, prev, next } = parts(host); + picture.clientWidth = 1600; // expanded: the sharp version is wanted + assert.equal(prev.disabled, true, 'nothing to step through yet'); + await viewer.open('flat1'); + assert.equal(picture.hidden, false); + assert.equal(canvas.hidden, true); + assert.equal(prev.disabled, false); + assert.equal(next.disabled, false); + const pose = poses.at(-1); + assert.deepEqual( + { + providerId: pose.providerId, + imageId: pose.imageId, + position: pose.position, + bearing: pose.bearing, + tilt: pose.tilt, + isPano: pose.isPano, + sequenceId: pose.sequenceId, + capturedAt: pose.capturedAt, + title: pose.title, + }, + { + providerId: 'kartaview', + imageId: 'flat1', + position: { lon: -83.04, lat: 42.33 }, + bearing: 90, + tilt: 0, + isPano: false, + sequenceId: 's1', + capturedAt: Date.UTC(2016, 7, 23), + title: null, + }, + ); + assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//); + // The proxy's 1,280 px version first: KartaView's own can be 1.7 MB. + assert.match( + loads[0].url, + /^https:\/\/cdn\.kartaview\.org\/.*rs:fit:1280:1280/, + ); + assert.equal(loads[0].crossOrigin, false, 'an needs no CORS'); + await settle(); + assert.match( + loads[1].url, + /^https:\/\/cdn\.kartaview\.org\/.*rs:fit:2560:2560/, + ); + assert.equal(picture.src, loads[1].url, 'upgraded to the sharp version'); + assert.equal(viewer.supportsRenderModes(), true); + viewer.setRenderMode('fill'); + assert.equal(picture.style.objectFit, 'cover'); +}); + +test('a failing image proxy falls back to the photo on KartaView storage', async () => { + const { viewer, host, loads } = setup({ failLoads: ['cdn.kartaview.org'] }); + await viewer.mount(host); + await viewer.open('flat1'); + assert.equal(loads[1].url, PHOTOS.flat1.urls.view); + assert.equal(parts(host).picture.hidden, false); + assert.equal(parts(host).picture.src, PHOTOS.flat1.urls.view); +}); + +test('a 360° photo shows on the panorama, preview first; dragging turns the bearing', async () => { + const { viewer, poses, host, loads, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const { picture, canvas } = parts(host); + assert.equal(canvas.hidden, false); + assert.equal(picture.hidden, true); + assert.match(loads[0].url, /rs:fit:1024:512/); + assert.equal(loads[0].crossOrigin, true, 'WebGL textures need CORS'); + assert.equal(poses.at(-1).isPano, true); + assert.equal(poses.at(-1).bearing, 150); + assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit'); + await settle(); + // Docked (400 px at 80°), 2,048 px is all it can show. + assert.match(renderers[0].images.at(-1), /rs:fit:2048:1024/); + // Drag left by a quarter of the width: the view turns right. + const frames = []; + globalThis.requestAnimationFrame = (callback) => { + frames.push(callback); + return frames.length; + }; + try { + canvas.fire('pointerdown', { + button: 0, + pointerId: 1, + clientX: 200, + clientY: 100, + }); + canvas.fire('pointermove', { pointerId: 1, clientX: 100, clientY: 100 }); + canvas.fire('pointerup', { pointerId: 1 }); + frames.forEach((callback) => callback()); + } finally { + delete globalThis.requestAnimationFrame; + } + const turned = poses.at(-1).bearing; + assert.ok(turned > 150 && turned < 360, `bearing ${turned} turned right`); +}); + +test('without WebGL, or when the panorama fails, a 360° photo shows its front view', async () => { + const noGl = setup({ rendererThrows: true }); + await noGl.viewer.mount(noGl.host); + await noGl.viewer.open('pano1'); + assert.equal(parts(noGl.host).picture.hidden, false); + assert.equal(noGl.loads[0].url, PHOTOS.pano1.urls.view); + assert.equal(noGl.poses.at(-1).isPano, false); + await settle(); + assert.equal( + noGl.loads.length, + 1, + 'the equirectangular image never replaces it', + ); + + const broken = setup({ failLoads: ['cdn.kartaview.org'] }); + await broken.viewer.mount(broken.host); + await broken.viewer.open('pano1'); + assert.equal(parts(broken.host).picture.hidden, false); + assert.equal(broken.poses.at(-1).imageId, 'pano1'); +}); + +test('‹ › and the arrow keys step along the sequence; ends disable at once', async () => { + const { viewer, poses, host, stepped, asked } = setup({ + steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' }, + }); + await viewer.mount(host); + await viewer.open('flat1'); + const { root, prev, next } = parts(host); + assert.ok( + await until(() => prev.disabled), + 'flat1 is the first photo: known up front', + ); + assert.equal(next.disabled, false); + next.fire('click'); + assert.ok(await until(() => poses.at(-1).imageId === 'flat2')); + assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it'); + assert.ok(await until(() => next.disabled), 'flat2 is the last photo'); + assert.equal(prev.disabled, false); + // A modified arrow, then a plain → (at the end: it only asks). Presses run + // in turn, so once → has asked, a stepping ⌘← would have asked first. + const before = asked.length; + root.fire('keydown', { key: 'ArrowLeft', metaKey: true }); + root.fire('keydown', { key: 'ArrowRight' }); + assert.ok(await until(() => asked.length > before)); + await settle(); + assert.deepEqual( + asked.slice(before), + ['flat2:1'], + 'modified arrows are not ours', + ); + assert.equal(poses.at(-1).imageId, 'flat2'); + root.fire('keydown', { key: 'ArrowLeft' }); + assert.ok(await until(() => poses.at(-1).imageId === 'flat1')); +}); + +test('quick presses each step on from the photo the last one chose', async () => { + const chain = { 'flat1:1': 'flat2', 'flat2:1': 'pano1' }; + const { viewer, host, stepped } = setup({ + steps: chain, + rendererThrows: true, + }); + await viewer.mount(host); + await viewer.open('flat1'); + const { next } = parts(host); + next.fire('click'); + next.fire('click'); + assert.ok(await until(() => stepped.length === 2), `stepped ${stepped}`); + assert.deepEqual(stepped, ['flat2', 'pano1']); +}); + +test('a newer open wins; close hides the photo; unmount tears down the panorama', async () => { + const { viewer, poses, host, renderers } = setup(); + await viewer.mount(host); + const first = viewer.open('pano1'); + const second = viewer.open('flat1'); + await Promise.all([first, second]); + assert.deepEqual( + poses.map((pose) => pose.imageId), + ['flat1'], + 'the overtaken panorama never posed', + ); + assert.equal(renderers.length, 0, 'nor built a WebGL context'); + await viewer.open('pano1'); + viewer.close(); + const { picture, canvas, root } = parts(host); + assert.equal(canvas.hidden, true); + assert.equal(picture.hidden, true); + assert.equal(root.dataset.kind, ''); + await assert.rejects(viewer.open('nope'), /no such photo/); + viewer.unmount(); + assert.equal(host.children.length, 0); + assert.equal(renderers[0].destroyed, true); + await assert.rejects(viewer.open('flat1'), /not mounted/); +}); + +test('a panorama is put on screen before its image is sized into the canvas', async () => { + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('flat1'); + await viewer.open('pano1'); + await settle(); + assert.deepEqual(renderers[0].hiddenAtSetImage, [false, false]); +}); + +test('a step that gets no answer is not the end of the sequence', async () => { + const { viewer, host, poses, asked } = setup({ failSteps: true }); + await viewer.mount(host); + await viewer.open('flat1'); + const { next } = parts(host); + next.disabled = false; + // The ends check asked once already; the press asks again and fails. + const asks = () => asked.filter((ask) => ask === 'flat1:1').length; + next.fire('click'); + assert.ok(await until(() => asks() === 2), 'the press asked'); + await settle(); + assert.equal(next.disabled, false, 'still enabled: try again'); + assert.equal(poses.at(-1).imageId, 'flat1'); +}); + +test('the keyboard looks around a panorama: Shift+arrows turn, ↑ ↓ tilt, + − zoom', async (t) => { + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + t.after(() => delete globalThis.requestAnimationFrame); + const { viewer, host, renderers, stepped, asked } = setup({ + steps: { 'pano1:1': 'flat1' }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + const { root } = parts(host); + const view = () => renderers[0].getView(); + const before = asked.length; + root.fire('keydown', { key: 'ArrowRight', shiftKey: true }); + assert.ok(view().yaw > 0, 'turned right'); + root.fire('keydown', { key: 'ArrowUp' }); + assert.ok(view().pitch > 0, 'looked up'); + const fov = view().fov; + root.fire('keydown', { key: '+' }); + assert.ok(view().fov < fov, 'zoomed in'); + root.fire('keydown', { key: '-' }); + root.fire('keydown', { key: '-' }); + assert.ok(view().fov > fov, 'zoomed out'); + root.fire('keydown', { key: 'ArrowRight' }); + assert.ok(await until(() => stepped.length === 1)); + assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps'); + // Only that press asked, then flat1's ends check: a Shift+→ that stepped + // would have sent the plain → on from flat1. + assert.ok(await until(() => asked.slice(before).length >= 3)); + await settle(); + assert.deepEqual( + asked.slice(before).sort(), + ['flat1:-1', 'flat1:1', 'pano1:1'], + 'none of the others stepped', + ); +}); + +test('two fingers pinch the panorama’s zoom', async () => { + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const { canvas } = parts(host); + const before = renderers[0].getView().fov; + canvas.fire('pointerdown', { + pointerId: 1, + pointerType: 'touch', + clientX: 100, + clientY: 100, + }); + canvas.fire('pointerdown', { + pointerId: 2, + pointerType: 'touch', + clientX: 140, + clientY: 100, + }); + canvas.fire('pointermove', { + pointerId: 2, + pointerType: 'touch', + clientX: 180, + clientY: 100, + }); + assert.ok(renderers[0].getView().fov < before, 'spread zooms in'); + canvas.fire('pointerup', { pointerId: 1 }); + canvas.fire('pointerup', { pointerId: 2 }); +}); + +test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => { + const { viewer, host, renderers, poses, stepped } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const lost = parts(host).canvas; + lost.fire('webglcontextlost'); + assert.equal(renderers[0].destroyed, true); + // A lost canvas only ever gives back its lost context: a new element. + assert.notEqual(parts(host).canvas, lost); + assert.deepEqual(stepped, ['pano1'], 'reopened through the core'); + assert.ok(await until(() => renderers.length === 2), 'a new renderer'); + assert.equal(renderers[1].canvas, parts(host).canvas); + assert.equal(poses.at(-1).imageId, 'pano1'); + assert.equal(parts(host).canvas.hidden, false); +}); + +test('a context lost while a newer photo opens leaves that open alone', async () => { + const { viewer, host, stepped } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const opening = viewer.open('flat1'); + parts(host).canvas.fire('webglcontextlost'); + await opening; + assert.deepEqual(stepped, [], 'no reopen of the old panorama'); + assert.equal(parts(host).picture.hidden, false); +}); + +test('a WebGL failure only lasts until the viewer is mounted again', async () => { + let fail = true; + const built = []; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + load: async (url) => ({ src: url }), + createRenderer: (canvas) => { + if (fail) throw new Error('WebGL is unavailable'); + const renderer = fakeRenderer(canvas); + built.push(renderer); + return renderer; + }, + }); + const host = fakeHost(); + await viewer.mount(host); + await viewer.open('pano1'); + assert.equal(parts(host).picture.hidden, false, 'front view'); + viewer.unmount(); + fail = false; + const again = fakeHost(); + await viewer.mount(again); + await viewer.open('pano1'); + assert.equal(built.length, 1); + assert.equal(parts(again).canvas.hidden, false); +}); + +/** Flat photos a → d of one drive, for the tests that hold loads back. */ +const DRIVE = Object.fromEntries( + ['a', 'b', 'c', 'd'].map((id, index) => [ + id, + { + id, + sequenceId: 'drive', + sequenceIndex: index, + lon: 0.1 + index * 0.0001, + lat: 0.1, + heading: 0, + isPano: false, + capturedAt: 0, + urls: { + view: `https://storage2.openstreetcam.org/lth/${id}.jpg`, + full: `https://storage2.openstreetcam.org/proc/${id}.jpg`, + pano: null, + }, + }, + ]), +); +const DRIVE_ORDER = Object.keys(DRIVE); + +/** Which drive photo, at which size, an image address shows. */ +function shows(src) { + const proxied = + /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:(\d+):\d+\/([\w-]+)$/.exec( + src || '', + ); + const url = proxied ? Buffer.from(proxied[2], 'base64url').toString() : src; + const id = /\/(\w+)\.jpg$/.exec(url || '')?.[1] ?? null; + return proxied ? `${id}@${proxied[1]}` : id; +} + +/** + * A viewer whose photo loads (and, with `slowSteps`, step answers) wait for + * the test. ‹ › open through a stand-in core that, like the real one, awaits + * a tick (the viewer mount) before it opens the photo. + */ +function heldSetup({ slowSteps = false } = {}) { + const loads = []; + const asks = []; + const stepped = []; + const shown = []; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => { + if (!DRIVE[id]) throw new Error('no such photo'); + return DRIVE[id]; + }, + step: (photo, delta) => { + const answer = DRIVE_ORDER[DRIVE_ORDER.indexOf(photo.id) + delta] ?? null; + if (!slowSteps) return Promise.resolve(answer); + return new Promise((resolve) => + asks.push({ key: `${photo.id}:${delta}`, resolve }), + ); + }, + onStep: async (id) => { + stepped.push(id); + await settle(); + viewer.open(id).catch(() => {}); + }, + onShown: (photo) => shown.push(photo.id), + load: (url) => + new Promise((resolve, reject) => + loads.push({ + url, + shows: shows(url), + resolve: () => resolve({ src: url }), + reject: () => + reject(new Error('The KartaView photo could not be loaded')), + }), + ), + createRenderer: () => { + throw new Error('no WebGL in Node'); + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose.imageId)); + const host = fakeHost(); + /** Let the load showing `what` (e.g. 'a@1280') finish. */ + const release = async (what) => { + assert.ok( + await until(() => loads.some((load) => load.shows === what)), + `${what} is loading: ${loads.map((load) => load.shows)}`, + ); + const at = loads.findIndex((load) => load.shows === what); + loads.splice(at, 1)[0].resolve(); + await settle(); + }; + /** Answer the oldest pending step ask for `key` (e.g. 'a:1'). */ + const answer = async (key, value) => { + assert.ok(await until(() => asks.some((ask) => ask.key === key))); + const at = asks.findIndex((ask) => ask.key === key); + asks.splice(at, 1)[0].resolve(value); + await settle(); + }; + return { viewer, host, loads, asks, stepped, shown, poses, release, answer }; +} + +test('quick presses while the next photo is still loading each step on from the last one chosen', async () => { + const { viewer, host, stepped, release } = heldSetup(); + await viewer.mount(host); + const opening = viewer.open('a'); + await release('a@1280'); + await opening; + const { next } = parts(host); + assert.ok(await until(() => !next.disabled)); + // b, c and d never load: each press steps on from the photo on its way. + next.fire('click'); + next.fire('click'); + next.fire('click'); + assert.ok(await until(() => stepped.length >= 3), `stepped ${stepped}`); + await settle(); + assert.deepEqual(stepped, ['b', 'c', 'd']); +}); + +test('a slow sharp version of an older photo never replaces the newer photo', async () => { + const { viewer, host, shown, poses, release } = heldSetup(); + await viewer.mount(host); + // An expanded viewer, so the sharp version is wanted. + parts(host).picture.clientWidth = 1600; + const first = viewer.open('a'); + await release('a@1280'); + await first; + const second = viewer.open('b'); + await release('b@1280'); + await second; + const { picture } = parts(host); + assert.equal(shows(picture.src), 'b@1280'); + await release('a@2560'); // a's upgrade, overtaken + assert.equal(shows(picture.src), 'b@1280', 'a never comes back'); + await release('b@2560'); + assert.equal(shows(picture.src), 'b@2560', 'b’s own upgrade lands'); + assert.deepEqual(shown, ['a', 'b']); + assert.deepEqual(poses, ['a', 'b']); +}); + +test('an overtaken flat open never shows, nor tells the provider it did', async () => { + const { viewer, host, shown, poses, loads, release } = heldSetup(); + await viewer.mount(host); + const first = viewer.open('a'); + assert.ok(await until(() => loads.length === 1), 'a is loading'); + const second = viewer.open('b'); + await release('b@1280'); + await second; + await release('a@1280'); + await first; + const { picture } = parts(host); + assert.equal(shows(picture.src), 'b@1280'); + assert.deepEqual(poses, ['b']); + assert.deepEqual(shown, ['b'], 'onShown only for the photo on screen'); +}); + +test('a photo still loading when the viewer closes stays closed', async () => { + const { viewer, host, shown, poses, loads, release } = heldSetup(); + await viewer.mount(host); + const opening = viewer.open('a'); + assert.ok(await until(() => loads.length === 1), 'a is loading'); + viewer.close(); + await release('a@1280'); + await opening; + const { picture, root } = parts(host); + assert.equal(picture.hidden, true); + assert.equal(root.dataset.kind, ''); + assert.deepEqual(poses, []); + assert.deepEqual(shown, []); +}); + +test('an ends check for an earlier photo does not disable › on the photo now showing', async () => { + const { viewer, host, release, answer } = heldSetup({ slowSteps: true }); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + const second = viewer.open('c'); + await release('c@1280'); + await second; + const { prev, next } = parts(host); + await answer('c:-1', 'b'); + await answer('c:1', 'd'); + // a's check answers last, saying nothing lies before or after it. + await answer('a:-1', null); + await answer('a:1', null); + assert.equal(prev.disabled, false); + assert.equal(next.disabled, false, 'c still has a next photo'); +}); + +test('a press whose answer comes after another photo was opened is dropped', async () => { + const { viewer, host, stepped, release, answer, asks } = heldSetup({ + slowSteps: true, + }); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + await answer('a:-1', null); + await answer('a:1', 'b'); + const { next } = parts(host); + next.disabled = false; + next.fire('click'); + assert.ok(await until(() => asks.some((ask) => ask.key === 'a:1'))); + // The user picks another photo meanwhile. + const other = viewer.open('d'); + await release('d@1280'); + await other; + await answer('a:1', 'b'); + await settle(); + assert.deepEqual(stepped, [], 'the old press does not open b over d'); +}); + +test('a failed open does not become the photo ‹ › step from', async () => { + const { viewer, host, stepped } = setup({ steps: { 'flat1:1': 'flat2' } }); + await viewer.mount(host); + await viewer.open('flat1'); + await assert.rejects(viewer.open('nope'), /no such photo/); + parts(host).next.disabled = false; + parts(host).next.fire('click'); + assert.ok(await until(() => stepped.length === 1)); + assert.deepEqual(stepped, ['flat2'], 'from the photo still showing'); +}); + +test('WebGL is tried once per mount, not again for every 360° photo', async () => { + const { viewer, host, attempts } = setup({ rendererThrows: true }); + await viewer.mount(host); + await viewer.open('pano1'); + await viewer.open('pano2'); + assert.equal(attempts.renderer, 1); + assert.equal(parts(host).picture.hidden, false, 'front views instead'); +}); + +test('a step button that disables under the keyboard hands focus to the viewer', async () => { + const { viewer, host } = setup(); + await viewer.mount(host); + const { root, prev, next } = parts(host); + let focused = 0; + root.focus = () => focused++; + next.ownerDocument = { activeElement: next }; + prev.ownerDocument = { activeElement: root }; + await viewer.open('flat1'); + assert.ok(await until(() => next.disabled && prev.disabled)); + assert.equal(focused, 1, 'only for the focused button'); +}); + +test('› to another 360° photo keeps the look direction; opening one afresh faces its heading', async (t) => { + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + t.after(() => delete globalThis.requestAnimationFrame); + const { viewer, host, renderers, poses } = setup({ + steps: { 'pano1:1': 'pano2' }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + const { root, next } = parts(host); + root.fire('keydown', { key: 'ArrowRight', shiftKey: true }); + const yaw = renderers[0].getView().yaw; + assert.ok(yaw > 0); + next.fire('click'); + assert.ok(await until(() => poses.at(-1).imageId === 'pano2')); + assert.equal(renderers[0].getView().yaw, yaw, 'still looking the same way'); + await viewer.open('pano1'); + assert.equal(renderers[0].getView().yaw, 0, 'a fresh open faces ahead'); +}); + +/** A viewer whose loads for `held` URLs wait until `release()`. */ +function heldPanoSetup(held) { + const renderers = []; + const stepped = []; + let release; + const gate = new Promise((resolve) => (release = resolve)); + const viewer = createKartaViewViewer({ + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep: (id) => { + stepped.push(id); + viewer.open(id).catch(() => {}); + }, + load: async (url) => { + if (held.some((part) => url.includes(part))) await gate; + return { src: url }; + }, + createRenderer: (canvas) => { + const renderer = fakeRenderer(canvas); + renderers.push(renderer); + return renderer; + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose)); + return { viewer, renderers, stepped, poses, host: fakeHost(), release }; +} + +const pano2Preview = Buffer.from( + 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg', +).toString('base64url'); + +test('a context lost while a newer panorama loads shows that one on a new canvas, not a black viewer', async () => { + const { viewer, renderers, poses, host, release, stepped } = heldPanoSetup([ + pano2Preview, + ]); + await viewer.mount(host); + await viewer.open('pano1'); + const opening = viewer.open('pano2'); + await new Promise((resolve) => setTimeout(resolve, 0)); + parts(host).canvas.fire('webglcontextlost'); + release(); + await opening; + assert.equal( + poses.at(-1).imageId, + 'pano2', + 'the newer panorama is on screen', + ); + assert.equal(poses.at(-1).isPano, true); + assert.deepEqual(stepped, [], 'its own open, not a reopen of the old one'); + assert.equal(renderers.length, 2, 'drawn by a new renderer'); + assert.equal(renderers[1].canvas, parts(host).canvas); + assert.equal(parts(host).canvas.hidden, false); +}); + +test('a second context loss, on the new canvas, reopens again; the new canvas still drags', async (t) => { + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + t.after(() => delete globalThis.requestAnimationFrame); + const { viewer, host, renderers, stepped, poses } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + parts(host).canvas.fire('webglcontextlost'); + assert.ok(await until(() => renderers.length === 2)); + parts(host).canvas.fire('webglcontextlost'); + assert.ok(await until(() => renderers.length === 3), 'reopened twice'); + assert.deepEqual(stepped, ['pano1', 'pano1']); + assert.equal(renderers[2].canvas, parts(host).canvas); + const canvas = parts(host).canvas; + const before = poses.at(-1).bearing; + canvas.fire('pointerdown', { + button: 0, + pointerId: 7, + pointerType: 'mouse', + clientX: 200, + clientY: 100, + }); + canvas.fire('pointermove', { pointerId: 7, clientX: 100, clientY: 100 }); + canvas.fire('pointerup', { pointerId: 7 }); + assert.ok(await until(() => poses.at(-1).bearing !== before), 'dragged'); +}); + +test('a context loss keeps where the user was looking', async () => { + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + renderers[0].setView({ yaw: 70, pitch: 12, fov: 60 }); + parts(host).canvas.fire('webglcontextlost'); + assert.ok(await until(() => renderers.length === 2)); + assert.deepEqual(renderers[1].getView(), { yaw: 70, pitch: 12, fov: 60 }); +}); + +test('the panorama is as sharp as the viewer needs: 2,048 docked, 4,096 once expanded', async () => { + assert.deepEqual(panoSizeFor(400, 80), [2048, 1024]); + assert.deepEqual(panoSizeFor(1400, 80), [4096, 2048]); + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + assert.ok(await until(() => renderers[0].images.length === 2)); + assert.match(renderers[0].images[1], /rs:fit:2048:1024/); + // Expanded: the panel tells the viewer it resized. + parts(host).canvas.clientWidth = 1400; + viewer.resize(); + assert.ok(await until(() => renderers[0].images.length === 3)); + assert.match(renderers[0].images[2], /rs:fit:4096:2048/); + viewer.resize(); + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); + assert.equal(renderers[0].images.length, 3, 'not again once sharp'); +}); + +test('a CORS image is decoded off the main thread into an ImageBitmap where the browser can', async (t) => { + const saved = { + fetch: globalThis.fetch, + bitmap: globalThis.createImageBitmap, + }; + t.after(() => { + globalThis.fetch = saved.fetch; + globalThis.createImageBitmap = saved.bitmap; + }); + const fetched = []; + globalThis.fetch = async (url, init) => { + fetched.push({ url, mode: init.mode }); + return url.includes('gone') + ? { ok: false, status: 409 } + : { ok: true, blob: async () => 'blob' }; + }; + globalThis.createImageBitmap = async (blob) => ({ + blob, + width: 4096, + height: 2048, + }); + const bitmap = await loadImage('https://cdn.kartaview.org/a', { + crossOrigin: true, + }); + assert.deepEqual(bitmap, { blob: 'blob', width: 4096, height: 2048 }); + assert.deepEqual(fetched, [ + { url: 'https://cdn.kartaview.org/a', mode: 'cors' }, + ]); + await assert.rejects( + loadImage('https://cdn.kartaview.org/gone', { crossOrigin: true }), + /could not be loaded/, + ); +}); + +test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px one', async () => { + const { viewer, host, loads } = setup(); + await viewer.mount(host); + await viewer.open('flat1'); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 1, 'no 2,560 px download for a 400 px frame'); + parts(host).picture.clientWidth = 1600; + viewer.resize(); + assert.ok(await until(() => loads.length === 2)); + assert.match(loads[1].url, /rs:fit:2560:2560/); + assert.equal(parts(host).picture.src, loads[1].url); + viewer.resize(); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 2, 'once only'); +}); + +test('resizes while a sharper panorama loads start no second load; the last size wins once it lands', async () => { + let first = true; + const { viewer, host, loads, renderers, held } = setup({ + hold: (url) => { + const holdIt = first && url.includes('rs:fit:2048'); + if (holdIt) first = false; + return holdIt; + }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + assert.ok(await until(() => held.length === 1)); + // The panel animates open: a burst of resizes while 2,048 px loads. + parts(host).canvas.clientWidth = 1400; + for (let i = 0; i < 5; i++) viewer.resize(); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 2, 'the preview and the one 2,048 px load'); + held[0](); + assert.ok(await until(() => renderers[0].images.length === 3)); + assert.match(renderers[0].images[1], /rs:fit:2048:1024/); + assert.match(renderers[0].images[2], /rs:fit:4096:2048/); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 3, 'one more load, for the size asked last'); +}); + +test('a preview or sharper version nobody will draw is freed, not left decoded', async () => { + const { viewer, host, renderers, held, closed } = setup({ + hold: (url) => /rs:fit:(1024|2048):/.test(url), + }); + await viewer.mount(host); + // pano1's preview is still loading when flat1 opens over it. + const overtaken = viewer.open('pano1'); + assert.ok(await until(() => held.length === 1)); + await viewer.open('flat1'); + held[0](); + await overtaken; + assert.deepEqual(closed.length, 1); + assert.match(closed[0], /rs:fit:1024/, 'the overtaken preview'); + // pano1 shows; its sharp version lands after flat1 replaced it. + const shown = viewer.open('pano1'); + assert.ok(await until(() => held.length === 2)); + held[1](); + await shown; + assert.ok(await until(() => held.length === 3)); + await viewer.open('flat1'); + held[2](); + assert.ok(await until(() => closed.length === 2)); + assert.match(closed[1], /rs:fit:2048/, 'the overtaken sharp version'); + assert.equal(renderers[0].images.length, 1, 'only the preview was drawn'); +}); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 5b9b5246c..e08a578e4 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -1,21 +1,11 @@ -import * as Cesium from 'cesium'; -import { decodeCoverageTile } from './decode.js'; -import { passesImageryFilter } from '../../filter.js'; import { - createHorizonCull, - groundUnderCamera, - metresBetween, - viewFocus, - visibleBbox, - whenIdle, -} from '../../view.js'; -import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js'; -import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js'; + createLineCoverage, + sequenceIdFromPick as lineSequenceIdFromPick, +} from '../../lineCoverage.js'; +import { decodeCoverageTile } from './decode.js'; import { coverageZoomForHeight, overviewZoomForHeight, - tileBounds, - tilesForBbox, } from '../../tileMath.js'; import { COLORS, @@ -33,885 +23,48 @@ import { SEQUENCE_VIEW_RANGE_PER_HEIGHT, } from './policy.js'; -/** Draped lines stay at most this long while a tile's cast lines build. */ -export const SWAP_MAX_WAIT_MS = 4000; -/** Tiles touched by new mesh samples are redrawn at most this often. */ -const REMESH_INTERVAL_MS = 1500; -/** Gap between redrawing one dirty tile and the next. */ -const REMESH_STAGGER_MS = 120; -/** Old-zoom tiles are kept at most this long after a zoom change. */ -const STALE_TILE_MAX_MS = 6000; -/** Sequences per primitive: smaller batches build, and show, sooner. */ -const SEQUENCE_PRIMITIVE_BATCH = 120; +export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js'; -const PER_TILE_SEQUENCE_CAP = Math.floor( - COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES, -); - -/** Colour for a sequence: Mapillary green, GEV cyan while selected. */ -function sequenceColor({ selected = false } = {}) { - return selected - ? Cesium.Color.fromCssColorString(COLORS.selected) - : Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92); -} - -/** Separates a multi-part sequence's part index from its id in pick ids. */ -const PART_SEPARATOR = '~'; - -/** Pick ids for every part of a sequence; the first part is unsuffixed. */ -function partIds(sequence) { - return sequence.parts.map((_, index) => - index - ? `${PICK_PREFIX.sequence}${sequence.id}${PART_SEPARATOR}${index}` - : `${PICK_PREFIX.sequence}${sequence.id}`, - ); +/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */ +export function mapillaryZoomFor(heightM) { + const sequence = coverageZoomForHeight(heightM); + if (sequence != null) return { zoom: sequence, kind: 'sequence' }; + const overview = overviewZoomForHeight(heightM); + return overview != null ? { zoom: overview, kind: 'overview' } : null; } /** Sequence id for any part's pick id (`mly:seq:[~]`), else null. */ export function sequenceIdFromPick(pickId) { - if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence)) - return null; - const rest = pickId.slice(PICK_PREFIX.sequence.length); - const cut = rest.indexOf(PART_SEPARATOR); - return cut === -1 ? rest : rest.slice(0, cut); + return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence); } -/** - * Whether any point of `box` ({west, south, east, north}) is within the mesh - * sampler's range of `centre`, the ground point under the camera. - */ -export function meshBoxInRange(box, centre) { - // Move the camera's longitude to the box's side of the date line first: - // clamping 179.999 into a box at -179.99 would pick its far edge. - const lon = - centre.lon + - 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); - const nearest = { - lon: Math.min(box.east, Math.max(box.west, lon)), - lat: Math.min(box.north, Math.max(box.south, centre.lat)), - }; - return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; -} - -/** - * Camera-driven coverage: overview points (z0–5) from orbit, sequence lines - * (z11–14) near the ground. In terrain surface mode a tile is drawn draped - * first, then swapped for lines cast to the bare earth (draped lines would - * land on roofs), so coverage never waits on terrain heights. - */ +/** Mapillary's vector-tile coverage through the server proxy. */ export function createCoverage({ state, source }) { - const { render } = state.services; - - function requestRender() { - render?.governorRequestRender?.('mapillary-coverage'); - } - - function ensureTerrainReady() { - return (state.coverage.terrainReady ||= - Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); - } - - function tileKey({ x, y, z }) { - return `${z}/${x}/${y}`; - } - - /** The imagery filter, resolved now: "since N days" moves with the clock. */ - function filter() { - return state.context.getFilter(); - } - - function terrainMode() { - return ( - state.context.getSurface?.() === 'terrain' && - Boolean(state.context.groundCaster) - ); - } - - /** - * A tile's sequences, filtered then capped: capping first could leave a - * dense tile of newer flat captures with no 360° lines at all. - */ - function drawnSequences(entry) { - const drawn = []; - const current = filter(); - for (const sequence of entry.sequenceList) { - if (drawn.length >= PER_TILE_SEQUENCE_CAP) break; - if (passesImageryFilter(sequence, current)) drawn.push(sequence); - } - return drawn; - } - - /** - * Batched primitives for a tile's sequence parts: cast polylines where - * terrain heights are cached, draped ground lines otherwise. Each batch - * records the selection its colours were built with. - */ - function buildSequencePrimitives(sequences) { - const ground = terrainMode() ? state.context.groundCaster : null; - const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; - const selectedId = state.sequence.selectedId ?? null; - const draped = []; - const cast = []; - for (const sequence of sequences) { - const color = Cesium.ColorGeometryInstanceAttribute.fromColor( - sequenceColor({ selected: sequence.id === selectedId }), - ); - const ids = partIds(sequence); - sequence.parts.forEach((coordinates, index) => { - const flat = ground?.castLine(coordinates, { meshAt }); - let positions; - try { - positions = flat - ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) - : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); - } catch { - return; - } - if (positions.length < 2) return; - const geometry = flat - ? new Cesium.PolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - arcType: Cesium.ArcType.NONE, - }) - : new Cesium.GroundPolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - }); - (flat ? cast : draped).push( - new Cesium.GeometryInstance({ - geometry, - id: ids[index], - attributes: { color }, - }), - ); - }); - } - const primitives = []; - for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: true, - selectedId, - primitive: new Cesium.GroundPolylinePrimitive({ - geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance(), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous: true, - allowPicking: true, - }), - }); - for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: false, - selectedId, - primitive: new Cesium.Primitive({ - geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - asynchronous: true, - allowPicking: true, - }), - }); - return { primitives, draped, cast: cast.length }; - } - - /** - * Fetch the terrain heights a tile's lines need, then redraw it cast. - * `castRequested` stops the redraw from casting again at once; a refresh - * `retry`s a tile whose lines are still draped (a failed or partial cast), - * and it is redrawn only when more of its lines can be cast. - */ - function castTile(entry, { retry = false } = {}) { - if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) - return; - entry.castRequested = true; - entry.castAbort = new AbortController(); - const { signal } = entry.castAbort; - const lines = [...entry.sequences.values()].flatMap( - (sequence) => sequence.parts, - ); - const caster = state.context.groundCaster; - caster.prepareLines(lines, { signal }).then(() => { - if (entry.castAbort?.signal === signal) entry.castAbort = null; - const attached = [...state.coverage.tiles.values()].includes(entry); - if (signal.aborted || !attached || !terrainMode()) return; - // Nothing new to cast: keep what is drawn until the next refresh. - // Counted like buildSequencePrimitives: a cast line needs two points. - const castable = lines.filter( - (coords) => caster.castLine(coords)?.length >= 6, - ).length; - if (castable <= (entry.castLines || 0)) { - entry.castRequested = (entry.castLines || 0) > 0; - return; - } - // A remesh may have cleared the flag while this was in flight. - entry.castRequested = true; - redrawTile(entry); - requestMesh(entry); - }); - } - - /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ - function redrawTile(entry) { - const previous = entry.primitives; - entry.primitives = []; - attachPrimitive(entry); - removeWhenReady(entry, previous); - } - - /** Lon/lat under the camera, where mesh range is measured from. */ - function meshCentre() { - const carto = state.viewer?.camera?.positionCartographic; - if (!carto) return null; - return { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), - }; - } - - /** - * A tile's drawn lines with bounding boxes, built once per filter; mesh - * points are densified the first time a line is in range. - */ - function meshParts(entry) { - if (entry.meshParts) return entry.meshParts; - entry.meshParts = []; - for (const sequence of entry.sequences.values()) - for (const part of sequence.parts) { - const box = { - west: Infinity, - south: Infinity, - east: -Infinity, - north: -Infinity, - }; - for (const [lon, lat] of part) { - if (lon < box.west) box.west = lon; - if (lon > box.east) box.east = lon; - if (lat < box.south) box.south = lat; - if (lat > box.north) box.north = lat; - } - entry.meshParts.push({ part, box, points: null }); - } - return entry.meshParts; - } - - /** - * Ask for mesh samples under a cast tile's lines within the sampler's range - * of the camera; the rest are asked for when a camera move brings them in. - */ - function requestMesh(entry) { - const sampler = state.context.meshSampler; - if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; - const centre = meshCentre(); - if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) - return; - const points = []; - for (const line of meshParts(entry)) { - if (!meshBoxInRange(line.box, centre)) continue; - line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); - for (const point of line.points) points.push(point); - } - if (points.length) sampler.request(points); - } - - /** Redraw the tiles new mesh samples fall in, throttled. */ - function onMeshSampled(batch) { - if (!terrainMode() || !state.context.isActive()) return; - for (const entry of state.coverage.tiles.values()) { - if (entry.kind !== 'sequence' || !entry.bounds) continue; - const { west, south, east, north } = entry.bounds; - if ( - batch.some( - ([lon, lat]) => - lon >= west && lon <= east && lat >= south && lat <= north, - ) - ) - state.coverage.remeshDirty.add(entry); - } - if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; - const wait = Math.max( - 0, - REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), - ); - state.coverage.remeshTimer = setTimeout(remesh, wait); - } - - /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ - function remesh() { - state.coverage.remeshTimer = null; - state.coverage.remeshAt = Date.now(); - if (!terrainMode() || !state.context.isActive()) { - state.coverage.remeshDirty.clear(); - return; - } - const attached = new Set(state.coverage.tiles.values()); - const [entry] = state.coverage.remeshDirty; - if (!entry) return; - state.coverage.remeshDirty.delete(entry); - if (attached.has(entry)) { - redrawTile(entry); - requestRender(); - } - if (state.coverage.remeshDirty.size) - state.coverage.remeshTimer = setTimeout( - () => whenIdle(remesh, 500), - REMESH_STAGGER_MS, - ); - } - - state.coverage.remeshDirty = new Set(); - state.context.meshSampler?.onSampled(onMeshSampled); - - /** - * Remove a tile's previous primitives once all its current ones are ready - * (or after a few seconds); detaching the tile removes them at once. - */ - function removeWhenReady(entry, old) { - const scene = state.viewer?.scene; - finishSwap(entry); - if (!scene?.postRender) { - removePrimitives(old); - return; - } - const fresh = entry.primitives; - const started = Date.now(); - const stop = scene.postRender.addEventListener(() => { - const ready = fresh.every( - ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), - ); - if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { - requestRender(); - return; - } - finishSwap(entry); - requestRender(); - }); - entry.swap = { old, stop }; - requestRender(); - } - - function finishSwap(entry) { - if (!entry.swap) return; - entry.swap.stop(); - removePrimitives(entry.swap.old); - entry.swap = null; - } - - function buildOverviewCollection(points) { - const collection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha( - 0.85, - ); - const drawn = []; - const current = filter(); - for (const point of points) { - if (!passesImageryFilter(point, current)) continue; - drawn.push( - collection.add({ - position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), - color: green, - pixelSize: COVERAGE_OVERVIEW_POINT_PX, - // Google 3D terrain and clouds must not hide the near side's dots; - // the horizon cull hides the far side's, which this lets through. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }), - ); - } - return { collection, points: drawn }; - } - - /** Every overview point on the globe, current zoom and old alike. */ - function* overviewPoints() { - for (const entry of drawnEntries()) - if (entry.overviewPoints) yield* entry.overviewPoints; - } - - // Overview points skip the depth test: hide the far hemisphere's. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: overviewPoints, - onChange: requestRender, + return createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile( + await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }), + tile, + ), + zoomFor: mapillaryZoomFor, + colors: COLORS, + pickPrefix: PICK_PREFIX.sequence, + renderKey: 'mapillary-coverage', + tuning: { + maxTiles: COVERAGE_MAX_TILES, + overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES, + maxSequences: COVERAGE_MAX_SEQUENCES, + lineWidthPx: COVERAGE_LINE_WIDTH_PX, + overviewPointPx: COVERAGE_OVERVIEW_POINT_PX, + moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS, + viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M, + viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT, + viewNearM: SEQUENCE_VIEW_NEAR_M, + }, + messages: { + keyRejected: KEY_REJECTED_MESSAGE, + rateLimited: RATE_LIMITED_MESSAGE, + }, }); - - function attachPrimitive(entry) { - const scene = state.viewer?.scene; - if (!scene) return; - if (entry.kind === 'sequence') { - // Lookup, picking and counts follow what is drawn, not the whole tile. - const sequences = drawnSequences(entry); - entry.sequences = new Map(sequences.map((s) => [s.id, s])); - const { primitives, draped, cast } = buildSequencePrimitives(sequences); - entry.primitives = primitives; - entry.count = sequences.length; - for (const { primitive, onGround } of primitives) - (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); - // Fewer lines cast than last time: the caster has dropped heights this - // tile used (a full cache), so it must be cast again. - if (cast < (entry.castLines || 0)) entry.castRequested = false; - entry.castLines = cast; - entry.drapedLines = draped.length; - if (draped.length) castTile(entry); - watchSelection(); - } else { - const { collection, points } = buildOverviewCollection(entry.points); - entry.primitive = collection; - entry.overviewPoints = points; - entry.count = points.length; - scene.primitives.add(collection); - // Cull the new points now, then all of them whenever the camera moves. - horizon.update(points); - } - } - - function removePrimitives(list) { - const scene = state.viewer?.scene; - for (const { primitive, onGround } of list || []) { - try { - (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( - primitive, - ); - } catch { - /* already gone */ - } - } - } - - function detachPrimitive(entry) { - const scene = state.viewer?.scene; - finishSwap(entry); - removePrimitives(entry.primitives); - entry.primitives = []; - if (entry.primitive) { - try { - scene?.primitives?.remove(entry.primitive); - } catch { - /* already gone */ - } - entry.primitive = null; - entry.overviewPoints = null; - } - } - - /** Stop a tile's pending terrain lookup (tile dropped or retired). */ - function cancelCast(entry) { - entry.castAbort?.abort(); - entry.castAbort = null; - } - - function removeTile(key) { - const entry = state.coverage.tiles.get(key); - if (!entry) return; - cancelCast(entry); - detachPrimitive(entry); - state.coverage.tiles.delete(key); - } - - async function loadTile(tile, kind) { - const key = tileKey(tile); - if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) - return; - const controller = new AbortController(); - state.coverage.pending.set(key, controller); - notify(); - try { - // Fetch and the one-time terrain-height table load run side by side, - // both handled from the start so an early tile failure is not unhandled. - const [bytes] = await Promise.all([ - source.getTile('coverage', tile.z, tile.x, tile.y, { - signal: controller.signal, - }), - kind === 'sequence' ? ensureTerrainReady() : null, - ]); - // Only an abort, a retire, a clear or a newer request for this tile - // discards the bytes; a refresh that still wants the tile keeps them. - const current = () => - state.coverage.pending.get(key) === controller && - !state.coverage.tiles.has(key); - if ( - controller.signal.aborted || - !current() || - kind !== state.coverage.kind || - tile.z !== state.coverage.zoom - ) - return; - const decoded = decodeCoverageTile(bytes, tile); - const entry = { kind, primitive: null, primitives: [], count: 0 }; - // Padded: vector tiles carry a small buffer past their edge. - const bounds = tileBounds(tile.x, tile.y, tile.z); - const pad = (bounds.east - bounds.west) * 0.05; - entry.bounds = { - west: bounds.west - pad, - east: bounds.east + pad, - south: bounds.south - pad, - north: bounds.north + pad, - }; - if (kind === 'sequence') { - // Newest first; the per-tile cap applies to what passes the filter. - entry.sequenceList = decoded.sequences.sort( - (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), - ); - entry.sequences = new Map(); - entry.total = decoded.sequences.length; - } else { - entry.points = decoded.overview; - entry.sequences = new Map(); - entry.total = decoded.overview.length; - } - attachPrimitive(entry); - state.coverage.tiles.set(key, entry); - state.coverage.lastError = null; - requestRender(); - } catch (error) { - if ( - controller.signal.aborted || - state.coverage.pending.get(key) !== controller - ) - return; - state.coverage.lastError = error?.message || 'Coverage tile failed'; - if (error?.keyRequired) state.keyRequired = true; - if (error?.keyRejected) { - // Every other tile would be refused too: stop asking. - state.keyRejected = true; - state.coverage.lastError = KEY_REJECTED_MESSAGE; - } - if (error?.retryAfterSec) holdFor(error.retryAfterSec); - } finally { - // Only the request that still owns the key settles it: a superseded one - // must not drop a newer request's entry (which is what counts as loading). - if (state.coverage.pending.get(key) === controller) { - state.coverage.pending.delete(key); - if (!state.coverage.pending.size) purgeStale(); - } - notify(); - } - } - - /** - * Mapillary is rate-limiting: keep what is drawn, request nothing until the - * wait is over, then refresh once. - */ - function holdFor(seconds) { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdUntil = Date.now() + seconds * 1000; - state.coverage.lastError = RATE_LIMITED_MESSAGE; - state.coverage.holdTimer = setTimeout(() => { - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - if (state.coverage.lastError === RATE_LIMITED_MESSAGE) - state.coverage.lastError = null; - refresh(); - }, seconds * 1000); - } - - /** Forget refusals when the provider goes off, so the next run asks again. */ - function resetErrors() { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - state.coverage.lastError = null; - state.keyRejected = false; - } - - /** Drop the previous zoom's tiles once the new ones are on screen. */ - function purgeStale() { - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = null; - if (!state.coverage.stale.size) return; - for (const entry of state.coverage.stale.values()) detachPrimitive(entry); - state.coverage.stale.clear(); - requestRender(); - } - - /** Move tiles to the stale set: the old zoom shows until the new one loads. */ - function retire() { - for (const controller of state.coverage.pending.values()) - controller.abort(); - state.coverage.pending.clear(); - for (const [key, entry] of state.coverage.tiles) { - cancelCast(entry); - const previous = state.coverage.stale.get(key); - if (previous) detachPrimitive(previous); - state.coverage.stale.set(key, entry); - } - state.coverage.tiles.clear(); - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); - } - - function notify() { - state.context.notify(); - } - - /** - * Whether the screen centre meets the ground further ahead than the camera - * is high (a view tilted above 45°), or misses it. - */ - function isTilted({ nadir, ahead }, height) { - if (!ahead) return true; - return metresBetween(nadir, ahead) > Math.max(1, height); - } - - /** Recompute the tile set for the current camera and reconcile primitives. */ - function refresh() { - const viewer = state.viewer; - if (!viewer || !state.context.isActive() || state.keyRequired) return; - if (state.statusKnown === false) return; - if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; - const ground = groundUnderCamera(viewer, { - groundAt: state.context.groundCaster?.groundAt, - }); - const cameraHeight = viewer.camera?.positionCartographic?.height; - const height = Number.isFinite(cameraHeight) - ? cameraHeight - (ground ?? 0) - : null; - const sequenceZoom = coverageZoomForHeight(height); - const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); - const zoom = sequenceZoom ?? overviewZoom; - const kind = sequenceZoom ? 'sequence' : 'overview'; - // At street zooms the rays meet the ground where it really is (1,600 m up - // in Denver; Google 3D hides the globe) and stop short of the horizon. - const ranged = - kind === 'sequence' - ? { - groundHeight: ground, - maxRange: Math.max( - SEQUENCE_VIEW_RANGE_MIN_M, - height * SEQUENCE_VIEW_RANGE_PER_HEIGHT, - ), - } - : {}; - // Tilted street views rank tiles along the line of sight, from the ground - // under the camera to the ground at the centre of the screen. - const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; - let bbox = visibleBbox(viewer, { - ...ranged, - // A tilted view's screen rows jump from the first metres to the horizon: - // keep the ground around the camera too. Looking down needs no help. - nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null, - }); - if (kind === 'overview' && (!bbox || zoom <= 1)) - bbox = [-180, -85, 180, 85]; - if (zoom == null || !bbox) { - state.coverage.hint = - 'Point the camera at the globe for street-level coverage'; - clear(); - notify(); - return; - } - state.coverage.hint = ''; - if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { - retire(); - state.coverage.zoom = zoom; - state.coverage.kind = kind; - } - const { tiles } = tilesForBbox(bbox, zoom, { - limit: - kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, - focus: focus && { from: focus.nadir, to: focus.ahead }, - }); - const wanted = new Set(tiles.map(tileKey)); - for (const key of [...state.coverage.tiles.keys()]) - if (!wanted.has(key)) removeTile(key); - for (const [key, controller] of [...state.coverage.pending]) - if (!wanted.has(key)) { - // The aborted request no longer owns its key, so its `finally` will - // not settle it: settle it here. - controller.abort(); - state.coverage.pending.delete(key); - } - for (const tile of tiles) loadTile(tile, kind); - if (!state.coverage.pending.size) purgeStale(); - // The camera moved: cells that were out of the sampler's range may not be, - // and lines left draped by a failed, partial or dropped cast get another try. - if (terrainMode()) - for (const entry of state.coverage.tiles.values()) { - if (entry.castRequested) requestMesh(entry); - if (entry.drapedLines) castTile(entry, { retry: true }); - } - notify(); - } - - function scheduleRefresh() { - clearTimeout(state.coverage.debounceTimer); - state.coverage.debounceTimer = setTimeout( - refresh, - COVERAGE_MOVE_DEBOUNCE_MS, - ); - } - - function attach(viewer) { - detach(); - const remove = viewer.camera.changed.addEventListener(scheduleRefresh); - const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); - state.coverage.removeCameraListener = () => { - remove(); - removeEnd(); - }; - refresh(); - } - - function detach() { - clearTimeout(state.coverage.debounceTimer); - state.coverage.debounceTimer = null; - state.coverage.removeCameraListener?.(); - state.coverage.removeCameraListener = null; - } - - function clear() { - for (const controller of state.coverage.pending.values()) - controller.abort(); - state.coverage.pending.clear(); - for (const key of [...state.coverage.tiles.keys()]) removeTile(key); - purgeStale(); - state.coverage.zoom = null; - state.coverage.kind = null; - clearTimeout(state.coverage.remeshTimer); - state.coverage.remeshTimer = null; - state.coverage.remeshDirty.clear(); - stopSelectionWatch(); - horizon.stop(); - requestRender(); - } - - /** Rebuild every loaded tile from its decoded cache (after a filter change). */ - function rebuild() { - purgeStale(); - for (const entry of state.coverage.tiles.values()) { - cancelCast(entry); - entry.castRequested = false; - entry.meshParts = null; - detachPrimitive(entry); - attachPrimitive(entry); - } - requestRender(); - notify(); - } - - /** - * Redraw tiles draped or cast for the new surface mode, then re-pick the - * zoom, since the ground height under the camera may have changed. - */ - function setSurface() { - if (!state.context.isActive()) return; - rebuild(); - refresh(); - } - - /** Recolour every part of a sequence in one ready primitive. */ - function recolorInstances(primitive, sequence, selected) { - const value = sequenceColor({ selected }); - for (const instanceId of partIds(sequence)) { - try { - const attributes = primitive.getGeometryInstanceAttributes(instanceId); - if (attributes) - attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( - value, - attributes.color, - ); - } catch { - /* instance not in this primitive */ - } - } - } - - /** Every tile on the globe: the current zoom's and the stale zoom's. */ - function drawnEntries() { - return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; - } - - /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ - function drawnRecords(entry) { - const records = entry.primitives || []; - return entry.swap ? [...records, ...entry.swap.old] : records; - } - - /** Recolour one sequence, every part of it, in place (selection highlight). */ - function recolorSequence(id, selected) { - for (const entry of drawnEntries()) { - const sequence = entry.sequences.get(id); - if (!sequence) continue; - for (const record of drawnRecords(entry)) { - // Still building: `syncSelection` catches it up once it is ready. - if (!record.primitive.ready) continue; - recolorInstances(record.primitive, sequence, selected); - if (selected) record.selectedId = id; - else if (record.selectedId === id) record.selectedId = null; - } - } - requestRender(); - } - - /** - * Bring a ready primitive's highlight up to the current selection, when - * it was built with another one (or none). True when it changed. - */ - function syncSelection(entry, record) { - const current = state.sequence.selectedId ?? null; - if (record.selectedId === current || !record.primitive.ready) return false; - const previous = - record.selectedId && entry.sequences.get(record.selectedId); - if (previous) recolorInstances(record.primitive, previous, false); - const next = current && entry.sequences.get(current); - if (next) recolorInstances(record.primitive, next, true); - record.selectedId = current; - return true; - } - - /** - * A primitive cannot be recoloured until it is ready, so while any is - * building, catch each one up with the selection as it becomes ready. - */ - function watchSelection() { - const scene = state.viewer?.scene; - if (state.coverage.stopSelectionWatch || !scene?.postRender) return; - const stop = scene.postRender.addEventListener(() => { - let building = false; - let changed = false; - for (const entry of drawnEntries()) { - for (const record of entry.primitives || []) { - if (!record.primitive.ready) building = true; - else if (syncSelection(entry, record)) changed = true; - } - // Lines a swap still shows were ready (or never show): catch up only. - for (const record of entry.swap?.old || []) - if (syncSelection(entry, record)) changed = true; - } - if (changed) requestRender(); - if (!building) stopSelectionWatch(); - }); - state.coverage.stopSelectionWatch = stop; - } - - function stopSelectionWatch() { - state.coverage.stopSelectionWatch?.(); - state.coverage.stopSelectionWatch = null; - } - - /** - * Sequences (or overview points) drawn. A sequence that crosses a tile - * edge is in both tiles' lists, so sequences count once by id. - */ - function sequenceCount() { - const ids = new Set(); - let points = 0; - for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') - for (const id of entry.sequences.keys()) ids.add(id); - else points += entry.count; - return ids.size + points; - } - - return { - attach, - detach, - refresh, - clear, - resetErrors, - rebuild, - setSurface, - recolorSequence, - sequenceCount, - }; } diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 6c038566e..c55c0d26e 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -55,8 +55,6 @@ export const COVERAGE_LINE_WIDTH_PX = 2.5; /** Per-sequence image cones after a sequence is selected. */ export const SEQUENCE_IMAGES_LIMIT = 2000; -export const IMAGE_CONE_SIZE_PX = 26; -export const IMAGE_CONE_MIN_SPACING_M = 3; /** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */ export const NEAREST_LIMIT = 8; diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 4be1f54a2..b10902a2b 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -1,20 +1,7 @@ -import * as Cesium from 'cesium'; -import { imageConeGlyph } from '../../glyphs.js'; -import { passesImageryFilter } from '../../filter.js'; -import { refineHeights } from '../../groundCast.js'; -import { createHorizonCull, metresBetween } from '../../view.js'; -import { meshCellKey } from '../../meshSampler.js'; -import { - COLORS, - IMAGE_CONE_MIN_SPACING_M, - IMAGE_CONE_SIZE_PX, - PICK_PREFIX, -} from './policy.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { COLORS, PICK_PREFIX } from './policy.js'; -const SPRITE_ID = 'street-level:mapillary-cones'; - -/** How many recently viewed sequences keep their image list in memory. */ -const SEQUENCE_CACHE_SIZE = 40; +export { thinImages } from '../../sequenceCones.js'; /** Normalize a graph image record into the shape the cones use. */ export function normalizeSequenceImage(record) { @@ -33,290 +20,24 @@ export function normalizeSequenceImage(record) { }; } -/** Drop images closer than the spacing to the previous kept one. */ -export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { - const kept = []; - let last = null; - for (const image of images) { - if (!last || metresBetween(last, image) >= spacingM) { - kept.push(image); - last = image; - } - } - return kept; -} - -/** Image cones for one selected sequence. */ +/** Image cones for the selected Mapillary sequence, its line highlighted. */ export function createSequences({ state, source, parts }) { - const { render, sprites } = state.services; - - function requestRender() { - render?.governorRequestRender?.('mapillary-sequence'); - } - - function notify() { - state.context.notify(); - } - - const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); - /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ - let drawn = nothingDrawn(); - - // The cones skip the depth test: hide the ones behind the globe. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: () => drawn.cones.map((cone) => cone.billboard), - onChange: requestRender, - }); - - function ensureCollections(viewer) { - if (state.sequence.collection) return; - state.sequence.collection = new Cesium.BillboardCollection({ - scene: viewer.scene, - }); - viewer.scene.primitives.add(state.sequence.collection); - sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection); - } - - function clearCones() { - state.sequence.collection?.removeAll(); - state.sequence.images = []; - drawn = nothingDrawn(); - horizon.stop(); - } - - /** Where a cone stands: at `height`, or clamped to the ground when null. */ - function placement(image, height) { - return { - position: Cesium.Cartesian3.fromDegrees( - image.lon, - image.lat, - height ?? 0, - ), - heightReference: - height === null - ? Cesium.HeightReference.CLAMP_TO_GROUND - : Cesium.HeightReference.NONE, - }; - } - - /** - * Terrain-mode cone heights on the street, not on roofs: bare earth refined - * by mesh samples. Null when draped or while terrain heights are fetched. - */ - function coneHeights(images, { request = true } = {}) { - const ground = state.context.groundCaster; - if (state.context.getSurface?.() !== 'terrain' || !ground) return null; - const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); - if (dems.includes(null)) { - ground - .prepare(images.map((image) => [image.lon, image.lat])) - .then((ready) => { - if (ready && state.sequence.images === images) renderCones(images); - }); - return null; - } - const sampler = state.context.meshSampler; - if (request) - sampler?.request(images.map((image) => [image.lon, image.lat])); - return refineHeights( - images.map((image, i) => ({ - dem: dems[i], - mesh: sampler?.meshAt(image.lon, image.lat), - })), - ); - } - - function renderCones(images) { - const collection = state.sequence.collection; - if (!collection) return; - collection.removeAll(); - drawn = { ...nothingDrawn(), images }; - const heights = coneHeights(images); - // One colour per source; 360° images keep the ring shape. - const cone = imageConeGlyph({ size: 32, color: COLORS.coverage }); - const ring = imageConeGlyph({ - size: 32, - color: COLORS.coverage, - pano: true, - }); - // Resolved now, so a "since N days" window keeps up with the clock. - const filter = state.context.getFilter(); - images.forEach((image, index) => { - if (!passesImageryFilter(image, filter)) return; - const height = heights?.[index] ?? null; - const billboard = collection.add({ - id: `${PICK_PREFIX.image}${image.id}`, - ...placement(image, height), - image: image.isPano ? ring : cone, - imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone', - width: IMAGE_CONE_SIZE_PX, - height: IMAGE_CONE_SIZE_PX, - rotation: -Cesium.Math.toRadians(image.compassAngle), - alignedAxis: Cesium.Cartesian3.UNIT_Z, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), - }); - drawn.cones.push({ index, billboard, height }); - drawn.cells.add(meshCellKey(image.lon, image.lat)); - }); - horizon.update(); - requestRender(); - } - - /** - * Re-place cones whose height new mesh samples changed. A sample can move - * a neighbour too, so every height is recomputed but only moved cones touched. - */ - function onMeshSampled(batch) { - if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) - return; - if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) - return; - const { images, cones } = drawn; - // Already asked for when drawn; a missing terrain cell redraws them all. - const heights = coneHeights(images, { request: false }); - if (!heights) return; - const moved = []; - for (const cone of cones) { - const height = heights[cone.index] ?? null; - if (height === cone.height) continue; - cone.height = height; - const { position, heightReference } = placement( - images[cone.index], - height, - ); - cone.billboard.heightReference = heightReference; - cone.billboard.position = position; - moved.push(cone.billboard); - } - if (!moved.length) return; - horizon.update(moved); - requestRender(); - } - - function remember(sequenceId, images) { - const { cache } = state.sequence; - cache.delete(sequenceId); - cache.set(sequenceId, images); - while (cache.size > SEQUENCE_CACHE_SIZE) - cache.delete(cache.keys().next().value); - } - - /** Select a sequence: highlight its line and load its image cones. */ - async function select(sequenceId) { - if (!sequenceId || !state.viewer) return; - // Already on screen, or already on its way. - if ( - state.sequence.selectedId === sequenceId && - (state.sequence.images.length || state.sequence.abort) - ) - return; - if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) - parts.coverage.recolorSequence(state.sequence.selectedId, false); - state.sequence.abort?.abort(); - state.sequence.selectedId = sequenceId; - parts.coverage.recolorSequence(sequenceId, true); - const cached = state.sequence.cache.get(sequenceId); - if (cached) { - state.sequence.abort = null; - state.sequence.loading = false; - state.sequence.images = cached; - renderCones(cached); - state.context.actions.reportError(null); - notify(); - return; - } - // The previous sequence's cones must not stay clickable under the new - // highlight while this one loads, nor after its load fails. - clearCones(); - requestRender(); - const controller = new AbortController(); - state.sequence.abort = controller; - state.sequence.loading = true; - notify(); - try { - const records = await source.getSequenceImages(sequenceId, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - const images = thinImages( + return createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const records = await source.getSequenceImages(sequenceId, { signal }); + return thinImages( records .map(normalizeSequenceImage) .filter(Boolean) .sort((a, b) => a.capturedAt - b.capturedAt), ); - remember(sequenceId, images); - state.sequence.images = images; - renderCones(images); - state.context.actions.reportError(null); - } catch (error) { - if (!controller.signal.aborted) { - // Nothing to show: drop the highlight, so a click asks again. - parts.coverage.recolorSequence(sequenceId, false); - state.sequence.selectedId = null; - state.context.actions.reportError( - error?.message || 'Sequence images unavailable', - ); - } - } finally { - if (state.sequence.abort === controller) { - state.sequence.loading = false; - state.sequence.abort = null; - } - notify(); - } - } - - function clearSelection() { - state.sequence.abort?.abort(); - state.sequence.abort = null; - if (state.sequence.selectedId) - parts.coverage.recolorSequence(state.sequence.selectedId, false); - state.sequence.selectedId = null; - state.sequence.loading = false; - clearCones(); - // An error about the sequence goes with it. - state.context.actions.reportError(null); - requestRender(); - notify(); - } - - /** Re-draw the current sequence's cones (after an imagery filter change). */ - function rerender() { - if (state.sequence.images.length) renderCones(state.sequence.images); - } - - // New mesh samples may move the cones onto (or off) the road surface. - const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); - - function setVisible(visible) { - if (state.sequence.collection) state.sequence.collection.show = visible; - requestRender(); - } - - function destroy(viewer) { - state.sequence.abort?.abort(); - horizon.stop(); - drawn = nothingDrawn(); - stopMeshListener?.(); - const collection = state.sequence.collection; - if (collection) { - sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); - viewer?.scene?.primitives?.remove(collection); - state.sequence.collection = null; - } - state.sequence.images = []; - state.sequence.selectedId = null; - } - - return { - ensureCollections, - select, - clearSelection, - rerender, - setVisible, - destroy, - }; + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:mapillary-cones', + renderKey: 'mapillary-sequence', + onHighlight: (sequenceId, selected) => + parts.coverage.recolorSequence(sequenceId, selected), + }); } diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index a9ed18331..3451447e4 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -22,6 +22,9 @@ export function filtersOf(provider) { * @property {boolean} [defaultOn] Default true. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). + * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). + * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). + * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -32,7 +35,7 @@ export function filtersOf(provider) { * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. - * @property {{openImage: (imageId: string) => Promise, reportError: (message: string|null) => void}} actions + * @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise, openNearest: (point: {lon: number, lat: number}) => Promise, reportError: (message: string|null) => void}} actions * * @typedef {object} ProviderInstance * @property {() => Promise<{configured: boolean}>} status @@ -44,7 +47,7 @@ export function filtersOf(provider) { * @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`). * @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode. * @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats - * @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix. + * @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground. * @property {(sequenceId: string) => Promise} [selectSequence] * @property {() => void} [clearSequence] * @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats] @@ -65,6 +68,7 @@ export function filtersOf(provider) { * @property {() => void} resize * @property {(host: HTMLElement) => Promise} [prewarm] * @property {(mode: 'letterbox'|'fill') => void} [setRenderMode] + * @property {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide. * @property {(listener: (pose: StreetPose) => void) => () => void} onPose * * @typedef {object} StreetPose @@ -157,6 +161,14 @@ export function validateProviders(providers) { throw new TypeError( `Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`, ); + if ( + provider.sinceBasis !== undefined && + provider.sinceBasis !== 'capture' && + provider.sinceBasis !== 'upload' + ) + throw new TypeError( + `Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`, + ); ids.add(provider.id); prefixes.push(provider.pickPrefix); } diff --git a/src/layers/streetLevel/registry.test.mjs b/src/layers/streetLevel/registry.test.mjs index 7981d9acf..1783d7f42 100644 --- a/src/layers/streetLevel/registry.test.mjs +++ b/src/layers/streetLevel/registry.test.mjs @@ -117,4 +117,9 @@ test('a provider may narrow the filters it honours and pick a view-cone marker', () => validateProviders([def({ marker: { kind: 'pegman' } })]), /marker must be/, ); + validateProviders([def({ sinceBasis: 'upload' })]); + assert.throws( + () => validateProviders([def({ sinceBasis: 'shot' })]), + /sinceBasis must be 'capture' or 'upload'/, + ); }); diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index 429608449..e3710dd8e 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -28,7 +28,24 @@ export function createSelection({ state, parts }) { } const entry = state.providers.get(route.providerId); if (!entry?.on) return; - route.instance.handlePick(route.id); + route.instance.handlePick(route.id, { + groundPoint: () => groundPointAt(viewer, click.position), + }); + } + + /** Lon/lat of the ground under a screen point, or null (sky, no picker). */ + function groundPointAt(viewer, screenPosition) { + const position = state.services.scenePick?.pickGroundPosition?.( + viewer, + screenPosition, + ); + const carto = position && Cesium.Cartographic.fromCartesian(position); + return carto + ? { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + } + : null; } /** diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index f35cd4361..72e9e6e0c 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -8,7 +8,12 @@ import { createSelection } from './selection.js'; * `frame()` runs them; timers and Date.now() are mocked so a burst of moves * has a known timeline. */ -function harness({ selected = false } = {}) { +function harness({ + selected = false, + router = null, + providers = new Map(), + services = {}, +} = {}) { const saved = { document: globalThis.document, requestAnimationFrame: globalThis.requestAnimationFrame, @@ -60,12 +65,16 @@ function harness({ selected = false } = {}) { viewer, enabled: true, clickHandler: null, - services: { picking: null, input: null }, + providers, + services: { picking: null, input: null, ...services }, }; const selection = createSelection({ state, parts: { - router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null }, + router: router || { + ownsPick: (id) => id === 'mly:seq:1', + resolve: () => null, + }, hasSelectedSequence: () => sequences.selected, openAtGround: (position, picked) => groundClicks.push({ position, picked }), @@ -282,3 +291,43 @@ test('a click on nothing of ours goes to the ground handler with the pick, which h.restore(); } }); + +test('a click on a provider line hands it the ground under the click, measured only if asked', () => { + const handled = []; + const instance = { + handlePick: (id, click) => handled.push({ id, click }), + }; + const groundPicks = []; + const h = harness({ + router: { + ownsPick: () => true, + resolve: (id) => ({ providerId: 'kartaview', instance, id }), + }, + providers: new Map([['kartaview', { on: true }]]), + services: { + scenePick: { + pickGroundPosition: (viewer, position) => { + groundPicks.push(position); + return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190); + }, + }, + }, + }); + try { + h.scene.under = 'kv:line:14/1/2/3'; + h.click(40); + assert.equal(handled.length, 1); + assert.equal(handled[0].id, 'kv:line:14/1/2/3'); + assert.equal( + groundPicks.length, + 0, + 'no depth read until the provider asks', + ); + const point = handled[0].click.groundPoint(); + assert.ok(Math.abs(point.lon + 83.0458) < 1e-9); + assert.ok(Math.abs(point.lat - 42.3314) < 1e-9); + assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]); + } finally { + h.restore(); + } +}); diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js new file mode 100644 index 000000000..8eb79b29f --- /dev/null +++ b/src/layers/streetLevel/sequenceCones.js @@ -0,0 +1,334 @@ +import * as Cesium from 'cesium'; +import { imageConeGlyph } from './glyphs.js'; +import { passesImageryFilter } from './filter.js'; +import { refineHeights } from './groundCast.js'; +import { createHorizonCull, metresBetween } from './view.js'; +import { meshCellKey } from './meshSampler.js'; + +/** How many recently viewed sequences keep their image list in memory. */ +const SEQUENCE_CACHE_SIZE = 40; + +/** Cones closer than this to the previous kept one are dropped. */ +export const IMAGE_CONE_MIN_SPACING_M = 3; +export const IMAGE_CONE_SIZE_PX = 26; + +/** + * @typedef {object} ConeImage One image of a sequence, as the cones draw it. + * @property {string} id + * @property {number} lon + * @property {number} lat + * @property {number} compassAngle Degrees clockwise from north. + * @property {number} capturedAt Epoch ms (0 when unknown). + * @property {boolean} isPano + */ + +/** Drop images closer than the spacing to the previous kept one. */ +export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { + const kept = []; + let last = null; + for (const image of images) { + if (!last || metresBetween(last, image) >= spacingM) { + kept.push(image); + last = image; + } + } + return kept; +} + +/** + * Image cones for one selected sequence, any provider's. A cone's pick id is + * `pickPrefix` + the image id; 360° images draw as rings. + * + * @param {object} options + * @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`. + * @param {(sequenceId: string, init: {signal: AbortSignal}) => Promise>} options.loadImages + * The sequence's images in capture order, thinned (see `thinImages`). + * @param {{coverage: string}} options.colors + * @param {string} options.pickPrefix Pick-id prefix of the cones. + * @param {string} options.spriteId Sprite-order id of the cone collection. + * @param {string} options.renderKey Render-governor reason. + * @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight] + * Highlight (or stop highlighting) the sequence's coverage line. + * @param {(image: ConeImage, filter: object) => boolean} [options.passes] + * Whether the imagery filter keeps a cone. + */ +export function createSequenceCones({ + state, + loadImages, + colors, + pickPrefix, + spriteId, + renderKey, + onHighlight = () => {}, + passes = passesImageryFilter, +}) { + const { render, sprites } = state.services; + + function requestRender() { + render?.governorRequestRender?.(renderKey); + } + + function notify() { + state.context.notify(); + } + + const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); + /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ + let drawn = nothingDrawn(); + + // The cones skip the depth test: hide the ones behind the globe. + const horizon = createHorizonCull({ + getViewer: () => state.viewer, + items: () => drawn.cones.map((cone) => cone.billboard), + onChange: requestRender, + }); + + function ensureCollections(viewer) { + if (state.sequence.collection) return; + state.sequence.collection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.sequence.collection); + sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection); + } + + function clearCones() { + state.sequence.collection?.removeAll(); + state.sequence.images = []; + drawn = nothingDrawn(); + horizon.stop(); + } + + /** Where a cone stands: at `height`, or clamped to the ground when null. */ + function placement(image, height) { + return { + position: Cesium.Cartesian3.fromDegrees( + image.lon, + image.lat, + height ?? 0, + ), + heightReference: + height === null + ? Cesium.HeightReference.CLAMP_TO_GROUND + : Cesium.HeightReference.NONE, + }; + } + + /** + * Terrain-mode cone heights on the street, not on roofs: bare earth refined + * by mesh samples. Null when draped or while terrain heights are fetched. + */ + function coneHeights(images, { request = true } = {}) { + const ground = state.context.groundCaster; + if (state.context.getSurface?.() !== 'terrain' || !ground) return null; + const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); + if (dems.includes(null)) { + ground + .prepare(images.map((image) => [image.lon, image.lat])) + .then((ready) => { + if (ready && state.sequence.images === images) renderCones(images); + }); + return null; + } + const sampler = state.context.meshSampler; + if (request) + sampler?.request(images.map((image) => [image.lon, image.lat])); + return refineHeights( + images.map((image, i) => ({ + dem: dems[i], + mesh: sampler?.meshAt(image.lon, image.lat), + })), + ); + } + + function renderCones(images) { + const collection = state.sequence.collection; + if (!collection) return; + collection.removeAll(); + drawn = { ...nothingDrawn(), images }; + const heights = coneHeights(images); + // One colour per source; 360° images keep the ring shape. + const cone = imageConeGlyph({ size: 32, color: colors.coverage }); + const ring = imageConeGlyph({ + size: 32, + color: colors.coverage, + pano: true, + }); + // Resolved now, so a "since N days" window keeps up with the clock. + const filter = state.context.getFilter(); + images.forEach((image, index) => { + if (!passes(image, filter)) return; + const height = heights?.[index] ?? null; + const billboard = collection.add({ + id: `${pickPrefix}${image.id}`, + ...placement(image, height), + image: image.isPano ? ring : cone, + imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`, + width: IMAGE_CONE_SIZE_PX, + height: IMAGE_CONE_SIZE_PX, + rotation: -Cesium.Math.toRadians(image.compassAngle), + alignedAxis: Cesium.Cartesian3.UNIT_Z, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), + }); + drawn.cones.push({ index, billboard, height }); + drawn.cells.add(meshCellKey(image.lon, image.lat)); + }); + horizon.update(); + requestRender(); + } + + /** + * Re-place cones whose height new mesh samples changed. A sample can move + * a neighbour too, so every height is recomputed but only moved cones touched. + */ + function onMeshSampled(batch) { + if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) + return; + if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) + return; + const { images, cones } = drawn; + // Already asked for when drawn; a missing terrain cell redraws them all. + const heights = coneHeights(images, { request: false }); + if (!heights) return; + const moved = []; + for (const cone of cones) { + const height = heights[cone.index] ?? null; + if (height === cone.height) continue; + cone.height = height; + const { position, heightReference } = placement( + images[cone.index], + height, + ); + cone.billboard.heightReference = heightReference; + cone.billboard.position = position; + moved.push(cone.billboard); + } + if (!moved.length) return; + horizon.update(moved); + requestRender(); + } + + function remember(sequenceId, images) { + const { cache } = state.sequence; + cache.delete(sequenceId); + cache.set(sequenceId, images); + while (cache.size > SEQUENCE_CACHE_SIZE) + cache.delete(cache.keys().next().value); + } + + /** + * Select a sequence: highlight its line and load its image cones. `force` + * reloads a selected one (a provider that loads part of a sequence moved on). + */ + async function select(sequenceId, { force = false } = {}) { + if (!sequenceId || !state.viewer) return; + // Already on screen, or already on its way. + if ( + !force && + state.sequence.selectedId === sequenceId && + (state.sequence.images.length || state.sequence.abort) + ) + return; + if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) + onHighlight(state.sequence.selectedId, false); + state.sequence.abort?.abort(); + state.sequence.selectedId = sequenceId; + onHighlight(sequenceId, true); + const cached = force ? null : state.sequence.cache.get(sequenceId); + if (cached) { + state.sequence.abort = null; + state.sequence.loading = false; + state.sequence.images = cached; + renderCones(cached); + state.context.actions.reportError(null); + notify(); + return; + } + // The previous sequence's cones must not stay clickable under the new + // highlight while this one loads, nor after its load fails. + clearCones(); + requestRender(); + const controller = new AbortController(); + state.sequence.abort = controller; + state.sequence.loading = true; + notify(); + try { + const images = await loadImages(sequenceId, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + remember(sequenceId, images); + state.sequence.images = images; + renderCones(images); + state.context.actions.reportError(null); + } catch (error) { + if (!controller.signal.aborted) { + // Nothing to show: drop the highlight, so a click asks again. + onHighlight(sequenceId, false); + state.sequence.selectedId = null; + state.context.actions.reportError( + error?.message || 'Sequence images unavailable', + ); + } + } finally { + if (state.sequence.abort === controller) { + state.sequence.loading = false; + state.sequence.abort = null; + } + notify(); + } + } + + function clearSelection() { + state.sequence.abort?.abort(); + state.sequence.abort = null; + if (state.sequence.selectedId) + onHighlight(state.sequence.selectedId, false); + state.sequence.selectedId = null; + state.sequence.loading = false; + clearCones(); + // An error about the sequence goes with it. + state.context.actions.reportError(null); + requestRender(); + notify(); + } + + /** Re-draw the current sequence's cones (after an imagery filter change). */ + function rerender() { + if (state.sequence.images.length) renderCones(state.sequence.images); + } + + // New mesh samples may move the cones onto (or off) the road surface. + const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); + + function setVisible(visible) { + if (state.sequence.collection) state.sequence.collection.show = visible; + requestRender(); + } + + function destroy(viewer) { + state.sequence.abort?.abort(); + horizon.stop(); + drawn = nothingDrawn(); + stopMeshListener?.(); + const collection = state.sequence.collection; + if (collection) { + sprites?.unregisterSpriteCollection?.(spriteId, collection); + viewer?.scene?.primitives?.remove(collection); + state.sequence.collection = null; + } + state.sequence.images = []; + state.sequence.selectedId = null; + } + + return { + ensureCollections, + select, + clearSelection, + rerender, + setVisible, + destroy, + }; +} diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 4163d473f..4c4590b3e 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -17,6 +17,9 @@ import { COLORS } from './policy.js'; * @property {string} color The source's one colour. * @property {boolean} [groundClick] * @property {Array<'pano'|'since'>} [filters] + * @property {{noun: string, action: string}|null} [coverageLabel] + * @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected. + * @property {'capture'|'upload'} [sinceBasis] What its SINCE compares. */ /** @@ -34,6 +37,8 @@ export function summarizeCoverage(providers) { ? 'No street-level imagery providers configured' : [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '), error: active.find((p) => p.error)?.error || null, + // One source is lit at a time; its wording counts the lines. + label: active.find((p) => p.coverageLabel)?.coverageLabel || null, keyRequired: active.length > 0 && active.every((p) => p.keyRequired === true), keyRejected: @@ -68,6 +73,18 @@ export function filterControls(providers) { return { pano: honoured('pano'), since: honoured('since') }; } +/** + * The note under SINCE when a lit provider dates its imagery by upload, + * e.g. "KartaView: by upload date", or null. + * @param {Array} providers + */ +export function sinceNote(providers) { + const names = providers + .filter((p) => p.on && p.sinceBasis === 'upload') + .map((p) => p.name); + return names.length ? `${names.join(', ')}: by upload date` : null; +} + /** * The panel snapshot from core state and provider snapshots. The legend has * one swatch per active source that draws coverage lines, then Selected. @@ -92,7 +109,13 @@ export function composeUIState({ label: provider.name, color: provider.color, })); - if (legend.length) + // "Selected" only beside a source whose lines turn cyan when selected. + if ( + active.some( + (provider) => + provider.groundClick !== true && provider.highlightsSequence !== false, + ) + ) legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); return { enabled, @@ -102,6 +125,7 @@ export function composeUIState({ keyRejected, filter: { ...filter }, filterControls: filterControls(providers), + sinceNote: sinceNote(providers), providers: providers.map((p) => ({ ...p })), coverage, legend, diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 894075f6f..21b5eefe0 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -128,6 +128,18 @@ test('the filter controls show only for a lit provider that honours them', () => ); }); +test('a lit provider that dates by upload puts a note under SINCE', () => { + const kartaView = provider({ + id: 'kartaview', + name: 'KartaView', + sinceBasis: 'upload', + }); + const note = (providers) => composeUIState({ ...base, providers }).sinceNote; + assert.equal(note([provider()]), null, 'Mapillary: capture date'); + assert.equal(note([kartaView]), 'KartaView: by upload date'); + assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit'); +}); + test('hint and error come from the first active provider that has one', () => { const ui = composeUIState({ ...base, @@ -154,6 +166,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot', loading: true, hint: 'Look down', error: 'boom', + label: null, keyRequired: false, keyRejected: false, }, @@ -235,3 +248,25 @@ test('a key-gated layer offers the switched-off sources that would draw with a k [], ); }); + +test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => { + const kartaView = provider({ + id: 'kartaview', + name: 'KartaView', + color: '#635bff', + highlightsSequence: false, + coverageLabel: { + noun: 'streets', + action: 'click a line for its nearest photo', + }, + }); + const ui = composeUIState({ ...base, providers: [kartaView] }); + assert.deepEqual( + ui.legend.map((entry) => entry.key), + ['kartaview'], + ); + assert.deepEqual(ui.coverage.label, { + noun: 'streets', + action: 'click a line for its nearest photo', + }); +}); diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 628633927..3a562c21e 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -39,6 +39,10 @@ export function createViewerHost({ state, parts }) { sequenceId: pose.sequenceId ?? null, creator: pose.creator || null, externalUrl: pose.externalUrl || null, + // FIT / FILL, unless this image has no frame to fit (a panorama). + renderModes: + typeof active.adapter.setRenderMode === 'function' && + active.adapter.supportsRenderModes?.() !== false, }); parts.marker.set( state.street.position, diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index f7da6412e..070fe3d91 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -659,3 +659,17 @@ test('FIT/FILL stays hidden for a second Street View panorama after a close', as await host.open('google', 'g-2'); assert.equal(state.street.renderModes, false); }); + +test('an adapter that can frame but says this image has no frame offers no FIT / FILL', async () => { + const adapter = fakeAdapter(); + let frameable = false; + adapter.setRenderMode = () => {}; + adapter.supportsRenderModes = () => frameable; + const { state, host } = harness(adapter); + await host.open('mapillary', 'pano-1'); + assert.equal(state.street.imageId, 'pano-1', 'the pose arrived'); + assert.equal(state.street.renderModes, false); + frameable = true; + await host.open('mapillary', 'flat-1'); + assert.equal(state.street.renderModes, true, 'a flat image can be framed'); +}); diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs new file mode 100644 index 000000000..a2e6452f6 --- /dev/null +++ b/src/testSupport/kartaViewFakes.mjs @@ -0,0 +1,81 @@ +// DOM stand-ins for the KartaView viewer's tests (no browser in Node). + +/** A DOM element with what the viewer touches: children, events, attributes. */ +export function fakeElement(tag) { + const listeners = new Map(); + const element = { + tagName: tag.toUpperCase(), + children: [], + parentNode: null, + style: {}, + dataset: {}, + attributes: {}, + hidden: false, + disabled: false, + clientWidth: 400, + clientHeight: 250, + classList: { + values: new Set(), + add: (name) => element.classList.values.add(name), + remove: (name) => element.classList.values.delete(name), + }, + append(...nodes) { + for (const node of nodes) { + node.parentNode = element; + element.children.push(node); + } + }, + remove() { + const list = element.parentNode?.children; + if (list) list.splice(list.indexOf(element), 1); + element.parentNode = null; + }, + replaceWith(next) { + const parent = element.parentNode; + if (!parent) return; + parent.children[parent.children.indexOf(element)] = next; + next.parentNode = parent; + next.ownerDocument = element.ownerDocument; + element.parentNode = null; + }, + setAttribute: (name, value) => (element.attributes[name] = value), + removeAttribute: (name) => { + delete element.attributes[name]; + if (name === 'src') element.src = ''; + }, + addEventListener: (type, handler) => listeners.set(type, handler), + removeEventListener: (type) => listeners.delete(type), + setPointerCapture() {}, + /** Fire an event at the element's listener. */ + fire(type, event = {}) { + return listeners.get(type)?.({ + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() {}, + ...event, + }); + }, + }; + return element; +} + +export function fakeHost() { + const doc = { createElement: (tag) => fakeElement(tag) }; + const host = fakeElement('div'); + host.ownerDocument = doc; + return host; +} + +/** + * Wait, a macrotask at a time, until `condition` holds; false if it never + * does within `ticks`. A test asserting that something did not happen waits + * on a sign that the work is done instead of a fixed count of ticks. + */ +export async function until(condition, { ticks = 200 } = {}) { + for (let i = 0; i < ticks; i++) { + if (await condition()) return true; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + return Boolean(await condition()); +} diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index b3d6c3a13..73702c1f1 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -1,6 +1,11 @@ /** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */ import * as Cesium from 'cesium'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; +import { + latToTileY, + lonToTileX, + tileBounds, +} from '../layers/streetLevel/tileMath.js'; const RAD = Math.PI / 180; @@ -141,3 +146,92 @@ export function rayCamera({ computeViewRectangle: () => undefined, }; } + +/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */ +export function tileCentre(lon, lat, z = 14) { + const x = lonToTileX(lon, z); + const y = latToTileY(lat, z); + const { west, east, south, north } = tileBounds(x, y, z); + return { + tile: { x, y, z }, + lon: (west + east) / 2, + lat: (south + north) / 2, + }; +} + +/** A scene collection that remembers what is on the globe. */ +function sceneCollection() { + const items = new Set(); + return { + items, + add(primitive) { + items.add(primitive); + return primitive; + }, + remove(primitive) { + return items.delete(primitive); + }, + }; +} + +/** + * A viewer looking straight down on a 0.004° square around `view` + * (`{lon, lat, height}`, mutable), enough for line coverage to attach, + * refresh and draw. Cesium's one-time terrain-height download is skipped. + */ +export function coverageViewer(view, t) { + const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights; + Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () => + Promise.resolve(); + t?.after(() => { + Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize; + }); + // Node has no WebGL context to report line-width limits. + Cesium.ContextLimits._minimumAliasedLineWidth = 1; + Cesium.ContextLimits._maximumAliasedLineWidth = 10; + const listeners = () => { + const set = new Set(); + return { + set, + addEventListener(listener) { + set.add(listener); + return () => set.delete(listener); + }, + }; + }; + return { + view, + scene: { + canvas: { clientWidth: 100, clientHeight: 100 }, + globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + updateHeight: () => () => {}, + getHeight: () => undefined, + groundPrimitives: sceneCollection(), + primitives: sceneCollection(), + postRender: listeners(), + preRender: listeners(), + }, + camera: { + changed: new Cesium.Event(), + moveEnd: new Cesium.Event(), + get positionWC() { + return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); + }, + get positionCartographic() { + return { + longitude: view.lon * RAD, + latitude: view.lat * RAD, + height: view.height, + }; + }, + pickEllipsoid(point) { + return Cesium.Cartesian3.fromDegrees( + view.lon - 0.002 + (point.x / 100) * 0.004, + view.lat + 0.002 - (point.y / 100) * 0.004, + ); + }, + computeViewRectangle: () => undefined, + }, + }; +} diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index e1e38bfb5..6a29b4f69 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; +import { inflateSync } from 'node:zlib'; import { createApplicationStreetLevel } from '../app/layers/streetLevel.js'; import { fixtureTile, @@ -20,6 +21,24 @@ import { THUMB_HOST, } from '../../scripts/fixtures/street-level/mapillaryGraph.mjs'; import { redact } from '../../scripts/qa-browserEvidence.mjs'; +import { + answerKartaViewRequest, + describeKartaViewCall, + KV_LINE, + KV_OLD_STREET, + KV_SEQUENCE_ID, + kartaViewPhotos, + resetKartaViewFixture, +} from '../../scripts/fixtures/street-level/kartaView.mjs'; +import { + maskedSegments, + tileSegments, +} from '../layers/streetLevel/providers/kartaview/decode.js'; +import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js'; +import { + normalizePhoto, + resizedImageUrl, +} from '../layers/streetLevel/providers/kartaview/policy.js'; // The press helpers wait for paint; Node has no frames, so a tick stands in. globalThis.requestAnimationFrame ??= (callback) => @@ -639,3 +658,170 @@ test('CI runs the gates hermetically against a production build, strict, with ev 'a missed header press fails CI now that the rail settles', ); }); + +test('KartaView fixtures draw the drive, answer nearest first, and refuse the storage host the API names', () => { + const body = (answer) => JSON.parse(answer.body); + const lat = (KV_LINE.south + KV_LINE.north) / 2; + const x = lonToTileX(KV_LINE.lon, 14); + const y = latToTileY(lat, 14); + const tile = answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/14.json`, + }); + assert.equal(tile.known, true); + assert.ok(body(tile).lines.length >= 1); + const empty = answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/sequence/tiles/${x + 3}/${y}/14.json`, + }); + assert.deepEqual(body(empty).lines, []); + const nearUrl = `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`; + resetKartaViewFixture(); + // The first lookup is rate-limited, readable cross-origin; the retry answers. + const limited = answerKartaViewRequest({ method: 'GET', url: nearUrl }); + assert.equal(limited.status, 429); + assert.equal(limited.known, true); + assert.equal(limited.headers['Retry-After'], '0'); + assert.match(limited.headers['Access-Control-Expose-Headers'], /Retry-After/); + const near = answerKartaViewRequest({ method: 'GET', url: nearUrl }); + assert.equal(near.status, 200); + const records = body(near).result.data; + assert.ok(records.length > 1); + assert.ok(Number(records[0].distance) <= Number(records[1].distance)); + const photo = normalizePhoto(records[0]); + assert.match(photo.urls.view, /^https:\/\/storage2\.openstreetcam\.org\//); + assert.equal( + answerKartaViewRequest({ method: 'GET', url: photo.urls.view }).known, + true, + ); + // Every odd photo is archived: its file answers 409, an even one the image. + const photos = kartaViewPhotos(); + const storageAnswer = (index) => + answerKartaViewRequest({ + method: 'GET', + url: normalizePhoto( + body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/${photos[index].id}`, + }), + ).result.data, + ).urls.view, + }); + assert.equal(photos[3].archived, true); + assert.equal(storageAnswer(3).status, 409); + assert.equal(storageAnswer(3).known, true); + assert.equal(photos[4].archived, false); + assert.equal(storageAnswer(4).status, 200); + assert.equal(storageAnswer(4).contentType, 'image/jpeg'); + // The image proxy still serves an archived photo: the 360° step opens one. + const pano = photos.find((p) => p.isPano); + assert.equal(pano.archived, true); + const panoRecord = body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/${pano.id}`, + }), + ).result.data; + assert.equal( + answerKartaViewRequest({ + method: 'GET', + url: resizedImageUrl(normalizePhoto(panoRecord).urls.pano, [1024, 512]), + }).status, + 200, + ); + assert.equal( + answerKartaViewRequest({ method: 'GET', url: records[0].fileurlLTh }).known, + false, + 'the storage1 address the API gives is not where the photo is', + ); + const page = answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/sequence/${KV_SEQUENCE_ID}/photos?page=1&itemsPerPage=1000`, + }); + assert.equal(body(page).result.data.length, kartaViewPhotos().length); + assert.ok(kartaViewPhotos().some((p) => p.isPano)); + assert.equal( + describeKartaViewCall( + 'GET', + new URL('https://api.kartaview.org/2.0/photo/77000001'), + ), + 'GET api.kartaview.org/2.0/photo/{id}', + ); +}); + +/** The alpha of a fixture PNG (8-bit RGBA, unfiltered rows, as it writes them). */ +function fixturePngAlpha(bytes) { + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + const idat = []; + for (let at = 8; at < bytes.length;) { + const length = bytes.readUInt32BE(at); + const type = bytes.toString('ascii', at + 4, at + 8); + if (type === 'IDAT') idat.push(bytes.subarray(at + 8, at + 8 + length)); + at += 12 + length; + } + const rows = inflateSync(Buffer.concat(idat)); + const alpha = new Uint8Array(width * height); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + alpha[y * width + x] = rows[y * (width * 4 + 1) + 1 + x * 4 + 3]; + return { width, height, alpha }; +} + +test('KartaView fixtures filter like KartaView: PNG tiles by view and upload date, the photo search by projection', () => { + const body = (answer) => JSON.parse(answer.body); + const z = 14; + const x = lonToTileX(KV_LINE.lon, z); + const y = latToTileY((KV_LINE.south + KV_LINE.north) / 2, z); + assert.equal(x, lonToTileX(KV_OLD_STREET.lon, z), 'both streets in a tile'); + const base = `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/${z}`; + const segments = tileSegments( + body(answerKartaViewRequest({ method: 'GET', url: `${base}.json` })), + ); + const lons = (kept) => [...new Set(kept.map((segment) => segment[0][0]))]; + const keptFor = (query) => { + const answer = answerKartaViewRequest({ + method: 'GET', + url: `${base}.png?${query}`, + }); + assert.equal(answer.contentType, 'image/png'); + assert.equal(answer.known, true); + return lons( + maskedSegments(segments, fixturePngAlpha(answer.body), { x, y, z }), + ); + }; + assert.deepEqual( + lons(segments).sort(), + [KV_LINE.lon, KV_OLD_STREET.lon].sort(), + ); + assert.deepEqual(keptFor('fieldOfView=360'), [KV_LINE.lon]); + assert.deepEqual(keptFor('fieldOfView=plane').sort(), lons(segments).sort()); + const lastYear = new Date(Date.now() - 365 * 86_400_000) + .toISOString() + .slice(0, 10); + assert.deepEqual(keptFor(`startDate=${lastYear}`), [KV_LINE.lon]); + assert.deepEqual(keptFor('startDate=2099-01-01'), []); + assert.equal( + describeKartaViewCall('GET', new URL(`${base}.png?fieldOfView=360`)), + 'GET api.kartaview.org/2.0/sequence/tiles/{x}/{y}/{z}.png?fieldOfView', + ); + // The photo search: 360° only, and nothing uploaded after the drive. + resetKartaViewFixture(); + const search = (extra) => { + answerKartaViewRequest({ + method: 'GET', + url: 'https://api.kartaview.org/2.0/photo/?lat=0&lng=0', + }); + return body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/?lat=${KV_LINE.south + 0.002}&lng=${KV_LINE.lon}&zoomLevel=18&itemsPerPage=10${extra}`, + }), + ).result.data; + }; + const panos = search('&projection=SPHERE'); + assert.ok(panos.length > 0); + assert.ok(panos.every((record) => record.projection === 'SPHERE')); + assert.deepEqual(search('&startDate=2099-01-01'), []); +}); diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 2ec277748..51e4d5c44 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -675,7 +675,7 @@ test('public transit documentation uses courtesy attribution and the approved gr ); assert.ok( readme.includes( - 'Twenty layers and map sources. **Seventeen have a keyless path.**', + 'Twenty layers and map sources. **Eighteen have a keyless path.**', ), ); assert.ok( diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index ae5fe5ae3..16e32eec6 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -15,7 +15,8 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js'; /** * Sites the app's browser code loads from directly: map imagery, 3D tiles, * terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its - * coverage tiles come through the app's server). Everything from the app's + * coverage tiles come through the app's server), Google Street View and + * KartaView. Everything from the app's * own server comes through the MCP server. */ const PROVIDER_ORIGINS = Object.freeze([ @@ -44,6 +45,10 @@ const PROVIDER_ORIGINS = Object.freeze([ 'https://*.ggpht.com', // Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net. 'https://*.xx.fbcdn.net', + // KartaView's API, its image proxy and its photo storage (storage1…storage13). + 'https://api.kartaview.org', + 'https://cdn.kartaview.org', + 'https://*.openstreetcam.org', ]); export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 7e6759199..db1a626f5 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -73,6 +73,7 @@ export class StreetLevelControls { sinceRow: byId('sl-since-row'), sinceRange: byId('sl-since'), sinceLabel: byId('sl-since-label'), + sinceNote: byId('sl-since-note'), legend: byId('sl-legend'), followBtn: byId('sl-follow-btn'), viewerWrap: byId('sl-viewer-wrap'), @@ -527,6 +528,9 @@ export class StreetLevelControls { const shows = view.filterControls || { pano: true, since: true }; setProp(el.panoRow, 'hidden', !shows.pano); setProp(el.sinceRow, 'hidden', !shows.since); + // A lit source that dates by upload says so under the slider. + setProp(el.sinceNote, 'textContent', view.sinceNote || ''); + setProp(el.sinceNote, 'hidden', !view.sinceNote); setProp(el.controls, 'hidden', !shows.pano && !shows.since); setProp(el.legend, 'hidden', view.legend.length === 0); this._renderRadios( diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index a27afd468..eb572781b 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import test from 'node:test'; import { StreetLevelControls } from './streetLevelControls.js'; import { createStreetLevelLayer } from '../layers/streetLevel/index.js'; @@ -313,6 +314,7 @@ function panelDom() { const sinceRow = add(controls, 'div', { id: 'sl-since-row' }); add(sinceRow, 'input', { id: 'sl-since' }); add(sinceRow, 'output', { id: 'sl-since-label' }); + add(sinceRow, 'small', { id: 'sl-since-note' }); add(settings, 'ul', { id: 'sl-legend' }); const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] }); add(metaRow, 'div', { id: 'sl-coverage-meta' }); @@ -1224,6 +1226,26 @@ test('a swap names every registered provider, also those hidden on this map', () ]); })); +test('a note under SINCE says what date it compares, only while one is given', () => + withDom(async (dom) => { + const state = uiState(); + const { layer } = stubPanel(dom, state); + const note = () => dom.root.querySelector('#sl-since-note'); + assert.equal(note().hidden, true, 'none for Mapillary'); + layer.publish({ ...state, sinceNote: 'KartaView: by upload date' }); + assert.equal(note().hidden, false); + assert.equal(note().textContent, 'KartaView: by upload date'); + layer.publish(state); + assert.equal(note().hidden, true); + // The panel's own markup has it, read out with the slider. + const template = fs.readFileSync( + new URL('./templates/layer-panels.html', import.meta.url), + 'utf8', + ); + assert.match(template, /]* hidden>/); + assert.match(template, /aria-describedby="sl-since-label sl-since-note"/); + })); + test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () => withDom(async (dom) => { const state = uiState(); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 6955e63b2..616829095 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -193,7 +193,7 @@ function presentMeta(state) { const hint = state.coverage.hint || ''; if (state.coverage.count > 0) return [ - `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`, + `${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`, hint, ] .filter(Boolean) @@ -224,6 +224,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { since: state.filterControls?.since !== false, }, since: presentSince(filter.sinceDays, now), + sinceNote: state.sinceNote || null, legend: state.legend || [], viewer: presentViewer(state), meta: presentMeta(state), diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 7f5b83cdc..324f28bb6 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -4,6 +4,9 @@ #street-level-panel, .sl-viewer-wrap { --sl-green: #05cb63; + /* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */ + --sl-kartaview: #635bff; + --sl-kartaview-text: #8f8aff; --sl-text: 0.62rem; --sl-text-small: 0.6rem; } @@ -174,6 +177,11 @@ button.sl-status:focus-visible { ); } +/* #635bff text on its own tint is 3.7:1 at this size; the lighter one is ~6:1. */ +.sl-chips .data-toggle-chip.active[data-chip-id='kartaview'] { + color: var(--sl-kartaview-text); +} + .sl-chips .data-toggle-chip.active::before { opacity: 1; } @@ -291,6 +299,17 @@ button.sl-status:focus-visible { outline-offset: 3px; } +.sl-since-note { + grid-column: 2; + font-family: var(--font-mono); + font-size: var(--sl-text-small); + color: var(--text-secondary); +} + +.sl-since-note[hidden] { + display: none; +} + .sl-since-label { grid-column: 2; font-family: var(--font-mono); @@ -613,6 +632,97 @@ button.sl-status:focus-visible { font-size: 1.3rem; } +/* ── KartaView photo viewer ──────────────────────────────────────────────── */ +.sl-kv-viewer { + position: absolute; + inset: 0; + background: #000; + outline: none; + overflow: hidden; +} + +.sl-kv-viewer:focus-visible { + outline: none; +} + +/* The photo and canvas fill the viewer and would cover a ring drawn on it: + draw the ring on a layer above them (clicks pass through). */ +.sl-kv-viewer:focus-visible::after { + content: ''; + position: absolute; + inset: 0; + z-index: 3; + border: 2px solid var(--sl-kartaview-text); + pointer-events: none; +} + +.sl-kv-photo, +.sl-kv-pano { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + user-select: none; +} + +.sl-kv-photo { + object-fit: contain; +} + +.sl-kv-pano { + cursor: grab; + touch-action: none; +} + +.sl-kv-pano.sl-kv-dragging { + cursor: grabbing; +} + +.sl-kv-photo[hidden], +.sl-kv-pano[hidden] { + display: none; +} + +.sl-kv-step { + position: absolute; + top: 50%; + z-index: 2; + width: 34px; + height: 44px; + margin-top: -22px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + background: rgba(10, 10, 15, 0.62); + color: #fff; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; +} + +.sl-kv-step-prev { + left: 6px; +} + +.sl-kv-step-next { + right: 6px; +} + +.sl-kv-step:hover:not(:disabled), +.sl-kv-step:focus-visible { + background: color-mix(in srgb, var(--sl-kartaview) 85%, transparent); + outline: none; +} + +.sl-kv-step:disabled { + opacity: 0.3; + cursor: default; +} + +/* Nothing to step through until a photo shows. */ +.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step { + display: none; +} + /* ── Short viewports ─────────────────────────────────────────────────────── */ @media (max-height: 640px) { .street-level-panel-inner { diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 4a44c03eb..d079ebd11 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -128,8 +128,9 @@
- + ANY DATE +