Merge pull request #1121 from asturksever/pr/kartaview

Street Level: KartaView as the third, keyless imagery provider
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-14 15:20:56 +01:00
commit 3ec31e599e
49 files changed
+9562 -1244

No files matched your search

+23
View File
@@ -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
+1
View File
@@ -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 |
+6 -6
View File
@@ -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
+99 -5
View File
@@ -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:<z/x/y/n>`, 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/<base64url>`),
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 <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
+371
View File
@@ -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 };
}
+17
View File
@@ -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",
+24
View File
@@ -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",
+555 -31
View File
@@ -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</a> 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(' | ')})`,
+6 -1
View File
@@ -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(),
];
}
+8 -3
View File
@@ -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, []);
});
+35 -1
View File
@@ -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;
+127 -2
View File
@@ -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);
});
+936
View File
@@ -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 (`<prefix><id>[~<part>]`), 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<object>, overview: Array<object>}>} 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,
};
}
@@ -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');
});
+1
View File
@@ -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. */
@@ -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<Array<[number, number]>>}
*/
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<Array<[number, number]>>} segments
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
*/
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<Array<[number, number]>>} segments
* @param {{width: number, height: number, alpha: ArrayLike<number>}} 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?.();
}
}
@@ -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/);
});
@@ -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<typeof createKartaViewSource>, 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<object>}>}
*/
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,
};
},
});
}
@@ -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();
},
};
}
@@ -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);
});
@@ -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 © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> 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<string, string>}
*/
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';
}
@@ -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',
);
});
File diff suppressed because it is too large. Load diff
@@ -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,
};
},
};
}
@@ -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);
});
@@ -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 <img> (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 <img> 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 <img>, 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<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
* @param {(photo: object, delta: -1|1) => Promise<string|null>} 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<HTMLImageElement>} [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;
}
@@ -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 <img> 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');
});
@@ -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:<id>[~<part>]`), 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,
};
}
@@ -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;
@@ -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),
});
}
+14 -2
View File
@@ -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<void>, reportError: (message: string|null) => void}} actions
* @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise<boolean>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, 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<void>} [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<void>} [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);
}
+5
View File
@@ -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'/,
);
});
+18 -1
View File
@@ -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;
}
/**
+52 -3
View File
@@ -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();
}
});
+334
View File
@@ -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<Array<ConeImage>>} 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,
};
}
+25 -1
View File
@@ -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<ProviderSnapshot>} 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,
+35
View File
@@ -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',
});
});
+4
View File
@@ -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,
@@ -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');
});
+81
View File
@@ -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());
}
+94
View File
@@ -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,
},
};
}
+186
View File
@@ -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'), []);
});
+1 -1
View File
@@ -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(
+6 -1
View File
@@ -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';
+4
View File
@@ -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(
+22
View File
@@ -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, /<small id="sl-since-note"[^>]* 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();
+2 -1
View File
@@ -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),
+110
View File
@@ -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 {
+2 -1
View File
@@ -128,8 +128,9 @@
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>