mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(street-level): IC8 review of KartaView — cones on the right page, pano sizing
P1s from the correctness review, each with a regression test: - Cones showed the wrong page of a long sequence (cache keyed by sequence; no re-centre after a close or a same-drive line click): KartaView always reselects, and re-centres whenever a photo of the selected sequence opens on another page. - A 360° photo's first frame was one colour: the canvas was sized while hidden (1×1). It is shown first, and re-measured every frame. P2/P3s: a storage timeout or 5xx is not remembered as archived (only 403/404/409/410 are); dense z13 tiles draw up to 1,600 lines (a third of downtown Detroit was cut); decoded tiles are kept 10 min; a timed-out tile is asked once more; pages are 250 photos and the newest page request goes first; a photo far past deleted ones, or unlisted at a page edge, still finds its neighbours; a step that gets no answer is not the end; a lost WebGL context redraws the photo, and a failed one is retried on the next mount; the first flat image comes from the proxy (KartaView's own can be 1.7 MB); fileurl fallbacks must be https storage hosts; oversized answers are refused; keyboard (Shift+← →, ↑ ↓, + −) and pinch look around a panorama; focus stays in the viewer when a step button disables. Docs: share links with 0.m.0, keyless count, CURRENT-STATE matched to the code. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1a2fbda464
commit
bd0bbf5134
13 files changed
+699
-117
No files matched your search
+1
-1
@@ -103,7 +103,7 @@
|
||||
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 as `r.kartaview-1`.
|
||||
contributors, CC BY-SA 4.0". Share links carry it as `0.m.0_0.r.kartaview-1`. Archived photos (KartaView answers 409) are skipped by the click and by ‹ ›; a keyboard and pinch look around a 360° photo.
|
||||
Mapillary's line coverage and sequence cones are now shared by both
|
||||
(`lineCoverage.js`, `sequenceCones.js`).
|
||||
|
||||
|
||||
@@ -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). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View and KartaView; Panoramax planned | Mapillary · Google · KartaView | 🟡 Mapillary token · 🔴 Google Maps key · KartaView keyless |
|
||||
| 📷 **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 | 🟢 |
|
||||
@@ -344,9 +344,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** 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. 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. KartaView photos are undated on its map, so the filters do not apply to it. 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", "© 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.
|
||||
**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; 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. KartaView photos are undated on its map, so the filters do not apply to it. 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.r.kartaview-1` KartaView 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.kartaview-1` KartaView on (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
|
||||
|
||||
|
||||
+43
-22
@@ -3192,28 +3192,49 @@ 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. 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 a quarter of the
|
||||
lines), they are drawn unselectable as `kv:line:<z/x/y/n>`, and the provider
|
||||
declares `filters: []` and `coverageLabel` ("N streets in view · click a line
|
||||
for its nearest photo"). A click on a line reaches `handlePick(id,
|
||||
{groundPoint})`, and the provider asks the core (`actions.openNearest`) for
|
||||
its photo nearest that point within 50 m, without framing. The open photo's
|
||||
sequence draws as cones from the 1,000-photo page holding it
|
||||
(`/2.0/sequence/{id}/photos`; a page with missing photos is found by stepping
|
||||
back), and ‹ › step across pages, re-centring the cones. The photo API answers
|
||||
429 to more than about three requests at once, so `source.js` keeps two in
|
||||
flight and retries a 429 with backoff. Photo URLs are built from `storage` and
|
||||
`filepath*`: the API's `fileurl*` always name storage1, where photos stored
|
||||
elsewhere 404. The viewer (`viewer.js`) shows a flat photo's 1280 px rendition,
|
||||
then a 2,560 px one from the image proxy (`pr:sharp/rs:fit:W:H/<base64url>`),
|
||||
and a 360° photo's equirectangular `wrapped_proc` image (13,000 px wide
|
||||
upstream) resized to 1,024 then 4,096 px on `panorama.js`, a single-triangle
|
||||
WebGL renderer (image centre = the camera heading; drag, wheel). Without WebGL
|
||||
a 360° photo shows its front view. 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, since kartaview.org's photo
|
||||
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), 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: []` and `coverageLabel` ("N streets in view ·
|
||||
click a line for its nearest photo"). Tiles come uncompressed and
|
||||
uncacheable, so the last 24 decoded tiles are kept for 10 minutes
|
||||
(`createTileCache`); 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`). 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 timed-out lookup once. 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 then 4,096 px on `panorama.js`, a single-triangle WebGL renderer (image
|
||||
centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the
|
||||
canvas re-measures every frame). 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 one. 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>`
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
/** Photos kept in memory by id (sequence pages fill it). */
|
||||
const PHOTO_CACHE_SIZE = 4000;
|
||||
/** Sequence pages kept in memory. */
|
||||
const PAGE_CACHE_SIZE = 8;
|
||||
const PAGE_CACHE_SIZE = 12;
|
||||
/** Decoded coverage tiles kept, and for how long. */
|
||||
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) {
|
||||
@@ -62,6 +65,30 @@ function createLru(limit) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -184,9 +211,21 @@ export function createKartaViewProvider({
|
||||
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();
|
||||
let page = null;
|
||||
while (!seen.has(number) && seen.size < 6) {
|
||||
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;
|
||||
@@ -208,11 +247,18 @@ export function createKartaViewProvider({
|
||||
async function windowOf(photo) {
|
||||
const home = await homePage(photo);
|
||||
const list = home.photos;
|
||||
const at = list.findIndex((p) => p.id === photo.id);
|
||||
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 !== -1 && at < SEQUENCE_PAGE_EDGE && home.page > 1)
|
||||
if (at < SEQUENCE_PAGE_EDGE && home.page > 1)
|
||||
parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos);
|
||||
if (at !== -1 && at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore)
|
||||
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]));
|
||||
@@ -224,19 +270,39 @@ export function createKartaViewProvider({
|
||||
};
|
||||
}
|
||||
|
||||
/** Photo id → whether its file answers (archived ones do not), per session. */
|
||||
/**
|
||||
* 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, { signal } = {}) {
|
||||
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, { signal });
|
||||
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);
|
||||
probe.catch(() => probes.delete(photo.id));
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
// Decoded coverage tiles, kept a while: panning back asks nothing.
|
||||
const tiles = createTileCache({
|
||||
load: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
});
|
||||
const fetchTile = (tile, options) => tiles.get(tile, options);
|
||||
|
||||
/**
|
||||
* The next showable photo `delta` along the sequence, or null at its
|
||||
* end. Archived photos are skipped, a few at most.
|
||||
@@ -269,8 +335,7 @@ export function createKartaViewProvider({
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
fetchTile,
|
||||
zoomFor: kartaViewZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.line,
|
||||
@@ -307,20 +372,28 @@ export function createKartaViewProvider({
|
||||
passes: () => true,
|
||||
});
|
||||
|
||||
/** ‹ › open through the core (loading, errors, cones), camera still. */
|
||||
function openStepped(photoId) {
|
||||
context.actions.openImage(photoId, { frame: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* ‹ › open through the core (loading, errors, the sequence's cones),
|
||||
* without moving the camera; the cones re-centre once ‹ › walk off the
|
||||
* page they were drawn from.
|
||||
* 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 openStepped(photoId) {
|
||||
const opened = await context.actions.openImage(photoId, {
|
||||
frame: false,
|
||||
});
|
||||
const photo = photos.get(photoId);
|
||||
if (!opened || !photo?.sequenceId) return;
|
||||
if (drawnPage?.sequenceId !== photo.sequenceId) return;
|
||||
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);
|
||||
if (window && window.home !== drawnPage.page)
|
||||
// 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 });
|
||||
}
|
||||
|
||||
@@ -332,6 +405,7 @@ export function createKartaViewProvider({
|
||||
},
|
||||
step,
|
||||
onStep: openStepped,
|
||||
onShown: recentreCones,
|
||||
render: context.services?.render,
|
||||
...viewerOptions,
|
||||
});
|
||||
@@ -414,7 +488,9 @@ export function createKartaViewProvider({
|
||||
return false;
|
||||
},
|
||||
|
||||
selectSequence: (sequenceId) => cones.select(sequenceId),
|
||||
// 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;
|
||||
|
||||
@@ -136,7 +136,8 @@ export function createPanoramaRenderer(canvas) {
|
||||
let frame = 0;
|
||||
const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG };
|
||||
|
||||
function resize() {
|
||||
/** 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));
|
||||
@@ -144,12 +145,18 @@ export function createPanoramaRenderer(canvas) {
|
||||
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);
|
||||
|
||||
@@ -37,8 +37,9 @@ export const COVERAGE_MAX_HEIGHT_M = 6_000;
|
||||
export const COVERAGE_TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 0,
|
||||
// Chained ways are short: a dense z13 tile holds ~1,300.
|
||||
maxSequences: 9_000,
|
||||
// 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,
|
||||
@@ -65,8 +66,10 @@ 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;
|
||||
/** A sequence page is about a megabyte of JSON. */
|
||||
/** 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. */
|
||||
@@ -83,17 +86,18 @@ export const NEAREST_SEARCH_ZOOM = 18;
|
||||
|
||||
/**
|
||||
* Photos of a sequence fetched at once, around the open one. Records are
|
||||
* ~2.4 kB each, so a page is ~1.2 MB; the cones and ‹ › use it. Pages are in
|
||||
* ~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 = 500;
|
||||
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]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
/** A flat photo's sharp version, after the 1280 px one is on screen. */
|
||||
/** 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. */
|
||||
@@ -149,24 +153,46 @@ function positionOf(record) {
|
||||
|
||||
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*`.
|
||||
* 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 `https://${record.storage}.openstreetcam.org/files/photo/${path}`;
|
||||
return record[urlKey] || null;
|
||||
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 url = fileUrl(record, 'filepath', 'fileurl') || '';
|
||||
return url.includes('{{sizeprefix}}')
|
||||
? url.replace('{{sizeprefix}}', prefix)
|
||||
: null;
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,18 @@ test('photo files come from the storage host the record names, not the one its U
|
||||
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',
|
||||
'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|^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\//);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createKartaViewProvider, kartaViewZoomFor } from './index.js';
|
||||
import {
|
||||
createKartaViewProvider,
|
||||
createTileCache,
|
||||
kartaViewZoomFor,
|
||||
} from './index.js';
|
||||
import { SEQUENCE_PAGE_SIZE } from './policy.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs';
|
||||
@@ -46,7 +50,8 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
calls.photo.push(id);
|
||||
for (const [seq, indexes] of Object.entries(sequences))
|
||||
for (const index of indexes)
|
||||
if (`${seq}-${index}` === id) return photoRecord(id, { seq, index });
|
||||
if (`${seq}-${index}` === id)
|
||||
return photoRecord(id, { seq, index, metres: index * 5 });
|
||||
throw new Error('KartaView has no such photo');
|
||||
},
|
||||
getSequencePhotos: async (seq, { page, itemsPerPage }) => {
|
||||
@@ -58,7 +63,7 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
);
|
||||
return {
|
||||
photos: rows.map((index) =>
|
||||
photoRecord(`${seq}-${index}`, { seq, index }),
|
||||
photoRecord(`${seq}-${index}`, { seq, index, metres: index * 5 }),
|
||||
),
|
||||
hasMore: page * itemsPerPage < indexes.length,
|
||||
};
|
||||
@@ -161,8 +166,9 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing
|
||||
await instance.viewer.open(id);
|
||||
assert.deepEqual(source.calls.photo, [], 'the lookup filled the cache');
|
||||
assert.equal(
|
||||
loads[0],
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/near.jpg',
|
||||
Buffer.from(loads[0].split('/').at(-1), 'base64url').toString(),
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/near.jpg',
|
||||
'the proxy shrinks the photo on its real storage host',
|
||||
);
|
||||
const none = setup({ nearest: [photoRecord('far', { metres: 80 })] });
|
||||
assert.equal(
|
||||
@@ -321,3 +327,131 @@ test('archived photos are skipped: by the nearest lookup, and by › and ‹', a
|
||||
'a record with no file is not a photo',
|
||||
);
|
||||
});
|
||||
|
||||
/** A stand-in Cesium viewer the cones can draw on; `cones()` lists their photo indexes. */
|
||||
async function withCones(t, env) {
|
||||
const Cesium = await import('cesium');
|
||||
const drawing = new Proxy({}, { get: () => () => {}, set: () => true });
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
};
|
||||
t.after(() => (globalThis.document = saved));
|
||||
let collection = null;
|
||||
env.instance.init({
|
||||
scene: {
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
updateHeight: () => () => {},
|
||||
getHeight: () => undefined,
|
||||
primitives: { add: (c) => (collection = c), remove() {} },
|
||||
preRender: { addEventListener: () => () => {} },
|
||||
},
|
||||
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) },
|
||||
});
|
||||
return () => {
|
||||
const indexes = [];
|
||||
for (let i = 0; i < collection.length; i++)
|
||||
indexes.push(Number(collection.get(i).id.split('-').at(-1)));
|
||||
return [Math.min(...indexes), Math.max(...indexes)];
|
||||
};
|
||||
}
|
||||
|
||||
test('the cones follow the open photo to another page of a long sequence, cached or not', async (t) => {
|
||||
const size = SEQUENCE_PAGE_SIZE;
|
||||
const env = await mounted({
|
||||
sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) },
|
||||
});
|
||||
const cones = await withCones(t, env);
|
||||
const settleLong = async () => {
|
||||
for (let i = 0; i < 40; i++) await settle();
|
||||
};
|
||||
await env.instance.viewer.open('s-10');
|
||||
await env.instance.selectSequence('s');
|
||||
await settleLong();
|
||||
assert.ok(cones()[1] < size, `first page: ${cones()}`);
|
||||
// A line click further along the same drive: the core does not reselect.
|
||||
await env.instance.viewer.open(`s-${size * 4 + 10}`);
|
||||
await settleLong();
|
||||
assert.ok(cones()[0] >= size * 3, `re-centred: ${cones()}`);
|
||||
// Closed and reopened on another page: not the sequence's cached cones.
|
||||
env.instance.clearSequence();
|
||||
await env.instance.viewer.open(`s-${size * 2 + 10}`);
|
||||
await env.instance.selectSequence('s');
|
||||
await settleLong();
|
||||
assert.ok(
|
||||
cones()[0] <= size * 2 + 10 && cones()[1] >= size * 2 + 10,
|
||||
`around the photo: ${cones()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a storage hiccup is not remembered as an archived photo', async () => {
|
||||
const env = setup({ nearest: [photoRecord('near', { metres: 3 })] });
|
||||
let answer = null;
|
||||
env.source.probeImage = async () => answer;
|
||||
// No answer: the photo is tried rather than called archived.
|
||||
assert.equal(
|
||||
await env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
'near',
|
||||
);
|
||||
answer = false;
|
||||
await assert.rejects(
|
||||
env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
/archived/,
|
||||
'asked again, and now it is gone',
|
||||
);
|
||||
});
|
||||
|
||||
test('a photo far past a run of deleted photos still finds its page and neighbours', async () => {
|
||||
const indexes = [
|
||||
...Array.from({ length: 1000 }, (_, i) => i),
|
||||
...Array.from({ length: 20 }, (_, i) => 6000 + i),
|
||||
];
|
||||
const { instance, poses, next, prev } = await mounted({
|
||||
sequences: { s: indexes },
|
||||
});
|
||||
await instance.viewer.open('s-6005');
|
||||
for (let i = 0; i < 12; i++) await settle();
|
||||
assert.equal(next.disabled, false);
|
||||
assert.equal(prev.disabled, false);
|
||||
await press(next);
|
||||
assert.equal(poses.at(-1), 's-6006');
|
||||
});
|
||||
|
||||
test('coverage tiles are kept for a while: panning back asks KartaView nothing', async () => {
|
||||
let now = 0;
|
||||
const asked = [];
|
||||
const tiles = createTileCache({
|
||||
load: async (tile) => {
|
||||
asked.push(`${tile.z}/${tile.x}/${tile.y}`);
|
||||
return { sequences: [], overview: [] };
|
||||
},
|
||||
size: 2,
|
||||
ttlMs: 1000,
|
||||
now: () => now,
|
||||
});
|
||||
const a = { x: 1, y: 2, z: 14 };
|
||||
const b = { x: 2, y: 2, z: 14 };
|
||||
const c = { x: 3, y: 2, z: 14 };
|
||||
const first = await tiles.get(a, {});
|
||||
assert.equal(await tiles.get(a, {}), first, 'the same decoded tile');
|
||||
await tiles.get(b, {});
|
||||
await tiles.get(c, {});
|
||||
await tiles.get(a, {});
|
||||
assert.deepEqual(
|
||||
asked,
|
||||
['14/1/2', '14/2/2', '14/3/2', '14/1/2'],
|
||||
'oldest dropped',
|
||||
);
|
||||
now = 2000;
|
||||
await tiles.get(a, {});
|
||||
assert.equal(asked.length, 5, 'stale after the TTL');
|
||||
// A failed load is not kept.
|
||||
const failing = createTileCache({
|
||||
load: async () => {
|
||||
throw new Error('KartaView did not answer in time — try again');
|
||||
},
|
||||
size: 2,
|
||||
ttlMs: 1000,
|
||||
});
|
||||
await assert.rejects(failing.get(a, {}), /did not answer/);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
MAX_RESPONSE_BYTES,
|
||||
KARTAVIEW_API_HOST,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
@@ -25,6 +26,8 @@ export class KartaViewSourceError extends Error {
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer in time — try again';
|
||||
/** 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) {
|
||||
@@ -50,13 +53,18 @@ function retryAfterSeconds(response) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/** A first-in, first-out gate letting at most `limit` tasks run at once. */
|
||||
export function createLimiter(limit) {
|
||||
/**
|
||||
* 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 } = queue.shift();
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
@@ -111,7 +119,7 @@ export function createKartaViewSource({
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function getJson(url, { signal, timeoutMs }) {
|
||||
@@ -135,6 +143,11 @@ export function createKartaViewSource({
|
||||
`KartaView answered HTTP ${response.status}`,
|
||||
{ status: response.status },
|
||||
);
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
if (length > MAX_RESPONSE_BYTES)
|
||||
throw new KartaViewSourceError('KartaView sent too much data', {
|
||||
status: response.status,
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
@@ -203,19 +216,27 @@ export function createKartaViewSource({
|
||||
return {
|
||||
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
return tileLimiter.run(
|
||||
() =>
|
||||
getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||
signal,
|
||||
timeoutMs: TILE_TIMEOUT_MS,
|
||||
}),
|
||||
{ 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?.message !== NO_ANSWER) throw error;
|
||||
return once();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a photo file can be fetched. Archived photos answer 409 (and
|
||||
* a HEAD says 200 regardless), so this GETs and stops at the headers.
|
||||
* 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;
|
||||
@@ -229,10 +250,11 @@ export function createKartaViewSource({
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
return response.ok;
|
||||
if (response.ok) return true;
|
||||
return GONE_STATUSES.includes(response.status) ? false : null;
|
||||
} catch {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
return false;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
|
||||
@@ -282,8 +282,67 @@ test('an image probe reads only the status: an archived photo (409) is not showa
|
||||
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'),
|
||||
false,
|
||||
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', async () => {
|
||||
let calls = 0;
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new TypeError('Failed to fetch');
|
||||
return ok({ lines: [] });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from './panorama.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
IMAGE_TIMEOUT_MS,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
PANO_FULL_SIZE,
|
||||
@@ -50,6 +51,7 @@ export function loadImage(
|
||||
* @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 {object} [options.render] Render governor.
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
@@ -59,6 +61,7 @@ export function createKartaViewViewer({
|
||||
getPhoto,
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
@@ -150,12 +153,18 @@ export function createKartaViewViewer({
|
||||
])
|
||||
step(target, delta).then(
|
||||
(id) => {
|
||||
if (photo === target && button && !id) button.disabled = true;
|
||||
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';
|
||||
@@ -180,6 +189,26 @@ export function createKartaViewViewer({
|
||||
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;
|
||||
try {
|
||||
lost?.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
if (showing === 'pano' && photo) {
|
||||
const again = photo.id;
|
||||
show(null);
|
||||
adapter.open(again).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -194,13 +223,14 @@ export function createKartaViewViewer({
|
||||
from = await getPhoto(fromId);
|
||||
if (from.sequenceId) nextId = await step(from, delta);
|
||||
} catch {
|
||||
nextId = null;
|
||||
// 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) button.disabled = true;
|
||||
if (button && photo?.id === fromId) disable(button);
|
||||
return;
|
||||
}
|
||||
openingId = nextId;
|
||||
@@ -209,20 +239,42 @@ export function createKartaViewViewer({
|
||||
});
|
||||
}
|
||||
|
||||
/** 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' || event.button !== 0) return;
|
||||
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.setPointerCapture?.(event.pointerId);
|
||||
canvas.classList.add('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
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);
|
||||
@@ -236,13 +288,15 @@ export function createKartaViewViewer({
|
||||
}
|
||||
|
||||
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') return;
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
|
||||
@@ -256,9 +310,30 @@ export function createKartaViewViewer({
|
||||
event.metaKey
|
||||
)
|
||||
return;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
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();
|
||||
stepBy(event.key === 'ArrowLeft' ? -1 : 1);
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +352,7 @@ export function createKartaViewViewer({
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView photo. Drag to look around a 360° photo; ← and → step along the sequence',
|
||||
'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';
|
||||
@@ -310,11 +385,14 @@ export function createKartaViewViewer({
|
||||
listen(canvas, 'pointerup', onPointerUp);
|
||||
listen(canvas, 'pointercancel', onPointerUp);
|
||||
listen(canvas, 'wheel', onWheel, { passive: false });
|
||||
listen(canvas, 'webglcontextlost', onContextLost);
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
pointers.clear();
|
||||
pinch = null;
|
||||
for (const remove of removeListeners) remove();
|
||||
removeListeners = [];
|
||||
if (poseFrame) cancelAnimationFrame(poseFrame);
|
||||
@@ -355,25 +433,36 @@ export function createKartaViewViewer({
|
||||
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||
{ crossOrigin: true },
|
||||
);
|
||||
if (seq !== openSeq) return false;
|
||||
if (seq !== openSeq || !renderer) return false;
|
||||
if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
renderer.setImage(image);
|
||||
photo = target;
|
||||
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
|
||||
show('pano');
|
||||
renderer.setImage(image);
|
||||
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;
|
||||
try {
|
||||
image = await load(target.urls.view, { crossOrigin: false });
|
||||
} catch (error) {
|
||||
// No 1280 px rendition: the proxy can shrink the full-size photo.
|
||||
const fallback = resizedImageUrl(target.urls.full, FLAT_FULL_SIZE);
|
||||
if (target.isPano || !fallback || seq !== openSeq) throw error;
|
||||
image = await load(fallback, { crossOrigin: false });
|
||||
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;
|
||||
photo = target;
|
||||
@@ -396,6 +485,7 @@ export function createKartaViewViewer({
|
||||
} else shown = await showFlat(target, seq);
|
||||
if (!shown) return;
|
||||
emit();
|
||||
onShown(target);
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
|
||||
@@ -404,6 +494,7 @@ export function createKartaViewViewer({
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
if (root && root.parentNode === host) return;
|
||||
teardown();
|
||||
rendererFailed = false;
|
||||
build(host);
|
||||
},
|
||||
|
||||
|
||||
@@ -51,12 +51,15 @@ const PHOTOS = {
|
||||
},
|
||||
};
|
||||
|
||||
function fakeRenderer() {
|
||||
function fakeRenderer(canvas) {
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
return {
|
||||
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);
|
||||
@@ -70,7 +73,12 @@ function fakeRenderer() {
|
||||
};
|
||||
}
|
||||
|
||||
function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
function setup({
|
||||
failLoads = [],
|
||||
rendererThrows = false,
|
||||
steps = {},
|
||||
failSteps = false,
|
||||
} = {}) {
|
||||
const loads = [];
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
@@ -79,7 +87,10 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
if (!PHOTOS[id]) throw new Error('no such photo');
|
||||
return PHOTOS[id];
|
||||
},
|
||||
step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null,
|
||||
step: async (photo, 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);
|
||||
@@ -91,9 +102,9 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
throw new Error('The KartaView photo could not be loaded');
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => {
|
||||
createRenderer: (canvas) => {
|
||||
if (rendererThrows) throw new Error('WebGL is unavailable');
|
||||
const renderer = fakeRenderer();
|
||||
const renderer = fakeRenderer(canvas);
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
@@ -149,7 +160,11 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
|
||||
},
|
||||
);
|
||||
assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//);
|
||||
assert.equal(loads[0].url, PHOTOS.flat1.urls.view);
|
||||
// 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(
|
||||
@@ -162,12 +177,13 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
|
||||
assert.equal(picture.style.objectFit, 'cover');
|
||||
});
|
||||
|
||||
test('a missing 1280 px rendition falls back to the proxy-shrunk full photo', async () => {
|
||||
const { viewer, host, loads } = setup({ failLoads: ['/lth/'] });
|
||||
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.match(loads[1].url, /rs:fit:2560:2560/);
|
||||
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 () => {
|
||||
@@ -291,3 +307,120 @@ test('a newer open wins; close hides the photo; unmount tears down the panorama'
|
||||
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 } = setup({ failSteps: true });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { next } = parts(host);
|
||||
next.disabled = false;
|
||||
next.fire('click');
|
||||
for (let i = 0; i < 4; i++) 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 } = setup({
|
||||
steps: { 'pano1:1': 'flat1' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const { root } = parts(host);
|
||||
const view = () => renderers[0].getView();
|
||||
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 before = view().fov;
|
||||
root.fire('keydown', { key: '+' });
|
||||
assert.ok(view().fov < before, 'zoomed in');
|
||||
root.fire('keydown', { key: '-' });
|
||||
root.fire('keydown', { key: '-' });
|
||||
assert.ok(view().fov > before, 'zoomed out');
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.deepEqual(stepped, [], 'none of those stepped');
|
||||
root.fire('keydown', { key: 'ArrowRight' });
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps');
|
||||
});
|
||||
|
||||
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 } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
parts(host).canvas.fire('webglcontextlost');
|
||||
assert.equal(renderers[0].destroyed, true);
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(renderers.length, 2, 'a new renderer');
|
||||
assert.equal(poses.at(-1).imageId, 'pano1');
|
||||
assert.equal(parts(host).canvas.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);
|
||||
});
|
||||
@@ -22,8 +22,8 @@ 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]
|
||||
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @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 {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
@@ -67,6 +67,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
|
||||
|
||||
Reference in new issue
Block a user