From 7485b16ded0158b804f1ac07da7e25b71eae0d5d Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 5 Oct 2026 17:12:41 +0700 Subject: [PATCH 01/17] fix(overpass): do not serve cached answers from a different upstream (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both disk caches fed by OVERPASS_UPSTREAMS key only on the query — the Overpass proxy on the sanitized request body, the installations proxy on the bbox — so an entry written against one instance is a hit for the same query against another. After an operator repoints OVERPASS_UPSTREAMS, the previous upstream's answers keep being served for 7 days, 30 for boundary-class queries, and indefinitely through the serve-stale path. An install that predates #742 has a cache written from the public instances that were the default then, and configuring your own instance does not displace it. Cached answers now carry a tag identifying the configured upstream set, and a disk read refuses an entry that does not match it, on the stale path too. With nothing configured any entry is still accepted: there is no instance to contradict, and serving last-good data at any age is what the not-configured path exists for. The tag is a hash of the resolved endpoint list, never the URLs; it is written to disk and compared on read, and is not sent to clients or surfaced in errors. One line is logged once per process when a foreign entry is ignored, naming the variable and what will happen, as local-receivers.js already does for a rejected feed. Cost worth naming: on upgrade every existing entry is untagged, so the first outage after upgrading serves nothing where it previously served stale data, until each query is fetched once from the configured instance. --- server/providers/military-installations.js | 13 +++- .../providers/military-installations/cache.js | 4 ++ server/providers/overpass/cache.js | 6 ++ server/providers/overpass/constants.js | 63 +++++++++++++++++-- server/providers/overpass/transport.js | 4 ++ src/overpassProxy.test.mjs | 52 ++++++++++++++- 6 files changed, 135 insertions(+), 7 deletions(-) diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 08ad87d9f..7d252b8ed 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -9,7 +9,10 @@ import { fetchOverpassPayload, overpassNotConfigured, } from './overpass/transport.js'; -import { resolveOverpassUpstreams } from './overpass/constants.js'; +import { + resolveOverpassUpstreams, + resolveOverpassUpstreamTag, +} from './overpass/constants.js'; import { _militaryInstallationCache, trimMilitaryInstallationCache, @@ -74,7 +77,13 @@ function militaryInstallationsProxy() { retrievedAt: new Date().toISOString(), status: 'ready', }; - const entry = { payload, cachedAt: Date.now() }; + // Tagged with the configured upstream set so the disk layer does not serve + // this answer back after the operator repoints (see overpass/cache.js). + const entry = { + payload, + cachedAt: Date.now(), + upstreams: resolveOverpassUpstreamTag(), + }; _militaryInstallationCache.set(key, entry); trimMilitaryInstallationCache(); writeMilitaryInstallationDisk(key, entry); diff --git a/server/providers/military-installations/cache.js b/server/providers/military-installations/cache.js index c77627e29..4d77981e6 100644 --- a/server/providers/military-installations/cache.js +++ b/server/providers/military-installations/cache.js @@ -5,6 +5,7 @@ import { MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, } from './constants.js'; +import { cachedByConfiguredUpstream } from '../overpass/constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { promises as fsp } from 'node:fs'; @@ -114,6 +115,9 @@ async function readMilitaryInstallationDisk( const entry = JSON.parse( await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'), ); + // Same reason as the Overpass disk cache: the key is the bbox alone, so an + // entry written against a different OVERPASS_UPSTREAMS is otherwise a hit. + if (!cachedByConfiguredUpstream(entry)) return null; if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null; return migrateMilitaryInstallationEntry(entry); } catch { diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index b4e53ab21..442f599a6 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -5,6 +5,7 @@ import { OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES, + cachedByConfiguredUpstream, } from './constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; @@ -47,6 +48,11 @@ async function readOverpassDisk(cacheKey, maxAgeMs) { // Older versions persisted 4xx refusals with normal data TTLs. Ignore // them on both fresh and stale reads so an upgrade can recover immediately. if (!overpassPayloadIsData(payload)) return null; + // The key is the query alone, so a file written by a different upstream — + // including the public instances that were the default before #742 — is a + // hit for the same query. Refuse it: the operator's configured instance + // must be what answers, on the stale path too. + if (!cachedByConfiguredUpstream(payload)) return null; if (Date.now() - payload.cachedAt > maxAgeMs) return null; return payload; } catch { diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index 40f244d11..2a0d4a3ef 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { createHash } from 'node:crypto'; // --------------------------------------------------------------------------- // Overpass API proxy constants and cache state @@ -27,16 +28,68 @@ function parseOverpassUpstreams(raw) { return endpoints.slice(0, 8); } -let upstreamMemo = { raw: null, endpoints: [] }; +let upstreamMemo = { raw: null, endpoints: [], tag: 'none' }; + +/** + * Short, stable identifier for the configured upstream set. Cached responses + * carry it so an answer produced by a different instance is not served after + * the operator repoints `OVERPASS_UPSTREAMS` — the disk cache outlives the + * configuration change by 7 days, a month for boundary queries, and forever on + * the serve-stale path. It is a hash, never the URLs, and it is only ever + * compared on read: it is not sent to clients and does not appear in errors. + */ +function upstreamTag(endpoints) { + if (!endpoints.length) return 'none'; + return createHash('sha1') + .update(endpoints.join('\n')) + .digest('hex') + .slice(0, 12); +} /** Resolve after environment loading. Public Overpass instances are not used by default. */ function resolveOverpassUpstreams() { - const raw = process.env.OVERPASS_UPSTREAMS || ''; - if (upstreamMemo.raw !== raw) - upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) }; + readUpstreamMemo(); return [...upstreamMemo.endpoints]; } +/** The configured-upstream tag stored alongside, and checked against, cache entries. */ +function resolveOverpassUpstreamTag() { + return readUpstreamMemo().tag; +} + +let reportedForeignCache = false; + +/** + * Was this cached entry produced by the upstream set configured now? + * + * With nothing configured there is no instance to contradict, and serving + * last-good data at any age is the point of the cache (the proxy's + * not-configured path depends on it), so any entry is accepted. Once an + * operator does configure an instance, only its own answers are served: an + * entry with no tag, or one from a different instance, is refused rather than + * matched on the query alone. Shared by both caches fed by these upstreams. + */ +function cachedByConfiguredUpstream(entry) { + const tag = resolveOverpassUpstreamTag(); + if (tag === 'none' || entry?.upstreams === tag) return true; + if (!reportedForeignCache) { + reportedForeignCache = true; + console.warn( + '[Overpass] ignoring cached responses produced by a different OVERPASS_UPSTREAMS; they will be refetched from the configured instance.', + ); + } + return false; +} + +function readUpstreamMemo() { + const raw = process.env.OVERPASS_UPSTREAMS || ''; + if (upstreamMemo.raw !== raw) { + const endpoints = parseOverpassUpstreams(raw); + upstreamMemo = { raw, endpoints, tag: upstreamTag(endpoints) }; + } + return upstreamMemo; +} + /** * TTL for FRESH cached Overpass responses (ms). Road geometry is static for * months — the original 45 s TTL forced a public-mirror round-trip on nearly @@ -153,6 +206,8 @@ export { OVERPASS_MAX_RESPONSE_BYTES, parseOverpassUpstreams, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, + cachedByConfiguredUpstream, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, }; diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 86b9eae15..38c31bb08 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -1,6 +1,7 @@ import { OVERPASS_MAX_RESPONSE_BYTES, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, } from './constants.js'; @@ -187,6 +188,9 @@ async function fetchOverpassPayload( contentType: 'application/json', // Never retain a secret-bearing endpoint in cache or response metadata. endpoint: 'configured', + // Which configured instance produced this, as a hash rather than a URL, + // so a cached answer is not reused after the operator repoints. + upstreams: resolveOverpassUpstreamTag(), rateLimited: false, }; } catch { diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs index 71b62aa2d..ed4539fda 100644 --- a/src/overpassProxy.test.mjs +++ b/src/overpassProxy.test.mjs @@ -12,6 +12,7 @@ import { createHash, randomUUID } from 'node:crypto'; import path from 'node:path'; import { Readable } from 'node:stream'; import createViteConfig, { fetchOverpassPayload, overpassPayloadIsData, readOverpassDisk } from '../vite.config.js'; +import { resolveOverpassUpstreamTag } from '../server/providers/overpass/constants.js'; const DATA = { status: 200, body: '{"elements":[]}' }; @@ -155,7 +156,13 @@ test('coalesced outage callers both receive last-good data, never a cached refus const directory = path.join(process.cwd(), '.gev-cache', 'overpass'); const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`); await mkdir(directory, { recursive: true }); - const stale = { ...DATA, cachedAt: Date.now() - 40 * 86400000 }; + // Last-good data was fetched from the instance configured above, so it + // carries that instance's tag the way the transport now writes it. + const stale = { + ...DATA, + cachedAt: Date.now() - 40 * 86400000, + upstreams: resolveOverpassUpstreamTag(), + }; await writeFile(file, JSON.stringify(stale)); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -188,3 +195,46 @@ test('coalesced outage callers both receive last-good data, never a cached refus } } }); + +test('a disk entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => { + const prior = process.env.OVERPASS_UPSTREAMS; + t.after(() => { + if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS; + else process.env.OVERPASS_UPSTREAMS = prior; + }); + const key = `overpass-upstream-switch-${randomUUID()}`; + const directory = path.join(process.cwd(), '.gev-cache', 'overpass'); + const file = path.join( + directory, + `${createHash('sha1').update(key).digest('hex')}.json`, + ); + await mkdir(directory, { recursive: true }); + try { + // Written while one instance was configured... + process.env.OVERPASS_UPSTREAMS = 'https://first.example/api/interpreter'; + const first = resolveOverpassUpstreamTag(); + await writeFile( + file, + JSON.stringify({ ...DATA, cachedAt: Date.now(), upstreams: first }), + ); + assert.ok(await readOverpassDisk(key, 60000), 'its own entry is a hit'); + + // ...is not an answer once the operator points at a different one, on the + // stale path either: the key is the query alone, so it would otherwise hit. + process.env.OVERPASS_UPSTREAMS = 'https://second.example/api/interpreter'; + assert.notEqual(resolveOverpassUpstreamTag(), first, 'tag follows the env'); + assert.equal(await readOverpassDisk(key, 60000), null, 'fresh read refused'); + assert.equal( + await readOverpassDisk(key, Infinity), + null, + 'stale read refused', + ); + + // With nothing configured there is no instance to contradict, and serving + // last-good data is what the not-configured path exists for. + delete process.env.OVERPASS_UPSTREAMS; + assert.ok(await readOverpassDisk(key, Infinity), 'unconfigured still serves'); + } finally { + await unlink(file); + } +}); From 42543c1fa1f547db940dd46b0f5c781cff11be9a Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:13:53 +0200 Subject: [PATCH 02/17] feat(bikeshare): add Oslo, Bergen and Trondheim Bysykkel Add Norway's three Urban Sharing city-bike systems to the Bike Share layer and get_bike_share. The feeds are keyless GBFS on gbfs.urbansharing.com, which joins the proxy allowlist. The proxy now sends the Client-Identifier header the operators ask clients to send. Attributed under NLOD 2.0 in DATA_SOURCES.md and the data credits. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ DATA_SOURCES.md | 1 + docs/CURRENT-STATE.md | 5 ++- server/providers/gbfs.js | 6 ++++ src/data/dataCredits.js | 7 ++++ src/data/gbfsProxy.test.mjs | 16 +++++++++ src/data/gbfsSource.js | 1 + src/layers/bikeshare/proxyRoute.test.mjs | 44 ++++++++++++++++++++++++ src/layers/bikeshare/registry.js | 44 ++++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 25 ++++++++++++++ 10 files changed, 154 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..a2fc2d1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and + Trondheim Bysykkel (Urban Sharing, ~440 stations). The feeds are keyless + GBFS on `gbfs.urbansharing.com`, which joins the proxy allowlist; the proxy + now sends the `Client-Identifier: gods-eye-view` header the operators ask + for. Attributed under NLOD 2.0. `get_bike_share` covers the three cities too. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..07782fd5e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -56,6 +56,7 @@ How to read this: | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..8a25ccb8c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -961,6 +961,9 @@ The station reader addresses the proxy as `/api/gbfs/`; the feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. +Every upstream request carries `Client-Identifier: gods-eye-view`, which Urban +Sharing asks of clients of the Oslo, Bergen and Trondheim Bysykkel feeds +(`gbfs.urbansharing.com`); other operators ignore it. ## Remaining local service modules @@ -2922,7 +2925,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 9741bb4e6..f55299992 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -11,6 +11,9 @@ import { /** Upstream fetch timeout for GBFS requests (ms). */ const GBFS_PROXY_TIMEOUT_MS = 12000; +/** Value of the Client-Identifier header sent with every upstream request. */ +export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view'; + export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB function gbfsRedirectHost(location, requestUrl) { @@ -60,6 +63,9 @@ export async function fetchGbfsUpstream( headers: { Accept: 'application/json', 'User-Agent': 'gods-eye-view-gbfs-proxy/1.0', + // Urban Sharing (the Norwegian city-bike feeds) asks every client to + // name itself in this header; other operators ignore it. + 'Client-Identifier': GBFS_CLIENT_IDENTIFIER, }, redirect: 'manual', signal: controller.signal, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..87358dafc 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -251,6 +251,13 @@ export const DATA_CREDITS = [ key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', }, + { + key: 'urbansharing-gbfs', + html: + 'City bikes (Norway): contains data under the ' + + 'Norwegian licence for Open Government data (NLOD) ' + + 'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel', + }, { key: 'osrm-routing', // The service asks for its attribution to carry a "fix the map" link, so diff --git a/src/data/gbfsProxy.test.mjs b/src/data/gbfsProxy.test.mjs index 191cb57ba..af107b38e 100644 --- a/src/data/gbfsProxy.test.mjs +++ b/src/data/gbfsProxy.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + GBFS_CLIENT_IDENTIFIER, GBFS_MAX_BODY_BYTES, fetchGbfsUpstream, } from '../../server/providers/gbfs.js'; @@ -56,6 +57,21 @@ test('GBFS upstream fetch passes a direct 200 through unchanged', async () => { }); }); +test('GBFS upstream fetch names the app in the Client-Identifier header', async () => { + let observedHeaders = null; + await fetchGbfsUpstream( + 'https://gbfs.urbansharing.com/oslobysykkel.no/station_status.json', + { + fetchImpl: async (_url, options) => { + observedHeaders = options.headers; + return new Response(STATION_BODY, { status: 200 }); + }, + }, + ); + assert.equal(GBFS_CLIENT_IDENTIFIER, 'gods-eye-view'); + assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER); +}); + test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => { const result = await fetchGbfsUpstream(STATION_URL, { fetchImpl: async () => new Response(null, { status: 404 }), diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js index a15a806d9..f06fa24f2 100644 --- a/src/data/gbfsSource.js +++ b/src/data/gbfsSource.js @@ -8,6 +8,7 @@ const GBFS_ALLOWED_HOSTS = new Set([ 'austin.publicbikesystem.net', 'hon.publicbikesystem.net', 'chat.publicbikesystem.net', + 'gbfs.urbansharing.com', ]); /** diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index 88b98f31b..9158a6bdf 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -17,6 +17,7 @@ import { createModel } from './model.js'; import { CITY_BY_ID } from './registry.js'; const AUSTIN = CITY_BY_ID.get('austin-capmetro'); +const OSLO = CITY_BY_ID.get('oslo-bysykkel'); const STATION_INFORMATION = { data: { @@ -204,6 +205,49 @@ test('the proxy relays its cache policy and upstream host through the mounted ro ); }); +test('the Norwegian city-bike systems are served through the mounted route', async () => { + for (const [id, systemId] of [ + ['oslo-bysykkel', 'oslobysykkel.no'], + ['bergen-bysykkel', 'bergenbysykkel.no'], + ['trondheim-bysykkel', 'trondheimbysykkel.no'], + ]) { + const system = CITY_BY_ID.get(id); + assert.ok(system, `${id} is registered`); + assert.deepEqual(system.hosts, ['gbfs.urbansharing.com']); + assert.equal( + system.stationStatusUrl, + `https://gbfs.urbansharing.com/${systemId}/station_status.json`, + ); + } + + await withUpstream( + { + [OSLO.stationInformationUrl]: STATION_INFORMATION, + [OSLO.stationStatusUrl]: STATION_STATUS, + }, + async (requested) => { + const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() }); + const info = await source.getStations(OSLO.stationInformationUrl); + const status = await source.getStations(OSLO.stationStatusUrl); + assert.deepEqual(requested, [ + OSLO.stationInformationUrl, + OSLO.stationStatusUrl, + ]); + assert.equal(info.data.stations.length, 1); + assert.equal(status.data.stations.length, 1); + + const response = await mountGbfsProxy()( + '/api/gbfs/' + encodeURIComponent(OSLO.stationStatusUrl), + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get('x-gbfs-upstream'), + 'gbfs.urbansharing.com', + ); + }, + ); +}); + test('the query-string shape the client used to send is refused by the mounted route', async () => { await withUpstream( { [AUSTIN.stationStatusUrl]: STATION_STATUS }, diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..37b7d2c4e 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -23,6 +23,26 @@ function bcycleEntry({ ...buildBcycleUrls(systemId), }; } +function urbanSharingEntry({ + id, + city, + centerLat, + centerLon, + systemId, + provider, + loadRadiusKm = 100, +}) { + return { + id, + city, + centerLat, + centerLon, + loadRadiusKm, + provider, + stationInformationUrl: `https://gbfs.urbansharing.com/${systemId}/station_information.json`, + stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`, + }; +} const RAW_GBFS_CITY_REGISTRY = [ { id: 'nyc-citibike', @@ -318,6 +338,30 @@ const RAW_GBFS_CITY_REGISTRY = [ centerLon: -119.6982, systemId: 'bcycle_santabarbara', }), + urbanSharingEntry({ + id: 'oslo-bysykkel', + city: 'Oslo, Norway', + centerLat: 59.9139, + centerLon: 10.7522, + systemId: 'oslobysykkel.no', + provider: 'Oslo Bysykkel', + }), + urbanSharingEntry({ + id: 'bergen-bysykkel', + city: 'Bergen, Norway', + centerLat: 60.3913, + centerLon: 5.3221, + systemId: 'bergenbysykkel.no', + provider: 'Bergen Bysykkel', + }), + urbanSharingEntry({ + id: 'trondheim-bysykkel', + city: 'Trondheim, Norway', + centerLat: 63.4305, + centerLon: 10.3951, + systemId: 'trondheimbysykkel.no', + provider: 'Trondheim Bysykkel', + }), ]; function normalizeRegistryEntry(entry) { const id = String(entry?.id || '') diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index af8142305..1626aa3dd 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -429,6 +429,31 @@ test('bike share names the systems it did not search', async () => { ); }); +test('bike share over Oslo searches Oslo Bysykkel only', async () => { + const requested = []; + const result = await composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: async (url) => { + requested.push(url); + return { data: { stations: [] } }; + }, + }, + }, + }).call('get_bike_share', { + area: { lat: 59.9139, lon: 10.7522, radius_km: 5 }, + }); + assert.deepEqual(result.data.systems, ['Oslo, Norway']); + assert.deepEqual(result.data.systems_not_searched, []); + assert.ok( + requested.every((url) => + url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/'), + ), + ); +}); + test('traffic counts only roads inside a radius area and reports missing tiles', async () => { const flow = (segments, partial = false) => ({ getStatus: async () => ({ hasKey: true }), From fa61955cf15cab33e55707aba55efc598f35d6e9 Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:02:37 +0200 Subject: [PATCH 03/17] =?UTF-8?q?feat(bikeshare):=20add=20Kolumbus,=20Fart?= =?UTF-8?q?e=20and=20Lillestr=C3=B8m=20city=20bikes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover every Norwegian station-based city-bike system with an open GBFS feed: Kolumbus Bysykkel (Stavanger) and Farte Bysykkel (Skien/Porsgrunn) through Entur Mobility, and Bysykkel Lillestrøm through JCDecaux Cyclocity. api.entur.io and api.cyclocity.fr join the GBFS allowlist, each kept to its GBFS path prefix since both hosts front other APIs. Entur serves its feeds without a .json suffix, so the endpoint checks accept either form, and requests to Entur carry the ET-Client-Name header it requires. Attributed under NLOD (Entur) and Licence Ouverte (JCDecaux). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++-- DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 10 +++- server/providers/gbfs.js | 4 +- src/data/dataCredits.js | 13 ++++++ src/data/gbfsProxy.test.mjs | 20 ++++++++ src/data/gbfsSource.js | 59 ++++++++++++++++++++++-- src/layers/bikeshare/proxyRoute.test.mjs | 52 +++++++++++++++++++++ src/layers/bikeshare/registry.js | 58 +++++++++++++++++++++-- src/tools/queries/mobility.test.mjs | 28 +++++++++-- 10 files changed, 241 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2fc2d1e2..184e55230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ ## [Unreleased] - Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and - Trondheim Bysykkel (Urban Sharing, ~440 stations). The feeds are keyless - GBFS on `gbfs.urbansharing.com`, which joins the proxy allowlist; the proxy - now sends the `Client-Identifier: gods-eye-view` header the operators ask - for. Attributed under NLOD 2.0. `get_bike_share` covers the three cities too. + Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and + Farte Bysykkel in Skien/Porsgrunn (Entur Mobility), and Bysykkel + Lillestrøm (JCDecaux), about 760 stations in all. Every feed is keyless + GBFS. `gbfs.urbansharing.com`, `api.entur.io` and `api.cyclocity.fr` join + the proxy allowlist; the last two are kept to their GBFS paths. The proxy + now sends `Client-Identifier: gods-eye-view` on every request and + `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, + and accepts Entur's extensionless feed paths. Attributed under NLOD and + Licence Ouverte. `get_bike_share` covers the six systems too. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 07782fd5e..f7e283117 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -57,6 +57,8 @@ How to read this: | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | +| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | +| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a25ccb8c..55056d2ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -963,7 +963,13 @@ streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. Every upstream request carries `Client-Identifier: gods-eye-view`, which Urban Sharing asks of clients of the Oslo, Bergen and Trondheim Bysykkel feeds -(`gbfs.urbansharing.com`); other operators ignore it. +(`gbfs.urbansharing.com`); other operators ignore it. Requests to Entur +(`api.entur.io`, Kolumbus and Farte Bysykkel) also carry +`ET-Client-Name: gods-eye-view-bikeshare`. Entur serves its feeds without a +`.json` suffix, so the endpoint check accepts `station_information` and +`station_status` with or without it. Hosts that front more than GBFS are kept to +their GBFS tree: `api.entur.io` to `/mobility/v2/gbfs/` and `api.cyclocity.fr` +(Bysykkel Lillestrøm) to `/contracts/`. ## Remaining local service modules @@ -2925,7 +2931,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index f55299992..990642c8b 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -3,6 +3,7 @@ import { isAllowedGbfsHost, isAllowedGbfsPath, gbfsCacheControl, + gbfsUpstreamHeaders, } from '../../src/data/gbfsSource.js'; // --------------------------------------------------------------------------- @@ -66,6 +67,7 @@ export async function fetchGbfsUpstream( // Urban Sharing (the Norwegian city-bike feeds) asks every client to // name itself in this header; other operators ignore it. 'Client-Identifier': GBFS_CLIENT_IDENTIFIER, + ...gbfsUpstreamHeaders(new URL(url).hostname), }, redirect: 'manual', signal: controller.signal, @@ -187,7 +189,7 @@ export function gbfsProxy() { return; } - if (!isAllowedGbfsPath(upstreamUrl.pathname)) { + if (!isAllowedGbfsPath(upstreamUrl.pathname, upstreamUrl.hostname)) { res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 87358dafc..195d2487e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -258,6 +258,19 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel', }, + { + key: 'entur-mobility-gbfs', + html: + 'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' + + 'Norwegian licence for Open Government data (NLOD) ' + + 'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)', + }, + { + key: 'jcdecaux-gbfs', + html: + 'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' + + 'Licence Ouverte', + }, { key: 'osrm-routing', // The service asks for its attribution to carry a "fix the map" link, so diff --git a/src/data/gbfsProxy.test.mjs b/src/data/gbfsProxy.test.mjs index af107b38e..c95256137 100644 --- a/src/data/gbfsProxy.test.mjs +++ b/src/data/gbfsProxy.test.mjs @@ -72,6 +72,26 @@ test('GBFS upstream fetch names the app in the Client-Identifier header', async assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER); }); +test('GBFS upstream fetch adds ET-Client-Name for Entur and only for Entur', async () => { + const headersFor = async (url) => { + let observed = null; + await fetchGbfsUpstream(url, { + fetchImpl: async (_url, options) => { + observed = options.headers; + return new Response(STATION_BODY, { status: 200 }); + }, + }); + return observed; + }; + const entur = await headersFor( + 'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status', + ); + assert.equal(entur['ET-Client-Name'], 'gods-eye-view-bikeshare'); + assert.equal(entur['Client-Identifier'], GBFS_CLIENT_IDENTIFIER); + const lyft = await headersFor(STATION_URL); + assert.equal(lyft['ET-Client-Name'], undefined); +}); + test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => { const result = await fetchGbfsUpstream(STATION_URL, { fetchImpl: async () => new Response(null, { status: 404 }), diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js index f06fa24f2..8d4980948 100644 --- a/src/data/gbfsSource.js +++ b/src/data/gbfsSource.js @@ -9,6 +9,30 @@ const GBFS_ALLOWED_HOSTS = new Set([ 'hon.publicbikesystem.net', 'chat.publicbikesystem.net', 'gbfs.urbansharing.com', + 'api.entur.io', + 'api.cyclocity.fr', +]); + +/** + * Hosts that serve more than GBFS keep the proxy to their GBFS tree: Entur's + * api.entur.io fronts every Entur API, and api.cyclocity.fr every JCDecaux + * contract endpoint. + */ +const GBFS_HOST_PATH_PREFIXES = new Map([ + ['api.entur.io', '/mobility/v2/gbfs/'], + ['api.cyclocity.fr', '/contracts/'], +]); + +/** + * Identifying headers an operator asks every GBFS client to send. Entur rate + * limits or blocks consumers that do not send ET-Client-Name, in the form + * "company-application". + */ +const GBFS_HOST_HEADERS = new Map([ + [ + 'api.entur.io', + Object.freeze({ 'ET-Client-Name': 'gods-eye-view-bikeshare' }), + ], ]); /** @@ -29,13 +53,40 @@ export function isAllowedGbfsHost(hostname) { } /** - * Only allow station_information.json and station_status.json endpoints. + * Only allow station_information and station_status endpoints. The `.json` + * suffix is optional because Entur serves its feeds without one. When a + * hostname is given and that host serves more than GBFS, the path must also + * sit under the host's GBFS prefix. * * @param {string} pathname + * @param {string} [hostname] * @returns {boolean} */ -export function isAllowedGbfsPath(pathname) { - return /\/station_(information|status)\.json$/i.test(String(pathname || '')); +export function isAllowedGbfsPath(pathname, hostname = '') { + const path = String(pathname || ''); + if (!/\/station_(information|status)(\.json)?$/i.test(path)) return false; + const prefix = GBFS_HOST_PATH_PREFIXES.get( + String(hostname || '') + .trim() + .toLowerCase(), + ); + return !prefix || path.startsWith(prefix); +} + +/** + * Extra request headers an upstream GBFS host asks clients to send. + * + * @param {string} hostname + * @returns {Record} + */ +export function gbfsUpstreamHeaders(hostname) { + return ( + GBFS_HOST_HEADERS.get( + String(hostname || '') + .trim() + .toLowerCase(), + ) || {} + ); } /** @@ -48,7 +99,7 @@ export function isAllowedGbfsPath(pathname) { * @returns {string} Cache-Control header value. */ export function gbfsCacheControl(pathname) { - if (/\/station_information\.json$/i.test(String(pathname || ''))) { + if (/\/station_information(\.json)?$/i.test(String(pathname || ''))) { return 'public, max-age=300'; } return 'no-store'; diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index 9158a6bdf..9bb04ced0 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -248,6 +248,58 @@ test('the Norwegian city-bike systems are served through the mounted route', asy ); }); +test('every Norwegian city-bike system is registered and reachable through the mounted route', async () => { + const expected = { + 'stavanger-kolumbus': + 'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status', + 'skien-farte': + 'https://api.entur.io/mobility/v2/gbfs/v2/fartebysykkel/station_status', + 'lillestrom-bysykkel': + 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json', + }; + const upstream = {}; + for (const [id, statusUrl] of Object.entries(expected)) { + const system = CITY_BY_ID.get(id); + assert.ok(system, `${id} is registered`); + assert.equal(system.stationStatusUrl, statusUrl); + upstream[system.stationInformationUrl] = STATION_INFORMATION; + upstream[system.stationStatusUrl] = STATION_STATUS; + } + + await withUpstream(upstream, async (requested) => { + const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() }); + for (const id of Object.keys(expected)) { + const system = CITY_BY_ID.get(id); + const info = await source.getStations(system.stationInformationUrl); + const status = await source.getStations(system.stationStatusUrl); + assert.equal(info.data.stations.length, 1, `${id} information`); + assert.equal(status.data.stations.length, 1, `${id} status`); + } + assert.equal(requested.length, 6); + + // Entur's extensionless station_information keeps its 5-minute cache. + const kolumbus = CITY_BY_ID.get('stavanger-kolumbus'); + const response = await mountGbfsProxy()( + '/api/gbfs/' + encodeURIComponent(kolumbus.stationInformationUrl), + ); + assert.equal(response.headers.get('cache-control'), 'public, max-age=300'); + }); +}); + +test('the mounted route keeps multi-API hosts to their GBFS tree', async () => { + await withUpstream({}, async (requested) => { + const mounted = mountGbfsProxy(); + for (const target of [ + 'https://api.entur.io/journey-planner/v3/station_status', + 'https://api.cyclocity.fr/other/station_status.json', + ]) { + const response = await mounted('/api/gbfs/' + encodeURIComponent(target)); + assert.equal(response.status, 400, target); + } + assert.deepEqual(requested, []); + }); +}); + test('the query-string shape the client used to send is refused by the mounted route', async () => { await withUpstream( { [AUSTIN.stationStatusUrl]: STATION_STATUS }, diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index 37b7d2c4e..89508d42e 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -43,6 +43,27 @@ function urbanSharingEntry({ stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`, }; } +function enturEntry({ + id, + city, + centerLat, + centerLon, + systemId, + provider, + loadRadiusKm = 100, +}) { + const base = `https://api.entur.io/mobility/v2/gbfs/v2/${systemId}`; + return { + id, + city, + centerLat, + centerLon, + loadRadiusKm, + provider, + stationInformationUrl: `${base}/station_information`, + stationStatusUrl: `${base}/station_status`, + }; +} const RAW_GBFS_CITY_REGISTRY = [ { id: 'nyc-citibike', @@ -362,6 +383,35 @@ const RAW_GBFS_CITY_REGISTRY = [ systemId: 'trondheimbysykkel.no', provider: 'Trondheim Bysykkel', }), + enturEntry({ + id: 'stavanger-kolumbus', + city: 'Stavanger, Norway', + centerLat: 58.9, + centerLon: 5.72, + systemId: 'kolumbusbysykkel', + provider: 'Kolumbus Bysykkel', + }), + enturEntry({ + id: 'skien-farte', + city: 'Skien/Porsgrunn, Norway', + centerLat: 59.16, + centerLon: 9.64, + systemId: 'fartebysykkel', + provider: 'Farte Bysykkel', + loadRadiusKm: 40, + }), + { + id: 'lillestrom-bysykkel', + city: 'Lillestrøm, Norway', + centerLat: 59.9556, + centerLon: 11.0492, + loadRadiusKm: 40, + stationInformationUrl: + 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_information.json', + stationStatusUrl: + 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json', + provider: 'Bysykkel Lillestrøm', + }, ]; function normalizeRegistryEntry(entry) { const id = String(entry?.id || '') @@ -390,11 +440,13 @@ function normalizeRegistryEntry(entry) { ) { throw new Error(`GBFS entry "${id}" must use https URLs`); } - // Validate that URLs end with expected GBFS endpoint filenames - if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) { + // Validate that URLs end with expected GBFS endpoint names (Entur omits .json) + if ( + !/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname) + ) { throw new Error(`GBFS entry "${id}" station_information URL is invalid`); } - if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) { + if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) { throw new Error(`GBFS entry "${id}" station_status URL is invalid`); } diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 1626aa3dd..07482946b 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -429,7 +429,7 @@ test('bike share names the systems it did not search', async () => { ); }); -test('bike share over Oslo searches Oslo Bysykkel only', async () => { +test('bike share over Oslo searches only the nearby Norwegian systems', async () => { const requested = []; const result = await composeCatalog({ tools: coreTools, @@ -445,15 +445,35 @@ test('bike share over Oslo searches Oslo Bysykkel only', async () => { }).call('get_bike_share', { area: { lat: 59.9139, lon: 10.7522, radius_km: 5 }, }); - assert.deepEqual(result.data.systems, ['Oslo, Norway']); + assert.deepEqual(result.data.systems.sort(), [ + 'Lillestrøm, Norway', + 'Oslo, Norway', + ]); assert.deepEqual(result.data.systems_not_searched, []); assert.ok( - requested.every((url) => - url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/'), + requested.every( + (url) => + url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/') || + url.startsWith('https://api.cyclocity.fr/contracts/lillestrom/'), ), ); }); +test('bike share over Stavanger searches Kolumbus Bysykkel', async () => { + const result = await composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: async () => ({ data: { stations: [] } }), + }, + }, + }).call('get_bike_share', { + area: { lat: 58.97, lon: 5.7331, radius_km: 5 }, + }); + assert.deepEqual(result.data.systems, ['Stavanger, Norway']); +}); + test('traffic counts only roads inside a radius area and reports missing tiles', async () => { const flow = (segments, partial = false) => ({ getStatus: async () => ({ hasKey: true }), From a4d6181fb99764d2f5493c8238224bac4f7b125e Mon Sep 17 00:00:00 2001 From: Jeff Blakely <7987380+jeffblakely@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:26:05 -0400 Subject: [PATCH 04/17] feat(bikeshare): add Detroit, Pittsburgh, Aspen and Kona & Hilo Register four live US systems on Lyft's PBSC platform: MoGo (Detroit, 81 stations), POGOH (Pittsburgh, 60), WE-cycle (Aspen, 101) and HIBike (Kona and Hilo, 23). The proxy already allows *.publicbikesystem.net, so no server change is needed. HIBike's centre sits between Kailua-Kona and Hilo so a camera over either town loads the whole system. The test checks each system is registered, passes the proxy's host and endpoint checks, and has an activation radius covering its farthest station as measured against the live feed. --- CHANGELOG.md | 4 ++ src/layers/bikeshare/registry.js | 50 ++++++++++++++++++++++ src/layers/bikeshare/usPbscCities.test.mjs | 31 ++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 src/layers/bikeshare/usPbscCities.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..5fd137e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo + (HIBike) to the GBFS bikeshare registry, the layer's first stations in + Michigan and Pennsylvania. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..38d417b04 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -48,6 +48,56 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json', provider: 'Divvy', }, + { + id: 'detroit-mogo', + city: 'Detroit, MI', + centerLat: 42.3314, + centerLon: -83.0458, + loadRadiusKm: 90, + stationInformationUrl: + 'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'MoGo', + }, + { + id: 'pittsburgh-pogoh', + city: 'Pittsburgh, PA', + centerLat: 40.4406, + centerLon: -79.9959, + loadRadiusKm: 90, + stationInformationUrl: + 'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'POGOH', + }, + { + id: 'aspen-we-cycle', + city: 'Aspen, CO', + centerLat: 39.1911, + centerLon: -106.8175, + loadRadiusKm: 90, + stationInformationUrl: + 'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'WE-cycle', + }, + // HIBike serves both Kailua-Kona and Hilo, about 95 km apart; the centre sits + // between them so a camera over either town loads the whole system. + { + id: 'hawaii-island-hibike', + city: 'Kona & Hilo, HI', + centerLat: 19.68, + centerLon: -155.54, + loadRadiusKm: 90, + stationInformationUrl: + 'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'HIBike', + }, { id: 'dc-capital-bikeshare', city: 'Washington, DC', diff --git a/src/layers/bikeshare/usPbscCities.test.mjs b/src/layers/bikeshare/usPbscCities.test.mjs new file mode 100644 index 000000000..4ff15c370 --- /dev/null +++ b/src/layers/bikeshare/usPbscCities.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CITY_BY_ID } from './registry.js'; +import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; +import { CITY_RANGE_BASE_KM } from './policy.js'; + +// Each city's PBSC host, and its farthest station from the registered centre +// as measured against the live feed (km), so a camera over any part of the +// system activates it. +const CITIES = { + 'detroit-mogo': { host: 'detroit.publicbikesystem.net', provider: 'MoGo', farthestKm: 22.4 }, + 'pittsburgh-pogoh': { host: 'pittsburgh.publicbikesystem.net', provider: 'POGOH', farthestKm: 9.1 }, + 'aspen-we-cycle': { host: 'aspen.publicbikesystem.net', provider: 'WE-cycle', farthestKm: 42.6 }, + 'hawaii-island-hibike': { host: 'kona.publicbikesystem.net', provider: 'HIBike', farthestKm: 53.8 }, +}; + +test('the added US PBSC systems are registered and readable through the proxy', () => { + for (const [id, expected] of Object.entries(CITIES)) { + const city = CITY_BY_ID.get(id); + assert.ok(city, `${id} is registered`); + assert.equal(city.provider, expected.provider); + assert.deepEqual(city.hosts, [expected.host]); + for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) { + const url = new URL(feed); + assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`); + assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`); + } + const radius = Math.max(CITY_RANGE_BASE_KM, city.loadRadiusKm); + assert.ok(radius > expected.farthestKm, `${id} radius ${radius} covers its farthest station`); + } +}); From afa9e763f211d224c014f506899707cc254f2da6 Mon Sep 17 00:00:00 2001 From: Jeff Blakely <7987380+jeffblakely@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:06:41 -0400 Subject: [PATCH 05/17] test(bikeshare): check PBSC activation from real station coordinates Assert through computeInRangeCities that a camera over each system's outermost live-feed station activates it, including both Kona and Hilo for HIBike, and that HIBike stays off over Honolulu. Drop Pennsylvania from the changelog: Philadelphia's Indego is already registered. --- CHANGELOG.md | 2 +- src/layers/bikeshare/usPbscCities.test.mjs | 66 ++++++++++++++++++---- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd137e89..eedaf06d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo (HIBike) to the GBFS bikeshare registry, the layer's first stations in - Michigan and Pennsylvania. + Michigan. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/src/layers/bikeshare/usPbscCities.test.mjs b/src/layers/bikeshare/usPbscCities.test.mjs index 4ff15c370..aba2d20ec 100644 --- a/src/layers/bikeshare/usPbscCities.test.mjs +++ b/src/layers/bikeshare/usPbscCities.test.mjs @@ -1,19 +1,48 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CITY_BY_ID } from './registry.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; -import { CITY_RANGE_BASE_KM } from './policy.js'; -// Each city's PBSC host, and its farthest station from the registered centre -// as measured against the live feed (km), so a camera over any part of the -// system activates it. +// Each city's PBSC host, and the stations farthest from its registered centre +// in the live feed (checked 2026-10-13). HIBike's centre sits between its two +// towns, so it lists the outermost station in each. const CITIES = { - 'detroit-mogo': { host: 'detroit.publicbikesystem.net', provider: 'MoGo', farthestKm: 22.4 }, - 'pittsburgh-pogoh': { host: 'pittsburgh.publicbikesystem.net', provider: 'POGOH', farthestKm: 9.1 }, - 'aspen-we-cycle': { host: 'aspen.publicbikesystem.net', provider: 'WE-cycle', farthestKm: 42.6 }, - 'hawaii-island-hibike': { host: 'kona.publicbikesystem.net', provider: 'HIBike', farthestKm: 53.8 }, + 'detroit-mogo': { + host: 'detroit.publicbikesystem.net', + provider: 'MoGo', + stations: [{ name: 'Robina Ave & W Twelve Mile Rd', lat: 42.50288, lon: -83.18921 }], + }, + 'pittsburgh-pogoh': { + host: 'pittsburgh.publicbikesystem.net', + provider: 'POGOH', + stations: [{ name: 'Wilkinsburg Park & Ride', lat: 40.446951, lon: -79.888593 }], + }, + 'aspen-we-cycle': { + host: 'aspen.publicbikesystem.net', + provider: 'WE-cycle', + stations: [{ name: 'Cowen Dr | Hwy 133', lat: 39.413115, lon: -107.220634 }], + }, + 'hawaii-island-hibike': { + host: 'kona.publicbikesystem.net', + provider: 'HIBike', + stations: [ + { name: 'Kona Community Center', lat: 19.642932, lon: -156.004962 }, + { name: 'Kealoha Beach Park, Hilo', lat: 19.733317, lon: -155.029202 }, + ], + }, }; +// The production range check, with no viewer or fetches behind it. +const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) }; +const { computeInRangeCities } = createViewport({ + state: {}, + services: { render: { governorRequestRender() {} } }, + parts, + source: null, +}); + test('the added US PBSC systems are registered and readable through the proxy', () => { for (const [id, expected] of Object.entries(CITIES)) { const city = CITY_BY_ID.get(id); @@ -25,7 +54,24 @@ test('the added US PBSC systems are registered and readable through the proxy', assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`); assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`); } - const radius = Math.max(CITY_RANGE_BASE_KM, city.loadRadiusKm); - assert.ok(radius > expected.farthestKm, `${id} radius ${radius} covers its farthest station`); } }); + +test('a camera over each outermost station activates its system', () => { + for (const [id, expected] of Object.entries(CITIES)) { + for (const station of expected.stations) { + assert.ok( + computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id), + `${station.name} activates ${id}`, + ); + } + } +}); + +test('HIBike stays inactive away from Hawaii Island', () => { + // Honolulu is about 270 km from the HIBike centre. + assert.equal( + computeInRangeCities({ lat: 21.3069, lon: -157.8583 }).has('hawaii-island-hibike'), + false, + ); +}); From 00d2b73b6af3cfd6cd56323226d86b33719e1f57 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 13:10:40 +0100 Subject: [PATCH 06/17] feat(street-level): Panoramax as a fourth, keyless imagery provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panoramax, the open federated street-level commons, registers beside Mapillary, Google Street View and KartaView. Its meta-catalogue (api.panoramax.xyz) is keyless and CORS-open, so it runs in the browser: - coverage from its vector tiles: sequence lines at z11–14 below 30 km (longest first under the per-tile cap), and above that the grid layer's density cells as binned points, not counted as sequences; - a click on a line opens that sequence's picture nearest the click (STAC search, any direction), without moving the camera, then any sequence's if it has none within 50 m; the 360° and since filters go to the lines and to the API; - cones from the z15 tiles' pictures layer around the open picture (~110 bytes a picture, against ~7.5 kB for a STAC item), re-centred when a picture opens outside them; ‹ › follow the picture's prev/next links; - the caption names the author (not an account named by an email address) and the picture's licence (StreetPose `license`), and links to the picture in the Panoramax viewer; the globe credits Panoramax. KartaView's viewer becomes the shared photoViewer.js (flat , 360° on panorama.js, ‹ ›, keyboard, pinch, context loss), driven by a per-provider PhotoSources table; the request limiter moves to limiter.js. Panoramax shows the 2,048 px SD derivative and fetches the HD original only when the viewer is wide enough, once, shrunk while it decodes (never above 8,192 px). Orange #ff6f00 (Panoramax's own), off by default, share option 0.m.0_0.r.panoramax-1. The MCP panel allows the meta-catalogue and the biggest instances. Tests: policy, decode, source, viewer, provider, and a Panoramax fixture in the hermetic browser gate. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 + DATA_SOURCES.md | 1 + README.md | 10 +- docs/CURRENT-STATE.md | 43 +- scripts/fixtures/street-level/panoramax.mjs | 256 +++++++ scripts/format-scope.json | 18 +- scripts/package-boundaries.json | 27 +- scripts/qa-street-level.mjs | 357 ++++++++- src/app/layers/streetLevel.js | 6 +- src/app/layers/streetLevel.test.mjs | 19 +- src/layers/streetLevel/limiter.js | 49 ++ .../{providers/kartaview => }/panorama.js | 0 .../kartaview => }/panorama.test.mjs | 0 src/layers/streetLevel/photoViewer.js | 705 ++++++++++++++++++ src/layers/streetLevel/policy.js | 1 + .../streetLevel/providers/kartaview/policy.js | 3 - .../streetLevel/providers/kartaview/source.js | 53 +- .../streetLevel/providers/kartaview/viewer.js | 659 ++-------------- .../providers/mapillary/coverageFixture.mjs | 6 +- .../streetLevel/providers/panoramax/decode.js | 178 +++++ .../providers/panoramax/decode.test.mjs | 173 +++++ .../streetLevel/providers/panoramax/index.js | 461 ++++++++++++ .../streetLevel/providers/panoramax/policy.js | 220 ++++++ .../providers/panoramax/policy.test.mjs | 195 +++++ .../providers/panoramax/provider.test.mjs | 526 +++++++++++++ .../streetLevel/providers/panoramax/source.js | 184 +++++ .../providers/panoramax/source.test.mjs | 176 +++++ .../streetLevel/providers/panoramax/viewer.js | 63 ++ .../providers/panoramax/viewer.test.mjs | 251 +++++++ src/layers/streetLevel/registry.js | 1 + src/layers/streetLevel/state.js | 1 + src/layers/streetLevel/viewerHost.js | 1 + src/testSupport/panoramaxFixtures.mjs | 129 ++++ src/tooling/streetLevelQa.test.mjs | 94 +++ src/tools/globePanel.js | 14 +- src/ui/streetLevelPresentation.js | 1 + src/ui/styles/street-level.css | 52 +- 37 files changed, 4235 insertions(+), 711 deletions(-) create mode 100644 scripts/fixtures/street-level/panoramax.mjs create mode 100644 src/layers/streetLevel/limiter.js rename src/layers/streetLevel/{providers/kartaview => }/panorama.js (100%) rename src/layers/streetLevel/{providers/kartaview => }/panorama.test.mjs (100%) create mode 100644 src/layers/streetLevel/photoViewer.js create mode 100644 src/layers/streetLevel/providers/panoramax/decode.js create mode 100644 src/layers/streetLevel/providers/panoramax/decode.test.mjs create mode 100644 src/layers/streetLevel/providers/panoramax/index.js create mode 100644 src/layers/streetLevel/providers/panoramax/policy.js create mode 100644 src/layers/streetLevel/providers/panoramax/policy.test.mjs create mode 100644 src/layers/streetLevel/providers/panoramax/provider.test.mjs create mode 100644 src/layers/streetLevel/providers/panoramax/source.js create mode 100644 src/layers/streetLevel/providers/panoramax/source.test.mjs create mode 100644 src/layers/streetLevel/providers/panoramax/viewer.js create mode 100644 src/layers/streetLevel/providers/panoramax/viewer.test.mjs create mode 100644 src/testSupport/panoramaxFixtures.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d0df3bc64..8c347fa44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,19 @@ ## [Unreleased] +- Street Level: Panoramax is the fourth provider, keyless. Light its chip + (orange; off by default, one provider at a time): from orbit dots show + where the federated commons has pictures, and below about 30 km its + sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a + line opens that sequence's picture nearest the click without moving the + camera, flat or 360°, with cones along the sequence around it and ‹ › / + ← → following the picture's own links. The 360° and since filters apply, + in the lines and in the lookup. The caption names each picture's author + and licence (CC BY-SA 4.0, Licence Ouverte 2.0…) and links to it in the + Panoramax viewer; HD originals load only for an expanded viewer, once. + Share links carry it as `0.m.0_0.r.panoramax-1`. KartaView's photo viewer + is now shared (`photoViewer.js`, `panorama.js`). + - 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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 23d9cfc82..944e4e3fc 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -23,6 +23,7 @@ How to read this: | **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), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index b58a2eaec..8fef6c826 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 📹 **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 | 🟢 KartaView · 🟡 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, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **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), **Google Street View** (your Google Maps key) and **KartaView** (no 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), **KartaView** and **Panoramax** (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. **KartaView** needs nothing: its public API is keyless. +**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; 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. +**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. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 30 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's author and licence (mostly CC BY-SA 4.0; IGN's are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -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`. +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, `0.m.0_0.r.panoramax-1` Panoramax 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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f595ce519..7b28ca40b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3084,7 +3084,7 @@ 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, Google -Street View and KartaView are registered. Without +Street View, KartaView and Panoramax 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 @@ -3244,14 +3244,49 @@ 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. +pages fail for signed-out visitors. The viewer itself is `photoViewer.js`, +shared with Panoramax: each provider hands it a `PhotoSources` table (first +and sharper flat and panorama URLs, and the caption fields). + +Panoramax (`providers/panoramax/`) is keyless and browser-side: the +meta-catalogue `api.panoramax.xyz` (CORS open) federates every instance, and +each picture is served by the instance holding it (OpenStreetMap France, +IGN, MapComplete…; IGN's redirect to its OVH object storage). Its chip +(orange, `#ff6f00`, Panoramax's own) starts off. Coverage comes from its +vector tiles (`/api/map/{z}/{x}/{y}.mvt`, an hour's HTTP cache): sequence +lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km and z11 +to 30 km (a dense z11 tile over Paris is ~1.4 MB and 3,800 sequences; each +tile draws its 1,600 longest), and above 30 km the `grid` layer's 0.1° +density cells, binned to at most 96×96 points a tile (a z0 tile holds +~70,000 cells) and not counted as sequences: the panel says to zoom in. Both +filters apply: lines carry their day and camera type, and a cell stays while +it holds pictures of the kind asked for (cells are undated). A click on a +line asks the core (`actions.openNearest`) for that sequence's picture +nearest the click (`/api/search?place_position…&collections=`, any +direction: `place_fov_tolerance=180`), without framing, then any sequence's +if it has none within 50 m; the filters go to the API (`filter= +field_of_view=360`, `datetime=/..`). Once the picture is on screen +its sequence is highlighted and drawn as cones from the z15 tiles around it +(their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC +item), plus a neighbour within 150 m of an edge; the cones re-centre when a +picture opens outside them. ‹ › follow the picture's own `prev`/`next` +links, so stepping costs one small search per picture. The viewer shows the +2,048 px SD derivative (equirectangular for a 360° picture) and swaps in the +HD original only when the viewer is wider than SD (flat) or needs a 4,096 px +panorama (expanded), once, shrunk while it decodes; originals wider than +8,192 px are never fetched. The caption names the author (not an account +named by an email address) and the picture's licence (CC BY-SA 4.0, Licence +Ouverte 2.0…; StreetPose `license`), and links to the picture in the +Panoramax viewer. The MCP globe panel allows the meta-catalogue and the +three biggest instances; a picture on another instance shows only in the +app. Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run qa:street-level:fixtures -- --url ` answers every Mapillary request from fixtures (`providers/mapillary/coverageFixture.mjs`, -`scripts/fixtures/street-level/`), so it needs no network and covers the whole -photo flow. CI runs the fixture gate and `qa:panel-resize` +`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it +needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize` against a production build served by `vite preview` with a dummy token. Directions is a keyless front end to the routing the voice agent already diff --git a/scripts/fixtures/street-level/panoramax.mjs b/scripts/fixtures/street-level/panoramax.mjs new file mode 100644 index 000000000..768a7ff62 --- /dev/null +++ b/scripts/fixtures/street-level/panoramax.mjs @@ -0,0 +1,256 @@ +import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js'; +import { encodePanoramaxTile } from '../../../src/testSupport/panoramaxFixtures.mjs'; +import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; + +/** + * Panoramax's meta-catalogue and one instance's pictures answered from + * fixtures, so the gate's Panoramax steps need no network. Two sequences + * drive east along the same Paris street (PNX_LINE), their pictures + * interleaved, so a click on one line must open that line's picture, not + * the other's that is as near. Every seventh picture of the first is 360°. + * The meta-catalogue's pictures live on panoramax.openstreetmap.fr; the + * first sequence is CC BY-SA 4.0 by "Fixture Mapper", the second Licence + * Ouverte by an account named by an email address (never shown). + */ + +export const PNX_SEQUENCES = Object.freeze([ + 'a1a1a1a1-0000-4000-8000-000000000001', + 'b2b2b2b2-0000-4000-8000-000000000002', +]); +export const PNX_LINE = Object.freeze({ + lat: 48.8874, + west: 2.3445, + east: 2.3545, +}); +export const PNX_PARK = Object.freeze({ + lon: 2.3495, + lat: 48.8874, + height: 700, + pitch: -1.4, +}); +export const PNX_CREATOR = 'Fixture Mapper'; +const PNX_SPACING_M = 10; +const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180); +const API = 'https://api.panoramax.xyz/api'; +const HOST = 'https://panoramax.openstreetmap.fr'; + +const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); + +/** Calls answered since the last reset, by kind. */ +let asked = { search: 0, hd: 0 }; + +export function resetPanoramaxFixture() { + asked = { search: 0, hd: 0 }; +} + +/** How many HD originals were downloaded since the last reset. */ +export function panoramaxHdDownloads() { + return asked.hd; +} + +/** + * Both sequences' pictures, west to east. The second's sit 5 m along from + * the first's, on the same street. + */ +export function panoramaxPictures() { + const step = PNX_SPACING_M / METRES_PER_DEG_LON; + const count = Math.floor((PNX_LINE.east - PNX_LINE.west) / step); + return PNX_SEQUENCES.flatMap((sequenceId, s) => + Array.from({ length: count }, (_, i) => ({ + id: `${sequenceId.slice(0, 8)}-${String(i).padStart(4, '0')}`, + sequenceId, + index: i, + count, + lon: Number((PNX_LINE.west + (i + s / 2) * step).toFixed(7)), + lat: PNX_LINE.lat, + isPano: s === 0 && i % 7 === 3, + datetime: + s === 0 ? '2025-06-01T10:00:00+00:00' : '2023-03-01T09:00:00+00:00', + })), + ); +} + +function item(picture, pictures) { + const sibling = (delta) => + pictures.find( + (p) => + p.sequenceId === picture.sequenceId && + p.index === picture.index + delta, + ); + const link = (rel, other) => + other + ? [ + { + rel, + href: `${API}/collections/${other.sequenceId}/items/${other.id}`, + }, + ] + : []; + const first = picture.sequenceId === PNX_SEQUENCES[0]; + const files = `${HOST}/derivates/${picture.id}`; + return { + type: 'Feature', + id: picture.id, + collection: picture.sequenceId, + geometry: { type: 'Point', coordinates: [picture.lon, picture.lat] }, + providers: [ + { + name: first ? PNX_CREATOR : 'someone@fixture.example.fr', + roles: ['producer'], + }, + ], + properties: { + datetime: picture.datetime, + license: first ? 'CC-BY-SA-4.0' : 'etalab-2.0', + 'view:azimuth': 90, + 'pers:interior_orientation': picture.isPano + ? { field_of_view: 360, sensor_array_dimensions: [5760, 2880] } + : { field_of_view: 70, sensor_array_dimensions: [4032, 3024] }, + }, + assets: { + hd: { href: `${files}/hd.jpg` }, + sd: { href: `${files}/sd.jpg` }, + thumb: { href: `${files}/thumb.jpg` }, + }, + links: [...link('prev', sibling(-1)), ...link('next', sibling(1))], + }; +} + +/** The tile `z/x/y` in the meta-catalogue's shape. */ +export function panoramaxTile(z, x, y) { + const tile = { x, y, z }; + const { west, east, south, north } = tileBounds(x, y, z); + if (PNX_LINE.lat < south || PNX_LINE.lat > north) return new Uint8Array(0); + const from = Math.max(PNX_LINE.west, west); + const to = Math.min(PNX_LINE.east, east); + if (from >= to) return new Uint8Array(0); + if (z <= 5) + return encodePanoramaxTile(tile, { + grid: [{ lon: (from + to) / 2, lat: PNX_LINE.lat, pano: 30, flat: 170 }], + }); + if (z < 7) return new Uint8Array(0); + const sequences = PNX_SEQUENCES.map((id, s) => ({ + id, + type: s === 0 ? 'equirectangular' : 'flat', + date: s === 0 ? '2025-06-01' : '2023-03-01', + parts: [ + [ + [from, PNX_LINE.lat], + [to, PNX_LINE.lat], + ], + ], + })); + if (z < 15) return encodePanoramaxTile(tile, { sequences }); + const pictures = panoramaxPictures() + .filter((p) => p.lon >= west && p.lon < east) + .map((p) => ({ + id: p.id, + lon: p.lon, + lat: p.lat, + sequenceId: p.sequenceId, + heading: 90, + ts: p.datetime.replace('T', ' ').replace('+00:00', '+00'), + type: p.isPano ? 'equirectangular' : 'flat', + })); + return encodePanoramaxTile(tile, { sequences, pictures }); +} + +/** A call named by host, path shape and parameter names. */ +export function describePanoramaxCall(method, url) { + const keys = [...url.searchParams.keys()].sort(); + const path = url.pathname + .replace(/\/map\/\d+\/\d+\/\d+\.mvt$/, '/map/{z}/{x}/{y}.mvt') + .replace( + /^\/derivates\/[\w-]+\/(sd|hd|thumb)\.jpg$/, + '/derivates/{id}/$1.jpg', + ); + return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`; +} + +const json = (body) => ({ + status: 200, + contentType: 'application/geo+json', + headers: CORS, + body: JSON.stringify(body), + known: true, +}); + +/** + * Answer one Panoramax request; `known` is false for a call no fixture + * covers, so the gate fails on a new one instead of missing it. + */ +export function answerPanoramaxRequest({ method, url }) { + const address = new URL(url); + if (method === 'OPTIONS') + return { status: 204, headers: CORS, body: '', known: true }; + const pictures = panoramaxPictures(); + if (address.hostname === new URL(HOST).hostname) { + const file = address.pathname.match( + /^\/derivates\/([\w-]+)\/(sd|hd|thumb)\.jpg$/, + ); + if (!file || !pictures.some((p) => p.id === file[1])) + return { status: 404, headers: CORS, body: '', known: false }; + if (file[2] === 'hd') asked.hd++; + return { + status: 200, + contentType: 'image/jpeg', + headers: CORS, + body: photoJpeg(), + known: true, + }; + } + if (address.hostname !== 'api.panoramax.xyz') + return { status: 404, headers: CORS, body: '', known: false }; + const tile = address.pathname.match(/^\/api\/map\/(\d+)\/(\d+)\/(\d+)\.mvt$/); + if (tile) { + const bytes = panoramaxTile( + Number(tile[1]), + Number(tile[2]), + Number(tile[3]), + ); + return bytes.length + ? { + status: 200, + contentType: 'application/vnd.mapbox-vector-tile', + headers: CORS, + body: Buffer.from(bytes), + known: true, + } + : { status: 204, headers: CORS, body: '', known: true }; + } + if (address.pathname !== '/api/search') + return { status: 404, headers: CORS, body: '', known: false }; + asked.search++; + const q = address.searchParams; + if (q.has('ids')) { + const wanted = q.get('ids').split(','); + return json({ + type: 'FeatureCollection', + features: pictures + .filter((p) => wanted.includes(p.id)) + .map((p) => item(p, pictures)), + }); + } + const [lon, lat] = (q.get('place_position') || '').split(',').map(Number); + const radius = Number((q.get('place_distance') || '0-50').split('-')[1]); + const filter = q.get('filter') || ''; + const since = q.get('datetime')?.split('/')[0]; + const near = pictures + .filter( + (p) => !q.get('collections') || p.sequenceId === q.get('collections'), + ) + .filter((p) => + filter === 'field_of_view=360' + ? p.isPano + : filter === 'field_of_view<360' + ? !p.isPano + : true, + ) + .filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since)) + .map((p) => ({ p, metres: metresApart({ lon, lat }, p) })) + .filter(({ metres }) => metres <= radius) + .sort((a, b) => a.metres - b.metres) + .slice(0, Number(q.get('limit')) || 10) + .map(({ p }) => item(p, pictures)); + return json({ type: 'FeatureCollection', features: near }); +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a77fc11f9..73959131f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -912,15 +912,28 @@ "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/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/panorama.test.mjs", "src/layers/streetLevel/providers/kartaview/viewer.test.mjs", "src/layers/streetLevel/providers/kartaview/provider.test.mjs", + "src/layers/streetLevel/providers/panoramax/index.js", + "src/layers/streetLevel/providers/panoramax/policy.js", + "src/layers/streetLevel/providers/panoramax/source.js", + "src/layers/streetLevel/providers/panoramax/decode.js", + "src/layers/streetLevel/providers/panoramax/viewer.js", + "src/layers/streetLevel/photoViewer.js", + "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/providers/panoramax/policy.test.mjs", + "src/layers/streetLevel/providers/panoramax/decode.test.mjs", + "src/layers/streetLevel/providers/panoramax/source.test.mjs", + "src/layers/streetLevel/providers/panoramax/viewer.test.mjs", + "src/layers/streetLevel/providers/panoramax/provider.test.mjs", + "src/testSupport/panoramaxFixtures.mjs", "src/layers/streetLevel/lineCoverage.test.mjs", "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", @@ -935,6 +948,7 @@ "scripts/qa-browserEvidence.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", "scripts/fixtures/street-level/kartaView.mjs", + "scripts/fixtures/street-level/panoramax.mjs", "src/layers/streetLevel/filter.test.mjs", "src/layers/streetLevel/registry.test.mjs", "src/layers/streetLevel/params.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ba1f0c36a..2e83eb2cc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1234,7 +1234,14 @@ "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/providers/panoramax/index.js", + "src/layers/streetLevel/providers/panoramax/policy.js", + "src/layers/streetLevel/providers/panoramax/source.js", + "src/layers/streetLevel/providers/panoramax/decode.js", + "src/layers/streetLevel/providers/panoramax/viewer.js", + "src/layers/streetLevel/photoViewer.js", + "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", @@ -2202,7 +2209,14 @@ "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/providers/panoramax/index.js", + "src/layers/streetLevel/providers/panoramax/policy.js", + "src/layers/streetLevel/providers/panoramax/source.js", + "src/layers/streetLevel/providers/panoramax/decode.js", + "src/layers/streetLevel/providers/panoramax/viewer.js", + "src/layers/streetLevel/photoViewer.js", + "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", @@ -2741,7 +2755,14 @@ "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/providers/panoramax/index.js", + "src/layers/streetLevel/providers/panoramax/policy.js", + "src/layers/streetLevel/providers/panoramax/source.js", + "src/layers/streetLevel/providers/panoramax/decode.js", + "src/layers/streetLevel/providers/panoramax/viewer.js", + "src/layers/streetLevel/photoViewer.js", + "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 153d7514c..7437b2be8 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -3,8 +3,9 @@ * 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 and KartaView - * request from fixtures, so nothing reaches Mapillary or KartaView. The server still needs a token + * `--fixtures` (the CI mode) answers every *.mapillary.com, KartaView and + * Panoramax request from fixtures, so nothing reaches those providers. 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. * @@ -46,6 +47,17 @@ import { kartaViewPhotos, resetKartaViewFixture, } from './fixtures/street-level/kartaView.mjs'; +import { + answerPanoramaxRequest, + describePanoramaxCall, + PNX_CREATOR, + PNX_LINE, + PNX_PARK, + PNX_SEQUENCES, + panoramaxHdDownloads, + panoramaxPictures, + resetPanoramaxFixture, +} from './fixtures/street-level/panoramax.mjs'; const PANEL_ID = 'street-level-panel'; @@ -62,6 +74,7 @@ export const EXPECTED_PROVIDERS = Object.freeze([ 'mapillary', 'google', 'kartaview', + 'panoramax', ]); /** @@ -245,6 +258,8 @@ export async function serveFixtures(page, fixture) { { urlPattern: '*://api.kartaview.org/*' }, { urlPattern: '*://cdn.kartaview.org/*' }, { urlPattern: '*://*.openstreetcam.org/*' }, + { urlPattern: '*://api.panoramax.xyz/*' }, + { urlPattern: '*://panoramax.openstreetmap.fr/*' }, ], }); const fulfil = (requestId, { status, headers = {}, contentType, body }) => @@ -280,6 +295,14 @@ export async function serveFixtures(page, fixture) { answer = answerKartaViewRequest({ method, url: request.url }); fixture.calls.add(call); if (!answer.known) fixture.unknown.push(call); + } else if ( + address.hostname === 'api.panoramax.xyz' || + address.hostname === 'panoramax.openstreetmap.fr' + ) { + const call = describePanoramaxCall(method, address); + answer = answerPanoramaxRequest({ 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 @@ -909,7 +932,7 @@ async function main() { assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo'); assert.ok( await settle(page, () => { - const img = document.querySelector('.sl-kv-photo'); + const img = document.querySelector('.sl-photo-flat'); return img && !img.hidden && img.naturalWidth > 0; }), 'the photo is on screen', @@ -944,7 +967,7 @@ async function main() { ); } const before = street.imageId; - await clickPanelControl(page, '.sl-kv-step-next'); + await clickPanelControl(page, '.sl-photo-step-next'); assert.ok( await uiUntil( (u, was) => @@ -996,10 +1019,10 @@ async function main() { 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), + await page.$eval('.sl-photo-pano', (canvas) => canvas.hidden), false, ); - const box = await page.$eval('.sl-kv-pano', (canvas) => { + const box = await page.$eval('.sl-photo-pano', (canvas) => { const r = canvas.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); @@ -1016,7 +1039,7 @@ async function main() { // 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'); + const canvas = document.querySelector('.sl-photo-pano'); window.__kvLostCanvas = canvas; const lose = canvas .getContext('webgl') @@ -1030,7 +1053,7 @@ async function main() { await settle( page, () => { - const canvas = document.querySelector('.sl-kv-pano'); + const canvas = document.querySelector('.sl-photo-pano'); return ( canvas && canvas !== window.__kvLostCanvas && @@ -1077,6 +1100,323 @@ async function main() { await setStack(original); } + /** + * Panoramax: keyless orange coverage with both filters, a line click + * opening that sequence's picture nearest the click (author, licence, + * cones, ›), a 360° picture whose original is fetched once on EXPAND. + * Live runs park over Paris, where Panoramax has pictures. Runs on a + * draped map; leaves Mapillary lit. + */ + async function panoramaxSteps() { + resetPanoramaxFixture(); + 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 module = (call, ...args) => + page.evaluate( + (name, list) => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module[name](...list), + call, + args, + ); + 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 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 pnxChip = '#sl-provider-chips [data-chip-id="panoramax"]'; + const original = await stack(); + await module('closeViewer'); + await setStack('esri-imagery'); + 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 }, + }); + }, PNX_PARK); + await sleep(1000); + } + try { + await step( + 'Panoramax: its chip draws keyless orange coverage, keeps both filters, credits Panoramax and lights in the share link', + async () => { + await clickPanelControl(page, pnxChip); + assert.ok(await lit('panoramax'), 'Panoramax alone is lit'); + await waitForCoverage(page); + const u = await ui(); + assert.equal(u.keyRequired, false); + assert.deepEqual(u.filterControls, { pano: true, since: true }); + assert.equal( + await page.$eval(pnxChip, (chip) => + chip.style.getPropertyValue('--chip-color'), + ), + '#ff6f00', + ); + assert.match( + await page.$eval('#sl-coverage-meta', (node) => node.textContent), + /sequences in view · click a line for the photo there/, + ); + await page.waitForFunction( + () => + document.body.innerHTML.includes('Panoramax 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('panoramax-1'), + ), + ), + `the share link lights Panoramax: ${await page.evaluate(() => location.hash)}`, + ); + }, + ); + await step( + 'Panoramax: a click on a line opens that sequence’s picture nearest the click, camera still, with author and licence, cones and ›', + async ({ skip }) => { + // Points on the street: the fixture's, or live pictures there. + let points = null; + if (fixtures) + points = Array.from({ length: 9 }, (_, i) => [ + PNX_LINE.west + + ((i + 2) / 13) * (PNX_LINE.east - PNX_LINE.west), + PNX_LINE.lat, + ]); + else { + const near = await fetch( + `https://api.panoramax.xyz/api/search?place_position=${PNX_PARK.lon},${PNX_PARK.lat}&place_distance=0-150&place_fov_tolerance=180&limit=12`, + ) + .then((r) => r.json()) + .catch(() => null); + points = near?.features?.map((f) => f.geometry.coordinates); + if (!points?.length) + return skip('no Panoramax picture near the park'); + } + const handle = await page + .waitForFunction( + findPickPoint, + { timeout: 60_000, polling: 500 }, + { + prefix: 'pnx:seq:', + id: null, + points, + billboardIds: null, + }, + ) + .catch(() => null); + if (!handle && !fixtures) return skip('no Panoramax line in view'); + assert.ok(handle, 'a Panoramax line is pickable'); + const at = await handle.jsonValue(); + const parked = await camera(); + 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; + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, 'panoramax'); + assert.equal( + street.sequenceId, + at.id, + 'the picture is from the line clicked', + ); + assert.ok(street.license, 'the caption names the licence'); + assert.match( + street.externalUrl, + /^https:\/\/api\.panoramax\.xyz\/#pic=/, + ); + 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 caption = await page.$eval( + '#sl-image-by', + (node) => node.textContent, + ); + if (street.sequenceId === PNX_SEQUENCES[0]) + assert.equal(caption, `Image by ${PNX_CREATOR} · CC BY-SA 4.0`); + else + assert.equal( + caption, + 'Licence Ouverte 2.0', + 'an account named by an email address is not shown', + ); + const pictures = panoramaxPictures().filter( + (p) => p.sequenceId === street.sequenceId, + ); + assert.ok( + await uiUntil( + (u, want) => + u.sequence.selectedId === want && + !u.sequence.loading && + u.sequence.images > 0, + street.sequenceId, + { timeout: 30_000 }, + ), + `cones along the sequence: ${JSON.stringify((await ui()).sequence)}`, + ); + assert.ok((await ui()).sequence.images <= pictures.length); + } + const before = street.imageId; + await clickPanelControl(page, '.sl-photo-step-next'); + assert.ok( + await uiUntil( + (u, was) => + u.street.imageId && + u.street.imageId !== was && + !u.street.loading && + !u.street.error, + before, + { timeout: 30_000 }, + ), + '› opened the next picture', + ); + const after = (await ui()).street; + assert.equal(after.sequenceId, street.sequenceId); + if (fixtures) { + const all = panoramaxPictures(); + const from = all.find((p) => p.id === before); + assert.equal( + after.imageId, + all.find( + (p) => + p.sequenceId === from.sequenceId && + p.index === from.index + 1, + ).id, + '› followed the picture’s next link', + ); + } + }, + ); + if (fixtures) + await step( + 'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once', + async () => { + const pano = panoramaxPictures().find((p) => p.isPano); + await module('openImage', 'panoramax', 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.renderModes, false, 'no frame to fit'); + const box = await page.$eval('.sl-photo-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', + ); + assert.equal(panoramaxHdDownloads(), 0, 'docked: SD only'); + await clickPanelControl(page, '#sl-viewer-expand'); + assert.ok( + await settle(page, () => + Boolean(document.querySelector('.sl-viewer-wrap-expanded')), + ), + 'the viewer expanded', + ); + const deadline = Date.now() + 30_000; + while (panoramaxHdDownloads() === 0 && Date.now() < deadline) + await sleep(250); + await sleep(2000); + assert.equal( + panoramaxHdDownloads(), + 1, + 'the original, once, for the expanded viewer', + ); + await page.keyboard.press('Escape'); + }, + ); + else + await skipStep( + 'fixtures only', + 'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once', + ); + } finally { + await 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. @@ -2386,6 +2726,7 @@ async function main() { }, ); await kartaViewSteps(); + await panoramaxSteps(); await step( 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', async () => { diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index f176d97de..b441d12d7 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -2,6 +2,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 { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; @@ -12,8 +13,8 @@ import * as render from '../../renderGovernor.js'; const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null; /** - * Mapillary, Google Street View and KartaView, once the Mapillary source is - * supplied; else none. + * Mapillary, Google Street View, KartaView and Panoramax, once the Mapillary + * source is supplied; else none. */ function defaultProviders(sources) { if (sources.mapillary == null) return []; @@ -21,6 +22,7 @@ function defaultProviders(sources) { createMapillaryProvider({ source: sources.mapillary }), createGoogleProvider({ getApiKey: googleApiKey }), createKartaViewProvider(), + createPanoramaxProvider(), ]; } diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index c2c3ebb16..16e2cc4b5 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it ); }); -test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => { +test('the defaults are Mapillary (given its source), Street View, KartaView and Panoramax, which start off and travel as provider switches', async () => { const { createDefaultLayerState } = await import('../../data/layerState.js'); const mapillary = { getStatus() {}, @@ -37,7 +37,12 @@ test('the defaults are Mapillary (given its source), Street View and KartaView, nearestImages() {}, }; const layer = createApplicationStreetLevel({ sources: { mapillary } }); - assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']); + assert.deepEqual(layer.providerIds, [ + 'mapillary', + 'google', + 'kartaview', + 'panoramax', + ]); // 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,12 +50,20 @@ test('the defaults are Mapillary (given its source), Street View and KartaView, // 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*kartaview-0'); + assert.equal( + layer.getParams().providerSwitches, + 'google-1*kartaview-0*panoramax-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); + // As is Panoramax: lighting it puts KartaView out. + layer.setParams({ providerSwitches: 'panoramax-1' }); + assert.equal(layer.getParams().panoramax, true); + assert.equal(layer.getParams().kartaview, false); + assert.equal(layer.getParams().mapillary, false); // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); diff --git a/src/layers/streetLevel/limiter.js b/src/layers/streetLevel/limiter.js new file mode 100644 index 000000000..c986bf356 --- /dev/null +++ b/src/layers/streetLevel/limiter.js @@ -0,0 +1,49 @@ +/** + * 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; + }, + }; +} diff --git a/src/layers/streetLevel/providers/kartaview/panorama.js b/src/layers/streetLevel/panorama.js similarity index 100% rename from src/layers/streetLevel/providers/kartaview/panorama.js rename to src/layers/streetLevel/panorama.js diff --git a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs b/src/layers/streetLevel/panorama.test.mjs similarity index 100% rename from src/layers/streetLevel/providers/kartaview/panorama.test.mjs rename to src/layers/streetLevel/panorama.test.mjs diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js new file mode 100644 index 000000000..49dac9949 --- /dev/null +++ b/src/layers/streetLevel/photoViewer.js @@ -0,0 +1,705 @@ +import { + createPanoramaRenderer, + PANO_FOV_DEFAULT_DEG, + wrapDegrees, +} from './panorama.js'; + +/** A photo download (tens of kilobytes to a few megabytes). */ +export const IMAGE_TIMEOUT_MS = 15_000; + +/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */ +export const PANO_MID_SIZE = Object.freeze([2048, 1024]); +export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); + +/** + * Load an image within the timeout. A CORS-clean one (for WebGL) is decoded + * off the main thread into an ImageBitmap where the browser can: uploading + * a decoded bitmap costs a fraction of uploading an (90 ms → 15 ms for + * 4096×2048 on a phone-class CPU). `resize` shrinks a bitmap while decoding + * it (a panorama larger than any texture the viewer uploads). + * @param {string} url + * @param {{crossOrigin?: boolean, timeoutMs?: number, resize?: [number, number]|null, failure?: string}} [options] + */ +export function loadImage( + url, + { + crossOrigin = true, + timeoutMs = IMAGE_TIMEOUT_MS, + resize = null, + failure = 'The photo could not be loaded', + } = {}, +) { + if ( + crossOrigin && + url && + typeof globalThis.createImageBitmap === 'function' && + typeof globalThis.fetch === 'function' + ) + return loadBitmap(url, timeoutMs, resize, failure); + return new Promise((resolve, reject) => { + if (!url) return reject(new Error(failure)); + const image = new Image(); + if (crossOrigin) image.crossOrigin = 'anonymous'; + image.decoding = 'async'; + const timer = setTimeout(() => { + image.src = ''; + reject(new Error(failure)); + }, timeoutMs); + image.onload = () => { + clearTimeout(timer); + resolve(image); + }; + image.onerror = () => { + clearTimeout(timer); + reject(new Error(failure)); + }; + image.src = url; + }); +} + +async function loadBitmap(url, timeoutMs, resize, failure) { + 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(failure); + const blob = await response.blob(); + return await (resize + ? createImageBitmap(blob, { + resizeWidth: resize[0], + resizeHeight: resize[1], + resizeQuality: 'high', + }) + : createImageBitmap(blob)); + } catch { + throw new Error(failure); + } finally { + clearTimeout(timer); + } +} + +/** 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; +} + +/** + * @typedef {object} PhotoSources Where a provider's photo comes from. + * @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image. + * @property {(photo: object) => {url: string, width: number}|null} panoFirst + * The panorama shown first, and its texture width (0 when it is only a preview). + * @property {(photo: object, size: [number, number]) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp + * A sharper panorama for a canvas that needs `size`, or null. + * @property {(photo: object) => Array} flatFirst Flat versions to try in turn. + * @property {(photo: object, pixels: number) => string|null} flatSharp + * A sharper flat version for a picture this many device pixels wide, or null. + * @property {(photo: object) => {title?: string|null, creator?: string|null, license?: string|null, externalUrl: string, capturedAtPrecision?: 'day'|'month'}} describe + */ + +/** + * Viewer adapter for providers whose photos are plain image files: a flat + * photo in an , a 360° photo on a WebGL panorama (drag to look, wheel + * or pinch to zoom), and ‹ › to step along the sequence (also ← →). Poses + * follow the view direction. + * + * Photos are normalized by the provider: `{id, sequenceId, lon, lat, + * heading, isPano, capturedAt}` plus whatever its `sources` read. + * + * @param {object} options + * @param {string} options.providerId + * @param {string} options.name Provider name for labels and messages, e.g. 'KartaView'. + * @param {PhotoSources} options.sources + * @param {(photoId: string) => Promise} options.getPhoto A normalized photo. + * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. + * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). + * @param {(photo: object) => void} [options.onShown] A photo is on screen. + * @param {object} [options.render] Render governor. + * @param {(url: string, options?: object) => Promise} [options.load] + * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] + * @returns {import('./registry.js').ViewerAdapter} + */ +export function createPhotoViewer({ + providerId, + name, + sources, + getPhoto, + step, + onStep, + onShown = () => {}, + render, + load = loadImage, + createRenderer = createPanoramaRenderer, +}) { + const LOAD_FAILED = `The ${name} photo could not be loaded`; + 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; + /** Width of a sharper panorama on its way (resizes come in bursts). */ + let panoComing = 0; + /** The sharper flat version on screen or on its way (one download each). */ + let flatSharpUrl = null; + /** 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(); + + const loadAs = (url, options) => + load(url, { failure: LOAD_FAILED, ...options }); + + function requestRender() { + render?.governorRequestRender?.(`${providerId}-viewer`); + } + + function emit() { + if (!photo || !showing) return; + const view = showing === 'pano' ? renderer.getView() : null; + const heading = photo.heading; + const described = sources.describe(photo); + const pose = { + providerId, + imageId: photo.id, + position: { lon: photo.lon, lat: photo.lat }, + bearing: + heading === null || heading === undefined + ? null + : view + ? wrapDegrees(heading + view.yaw) + : heading, + tilt: view ? view.pitch : 0, + altitude: null, + isPano: showing === 'pano', + capturedAt: photo.capturedAt, + capturedAtPrecision: described.capturedAtPrecision || 'day', + title: described.title ?? null, + creator: described.creator ?? null, + license: described.license ?? null, + sequenceId: photo.sequenceId, + externalUrl: described.externalUrl, + }; + 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; + onStep(again); + } + } + + /** The panorama canvas, wired for drag, pinch, wheel and context loss. */ + function makeCanvas(doc) { + const element = doc.createElement('canvas'); + element.className = 'sl-photo-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-photo-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-photo-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-photo-viewer'; + root.dataset.provider = providerId; + root.tabIndex = 0; + root.setAttribute('role', 'group'); + root.setAttribute( + 'aria-label', + `${name} 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-photo-flat'; + picture.alt = `${name} 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-photo-step sl-photo-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; + } + + /** Device pixels across the viewer (its picture or canvas). */ + function viewerPixels() { + const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); + const element = showing === 'pano' ? canvas : picture; + return (element?.clientWidth || root?.clientWidth || 0) * ratio; + } + + /** 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; + if (kind === 'pano') { + const size = panoSizeNow(); + if (size[0] <= Math.max(panoWidth, panoComing)) return; + const sharp = sources.panoSharp(target, size); + if (!sharp?.url) return; + panoComing = size[0]; + try { + const image = await loadAs(sharp.url, { + crossOrigin: true, + resize: sharp.resize ?? null, + ...(sharp.timeoutMs ? { timeoutMs: sharp.timeoutMs } : {}), + }); + if (seq !== openSeq || photo !== target || showing !== 'pano') { + image.close?.(); + return; + } + if (!renderer || size[0] <= panoWidth) { + image.close?.(); + return; + } + renderer.setImage(image); + panoWidth = size[0]; + requestRender(); + } catch { + /* the first version stays */ + } finally { + if (panoComing === size[0]) panoComing = 0; + } + return; + } + const url = sources.flatSharp(target, viewerPixels()); + if (!url || url === flatSharpUrl) return; + flatSharpUrl = url; + try { + const image = await loadAs(url, { crossOrigin: false }); + if (seq !== openSeq || photo !== target || showing !== 'flat') return; + picture.src = image.src; + requestRender(); + } catch { + // The first version stays; a later resize may try again. + if (flatSharpUrl === url && photo === target) flatSharpUrl = null; + } + } + + /** 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) { + // If the panorama fails, the caller shows the front view rather than + // waiting on it twice. + const first = sources.panoFirst(target); + if (!first?.url) throw new Error(LOAD_FAILED); + const image = await loadAs(first.url, { crossOrigin: true }); + if (seq !== openSeq) { + image.close?.(); + 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()) 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 = first.width || 0; + panoComing = 0; + return true; + } + + async function showFlat(target, seq) { + const urls = sources.flatFirst(target).filter(Boolean); + let image; + let failure = null; + for (const url of urls) { + try { + image = await loadAs(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; + flatSharpUrl = null; + 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 (sources.hasPano(target) && 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(`${name} 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; + flatSharpUrl = null; + show(null); + if (picture) picture.removeAttribute('src'); + renderer?.clear?.(); + }, + + unmount() { + openSeq++; + openingId = null; + keepViewFor = null; + flatSharpUrl = null; + teardown(); + }, + + resize() { + renderer?.resize(); + // Expanded: a docked viewer's panorama or flat version is now too soft. + if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth) + upgrade(photo, openSeq, 'pano'); + else if (showing === 'flat' && photo) 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(); + }, + + onPose(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return adapter; +} diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 84f9e2fc7..0d6fdadb8 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -9,6 +9,7 @@ export const PROVIDER_COLORS = Object.freeze({ mapillary: '#05cb63', google: '#4285f4', kartaview: '#635bff', + panoramax: '#ff6f00', }); /** Colours every provider shares: the selection highlight and the marker. */ diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 200033449..f4ca5a5d2 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -100,9 +100,6 @@ 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]); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 9e926a9b0..11637514e 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -15,6 +15,9 @@ import { TILE_CONCURRENCY, TILE_TIMEOUT_MS, } from './policy.js'; +import { createLimiter } from '../../limiter.js'; + +export { createLimiter } from '../../limiter.js'; /** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */ export class KartaViewSourceError extends Error { @@ -54,56 +57,6 @@ function retryAfterSeconds(response) { 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 diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index ae04b0f5a..31c052cb9 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -1,632 +1,81 @@ import { - createPanoramaRenderer, - PANO_FOV_DEFAULT_DEG, - wrapDegrees, -} from './panorama.js'; + createPhotoViewer, + loadImage as loadAnyImage, +} from '../../photoViewer.js'; import { FLAT_FULL_SIZE, FLAT_VIEW_SIZE, IMAGE_TIMEOUT_MS, + KARTAVIEW_NAME, KARTAVIEW_PROVIDER_ID, - PANO_FULL_SIZE, - PANO_MID_SIZE, PANO_PREVIEW_SIZE, kartaViewPhotoUrl, resizedImageUrl, } from './policy.js'; +export { panoSizeFor } from '../../photoViewer.js'; + const LOAD_FAILED = 'The KartaView photo could not be loaded'; -/** - * Load an image within the timeout. A CORS-clean one (for WebGL) is decoded - * off the main thread into an ImageBitmap where the browser can: uploading - * a decoded bitmap costs a fraction of uploading an (90 ms → 15 ms for - * 4096×2048 on a phone-class CPU). - */ -export function loadImage( - url, - { crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {}, -) { - if ( - crossOrigin && - url && - typeof globalThis.createImageBitmap === 'function' && - typeof globalThis.fetch === 'function' - ) - return loadBitmap(url, timeoutMs); - return new Promise((resolve, reject) => { - if (!url) return reject(new Error(LOAD_FAILED)); - const image = new Image(); - if (crossOrigin) image.crossOrigin = 'anonymous'; - image.decoding = 'async'; - const timer = setTimeout(() => { - image.src = ''; - reject(new Error(LOAD_FAILED)); - }, timeoutMs); - image.onload = () => { - clearTimeout(timer); - resolve(image); - }; - image.onerror = () => { - clearTimeout(timer); - reject(new Error(LOAD_FAILED)); - }; - image.src = url; +/** Load a KartaView photo (see photoViewer.js `loadImage`). */ +export function loadImage(url, options = {}) { + return loadAnyImage(url, { + timeoutMs: IMAGE_TIMEOUT_MS, + failure: LOAD_FAILED, + ...options, }); } -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); - } -} - -/** 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; -} +/** + * Where KartaView photos come from. Only KartaView's image proxy can shrink + * a 13,000 px panorama, so panoramas and flat upgrades go through it. + * @type {import('../../photoViewer.js').PhotoSources} + */ +const KARTAVIEW_SOURCES = Object.freeze({ + hasPano: (photo) => Boolean(photo.isPano && photo.urls.pano), + panoFirst: (photo) => ({ + url: resizedImageUrl(photo.urls.pano, PANO_PREVIEW_SIZE), + width: 0, + }), + panoSharp: (photo, size) => ({ + url: resizedImageUrl(photo.urls.pano, size), + }), + // 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. + flatFirst: (photo) => [ + photo.isPano ? null : resizedImageUrl(photo.urls.full, FLAT_VIEW_SIZE), + photo.urls.view, + ], + flatSharp: (photo) => resizedImageUrl(photo.urls.full, FLAT_FULL_SIZE), + describe: (photo) => ({ + // KartaView's only place name is the sequence's, where the drive began. + title: null, + creator: null, + externalUrl: kartaViewPhotoUrl(photo), + }), +}); /** * Viewer adapter for KartaView photos: a flat photo in an , a 360° - * photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step - * along the sequence (also ← →). Poses follow the view direction. + * photo on a WebGL panorama, and ‹ › along the sequence (photoViewer.js). * * @param {object} options * @param {(photoId: string) => Promise} options.getPhoto A normalized photo (policy.js normalizePhoto). - * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. - * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). - * @param {(photo: object) => void} [options.onShown] A photo is on screen. - * @param {object} [options.render] Render governor. + * @param {(photo: object, delta: -1|1) => Promise} options.step + * @param {(imageId: string) => void} options.onStep + * @param {(photo: object) => void} [options.onShown] + * @param {object} [options.render] * @param {(url: string, options?: object) => Promise} [options.load] * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] * @returns {import('../../registry.js').ViewerAdapter} */ -export function createKartaViewViewer({ - getPhoto, - step, - onStep, - onShown = () => {}, - 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 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; - const view = showing === 'pano' ? renderer.getView() : null; - const heading = photo.heading; - 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; - onStep(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; - const url = - kind === 'pano' - ? resizedImageUrl(target.urls.pano, size) - : resizedImageUrl(target.urls.full, size); - if (!url) return; - try { - const 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); - panoWidth = size[0]; - } else picture.src = image.src; - requestRender(); - } catch { - /* the first version stays */ - } - } - - /** 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) 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()) 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; - 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 2,048 px panorama is now too soft. - if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth) - upgrade(photo, openSeq, 'pano'); - }, - - /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */ - supportsRenderModes: () => showing !== 'pano', - - setRenderMode(mode) { - renderMode = mode === 'fill' ? 'fill' : 'letterbox'; - applyRenderMode(); - }, - - onPose(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; - return adapter; +export function createKartaViewViewer({ load = loadImage, ...options }) { + return createPhotoViewer({ + providerId: KARTAVIEW_PROVIDER_ID, + name: KARTAVIEW_NAME, + sources: KARTAVIEW_SOURCES, + load, + ...options, + }); } diff --git a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs index 8fb94821b..9f9f0fa2f 100644 --- a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs @@ -1,6 +1,6 @@ import { PbfWriter } from 'pbf'; -const zigzag = (value) => (value << 1) ^ (value >> 31); +export const zigzag = (value) => (value << 1) ^ (value >> 31); /** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */ export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) { @@ -13,7 +13,7 @@ export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) { } /** One MVT layer writer with deduplicated keys and appended values. */ -function layerWriter(name, extent) { +export function layerWriter(name, extent) { const keys = new Map(); const values = []; const features = []; @@ -62,7 +62,7 @@ function layerWriter(name, extent) { } /** MVT MoveTo/LineTo commands; the cursor carries over between parts. */ -function lineGeometry(parts) { +export function lineGeometry(parts) { const out = []; let cx = 0; let cy = 0; diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js new file mode 100644 index 000000000..02ff0579f --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -0,0 +1,178 @@ +import { PbfReader } from 'pbf'; +import { VectorTile } from '@mapbox/vector-tile'; +import { tileLocalToLonLat } from '../../tileMath.js'; +import { OVERVIEW_BINS_PER_AXIS, panoramaxTimeMs } from './policy.js'; + +/** Epoch ms of a sequence's day ('2025-08-11'), or 0 when undated. */ +function dayMs(value) { + return panoramaxTimeMs(typeof value === 'string' ? value : '') ?? 0; +} + +/** + * Decode a Panoramax coverage tile (`/api/map/{z}/{x}/{y}.mvt`). + * + * - `sequences` (z7–15): one line per sequence, its parts kept apart so no + * segment is drawn across a gap, with its day and camera type; longest + * first, so a cap on lines drawn drops the shortest. + * - `grid` (z0–6): picture-density cells, as overview points binned to at + * most OVERVIEW_BINS_PER_AXIS² a tile (a z0 tile holds ~70,000 cells). A + * cell has 360° and flat counts but no date. + * - `pictures` (z15) is left for `decodePictures`. + * @param {Uint8Array} bytes + * @param {{x: number, y: number, z: number}} address + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array<{id: string, lon: number, lat: number, capturedAt: null, isPano: boolean, hasPano: boolean, hasFlat: boolean, weight: number}>}} + */ +export function decodeCoverageTile(bytes, address) { + const result = { sequences: [], overview: [] }; + if (!bytes || !bytes.length) return result; + const tile = new VectorTile(new PbfReader(bytes)); + const { x, y, z } = address; + + const sequences = tile.layers.sequences; + if (sequences) { + const lengths = new Map(); + for (let i = 0; i < sequences.length; i++) { + const feature = sequences.feature(i); + const props = feature.properties || {}; + const parts = []; + let length = 0; + for (const line of feature.loadGeometry()) { + if (line.length < 2) continue; + for (let k = 1; k < line.length; k++) + length += Math.hypot( + line[k].x - line[k - 1].x, + line[k].y - line[k - 1].y, + ); + parts.push( + line.map((point) => + tileLocalToLonLat(point.x, point.y, sequences.extent, x, y, z), + ), + ); + } + if (!parts.length || props.id == null) continue; + const sequence = { + id: String(props.id), + parts, + capturedAt: dayMs(props.date), + isPano: props.type === 'equirectangular', + }; + lengths.set(sequence, length); + result.sequences.push(sequence); + } + result.sequences.sort((a, b) => lengths.get(b) - lengths.get(a)); + } + + const grid = tile.layers.grid; + if (grid) { + const bins = new Map(); + const size = grid.extent / OVERVIEW_BINS_PER_AXIS; + for (let i = 0; i < grid.length; i++) { + const feature = grid.feature(i); + const props = feature.properties || {}; + const point = feature.loadGeometry()?.[0]?.[0]; + if (!point) continue; + const pano = Number(props.nb_360_pictures) || 0; + const flat = Number(props.nb_flat_pictures) || 0; + const weight = Number(props.nb_pictures) || pano + flat; + if (weight <= 0) continue; + const key = `${Math.floor(point.x / size)}:${Math.floor(point.y / size)}`; + const bin = bins.get(key); + if (!bin) { + bins.set(key, { point, weight, pano, flat, top: weight }); + continue; + } + // The bin sits on its busiest cell and counts them all. + bin.weight += weight; + bin.pano += pano; + bin.flat += flat; + if (weight > bin.top) { + bin.top = weight; + bin.point = point; + } + } + for (const [key, bin] of bins) { + const [lon, lat] = tileLocalToLonLat( + bin.point.x, + bin.point.y, + grid.extent, + x, + y, + z, + ); + result.overview.push({ + id: `${z}/${x}/${y}/${key}`, + lon, + lat, + capturedAt: null, + isPano: bin.pano > bin.flat, + hasPano: bin.pano > 0, + hasFlat: bin.flat > 0, + weight: bin.weight, + }); + } + } + return result; +} + +/** A picture's sequence ids: the tile stores them as a JSON array in a string. */ +function sequenceIdsOf(props) { + const raw = props.sequences; + if (typeof raw === 'string' && raw.startsWith('[')) { + try { + const ids = JSON.parse(raw); + if (Array.isArray(ids)) return ids.map(String); + } catch { + /* fall through to the first sequence */ + } + } + return props.first_sequence != null ? [String(props.first_sequence)] : []; +} + +/** + * The pictures of a z15 tile that belong to `sequenceId`, as cones draw + * them (unsorted). + * @param {Uint8Array} bytes + * @param {{x: number, y: number, z: number}} address + * @param {string} sequenceId + * @returns {Array<{id: string, lon: number, lat: number, compassAngle: number, capturedAt: number, isPano: boolean}>} + */ +export function decodePictures(bytes, address, sequenceId) { + if (!bytes || !bytes.length) return []; + const tile = new VectorTile(new PbfReader(bytes)); + const layer = tile.layers.pictures; + if (!layer) return []; + const { x, y, z } = address; + const wanted = String(sequenceId); + const out = []; + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + const props = feature.properties || {}; + // A cheap test first: most pictures in a tile are other sequences'. + if ( + typeof props.sequences === 'string' && + !props.sequences.includes(wanted) + ) + continue; + if (!sequenceIdsOf(props).includes(wanted) || props.id == null) continue; + const point = feature.loadGeometry()?.[0]?.[0]; + if (!point) continue; + const [lon, lat] = tileLocalToLonLat( + point.x, + point.y, + layer.extent, + x, + y, + z, + ); + const heading = Number(props.heading); + out.push({ + id: String(props.id), + lon, + lat, + compassAngle: Number.isFinite(heading) ? heading : 0, + capturedAt: panoramaxTimeMs(String(props.ts ?? '')) ?? 0, + isPano: props.type === 'equirectangular', + }); + } + return out; +} diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs new file mode 100644 index 000000000..397380dad --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { decodeCoverageTile, decodePictures } from './decode.js'; +import { OVERVIEW_BINS_PER_AXIS } from './policy.js'; +import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs'; +import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs'; + +const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps; + +test('sequence lines keep their parts apart, with their day and camera type', () => { + const { tile, lon, lat } = tileCentre(2.35, 48.85); + const bytes = encodePanoramaxTile(tile, { + sequences: [ + { + id: 'pano-seq', + type: 'equirectangular', + date: '2024-03-05', + parts: [ + [ + [lon - 0.001, lat], + [lon, lat], + ], + [ + [lon + 0.0005, lat], + [lon + 0.001, lat], + ], + ], + }, + { + id: 'flat-seq', + parts: [ + [ + [lon, lat - 0.001], + [lon, lat], + ], + ], + }, + ], + }); + const { sequences, overview } = decodeCoverageTile(bytes, tile); + assert.equal(overview.length, 0); + assert.equal(sequences.length, 2); + const pano = sequences.find((s) => s.id === 'pano-seq'); + const flat = sequences.find((s) => s.id === 'flat-seq'); + assert.equal(pano.isPano, true); + assert.equal(pano.capturedAt, Date.UTC(2024, 2, 5)); + assert.equal(pano.parts.length, 2, 'the gap is not bridged'); + assert.ok(near(pano.parts[1][1][0], lon + 0.001)); + assert.equal(flat.isPano, false); +}); + +test('density cells bin to a bounded number of points that keep the counts and kinds', () => { + const tile = { x: 0, y: 0, z: 0 }; + // Two cells in one bin, one far away, one empty. + const grid = [ + { lon: 2.35, lat: 48.85, pano: 0, flat: 5 }, + { lon: 2.36, lat: 48.85, pano: 30, flat: 0 }, + { lon: -73.9, lat: 40.7, pano: 1, flat: 0 }, + { lon: 10, lat: 10, pano: 0, flat: 0 }, + ]; + const { overview } = decodeCoverageTile( + encodePanoramaxTile(tile, { grid }), + tile, + ); + assert.equal(overview.length, 2, 'Paris cells share a bin; empty cells go'); + const paris = overview.find((p) => p.lon > 0); + assert.equal(paris.weight, 35); + assert.equal(paris.hasPano, true); + assert.equal(paris.hasFlat, true); + assert.equal(paris.isPano, true, 'mostly 360°'); + assert.equal(paris.capturedAt, null, 'cells are undated'); + assert.ok(near(paris.lon, 2.36, 0.2), 'sits on the busiest cell'); + const nyc = overview.find((p) => p.lon < 0); + assert.deepEqual([nyc.hasPano, nyc.hasFlat, nyc.weight], [true, false, 1]); +}); + +test('lines come longest first, so a cap on lines drawn drops the shortest', () => { + const { tile, lon, lat } = tileCentre(2.35, 48.85); + const span = (id, metres) => ({ + id, + parts: [ + [ + [lon, lat], + [lon + metres / 73_000, lat], + ], + ], + }); + const { sequences } = decodeCoverageTile( + encodePanoramaxTile(tile, { + sequences: [span('short', 20), span('long', 900), span('mid', 300)], + }), + tile, + ); + assert.deepEqual( + sequences.map((s) => s.id), + ['long', 'mid', 'short'], + ); +}); + +test('a dense grid tile never draws more than the bins allow', () => { + const tile = { x: 0, y: 0, z: 0 }; + const grid = []; + for (let i = 0; i < 300; i++) + for (let j = 0; j < 60; j++) + grid.push({ lon: -170 + i * 1.1, lat: -60 + j * 2 }); + const { overview } = decodeCoverageTile( + encodePanoramaxTile(tile, { grid }), + tile, + ); + assert.ok(overview.length <= OVERVIEW_BINS_PER_AXIS ** 2); + assert.ok(overview.length > 1000); + assert.equal( + overview.reduce((sum, p) => sum + p.weight, 0), + grid.length, + 'every picture is still counted', + ); +}); + +test('an empty tile decodes to nothing', () => { + for (const bytes of [new Uint8Array(0), null, undefined]) + assert.deepEqual(decodeCoverageTile(bytes, { x: 0, y: 0, z: 0 }), { + sequences: [], + overview: [], + }); + assert.deepEqual( + decodePictures(new Uint8Array(0), { x: 0, y: 0, z: 15 }, 's'), + [], + ); +}); + +test('a z15 tile’s pictures of one sequence become cones; other sequences’ are skipped', () => { + const { tile, lon, lat } = tileCentre(2.35, 48.85, 15); + const bytes = encodePanoramaxTile(tile, { + pictures: [ + { + id: 'a', + lon, + lat, + sequenceId: 's1', + heading: 45, + type: 'equirectangular', + ts: '2023-12-12 07:56:37+00', + }, + { id: 'b', lon: lon + 0.0001, lat, sequenceId: 's2' }, + { id: 'c', lon: lon + 0.0002, lat, sequenceId: 's1' }, + ], + }); + const images = decodePictures(bytes, tile, 's1'); + assert.deepEqual( + images.map((i) => i.id), + ['a', 'c'], + ); + assert.equal(images[0].compassAngle, 45); + assert.equal(images[0].isPano, true); + assert.equal(images[0].capturedAt, Date.UTC(2023, 11, 12, 7, 56, 37)); + assert.ok(near(images[1].lon, lon + 0.0002)); + assert.deepEqual(decodePictures(bytes, tile, 'nope'), []); + // A tile without pictures (below z15) has none to give. + const lines = encodePanoramaxTile(tile, { + sequences: [ + { + id: 's1', + parts: [ + [ + [lon, lat], + [lon + 0.001, lat], + ], + ], + }, + ], + }); + assert.deepEqual(decodePictures(lines, tile, 's1'), []); +}); diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js new file mode 100644 index 000000000..cda9015c0 --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -0,0 +1,461 @@ +import { createLineCoverage, sequenceIdFromPick } from '../../lineCoverage.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { passesImageryFilter } from '../../filter.js'; +import { + lonToTileX, + latToTileY, + overviewZoomForHeight, + tileBounds, +} from '../../tileMath.js'; +import { metresBetween } from '../../view.js'; +import { decodeCoverageTile, decodePictures } from './decode.js'; +import { createPanoramaxSource } from './source.js'; +import { createPanoramaxViewer } from './viewer.js'; +import { + COLORS, + COVERAGE_LINES_BELOW_M, + COVERAGE_TUNING, + COVERAGE_Z12_BELOW_M, + COVERAGE_Z13_BELOW_M, + COVERAGE_Z14_BELOW_M, + LINE_CLICK_HINT, + NEAREST_LIMIT, + NEAREST_RADIUS_M, + NO_COVERAGE_HINT, + OVERVIEW_HINT, + PANORAMAX_CREDIT_HTML, + PANORAMAX_LABEL, + PANORAMAX_NAME, + PANORAMAX_PROVIDER_ID, + PICK_PREFIX, + PICTURE_TILE_EDGE_M, + PICTURE_TILE_ZOOM, + RATE_LIMITED_MESSAGE, + normalizeItem, +} from './policy.js'; + +export { PANORAMAX_PROVIDER_ID } from './policy.js'; + +/** Pictures kept in memory by id (lookups and ‹ › fill it). */ +const PHOTO_CACHE_SIZE = 2000; + +/** + * Sequence lines at z11–14 below 30 km; above, the density grid at z5 and + * coarser. + */ +export function panoramaxZoomFor(heightM) { + if (!Number.isFinite(heightM)) return null; + if (heightM <= COVERAGE_LINES_BELOW_M) { + const zoom = + heightM <= COVERAGE_Z14_BELOW_M + ? 14 + : heightM <= COVERAGE_Z13_BELOW_M + ? 13 + : heightM <= COVERAGE_Z12_BELOW_M + ? 12 + : 11; + return { zoom, kind: 'sequence' }; + } + return { + zoom: Math.min(5, overviewZoomForHeight(heightM) ?? 5), + kind: 'overview', + }; +} + +/** + * The imagery filter for lines (dated, typed) and grid cells (undated, with + * 360° and flat counts): a cell stays while it holds pictures of the kind + * asked for. + */ +export function passesCoverage(record, filter) { + if ('hasPano' in record) { + if (filter?.pano === 'pano') return record.hasPano; + if (filter?.pano === 'flat') return record.hasFlat; + return true; + } + return passesImageryFilter(record, filter); +} + +/** 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), + }; +} + +/** The z15 tile holding a point. */ +function pictureTileOf({ lon, lat }) { + const z = PICTURE_TILE_ZOOM; + return { x: lonToTileX(lon, z), y: latToTileY(lat, z), z }; +} + +/** + * The z15 tiles whose pictures surround a point: its own, and the + * neighbours it sits within PICTURE_TILE_EDGE_M of (at most four). + */ +export function pictureTilesAround(point) { + const home = pictureTileOf(point); + const box = tileBounds(home.x, home.y, home.z); + const near = (lon, lat) => + metresBetween(point, { lon, lat }) <= PICTURE_TILE_EDGE_M; + const dx = near(box.west, point.lat) ? -1 : near(box.east, point.lat) ? 1 : 0; + const dy = near(point.lon, box.north) + ? -1 + : near(point.lon, box.south) + ? 1 + : 0; + const max = 2 ** home.z; + const tiles = [home]; + const add = (x, y) => { + if (y < 0 || y >= max) return; + tiles.push({ x: (x + max) % max, y, z: home.z }); + }; + if (dx) add(home.x + dx, home.y); + if (dy) add(home.x, home.y + dy); + if (dx && dy) add(home.x + dx, home.y + dy); + return tiles; +} + +const tileKey = ({ x, y, z }) => `${z}/${x}/${y}`; + +/** + * Panoramax Street Level provider: the federated, keyless, open + * street-level commons. Coverage from its vector tiles (density points + * from orbit, sequence lines near the ground); a click on a line opens that + * sequence's picture nearest the click, with cones along the sequence + * around it and ‹ › to step through it. Flat pictures show as photos, 360° + * ones on the shared WebGL panorama. + * @param {{source?: ReturnType, viewerOptions?: object}} [options] + * @returns {import('../../registry.js').StreetLevelProvider} + */ +export function createPanoramaxProvider({ + source = createPanoramaxSource(), + viewerOptions = {}, +} = {}) { + return Object.freeze({ + id: PANORAMAX_PROVIDER_ID, + name: PANORAMAX_NAME, + label: PANORAMAX_LABEL, + requiresKeyId: null, + pickPrefix: PICK_PREFIX.root, + colors: COLORS, + credit: Object.freeze({ html: PANORAMAX_CREDIT_HTML }), + defaultOn: false, + coverageLabel: Object.freeze({ + noun: 'sequences', + 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); + /** Where each sequence's cones centre: the picture last opened in it, or a click. */ + const anchors = new Map(); + /** The z15 tiles the cones were drawn from. */ + let drawn = null; + /** A line click: its sequence's pictures win the next nearest lookup there. */ + let preferred = null; + + function remember(item) { + const photo = normalizeItem(item); + if (photo) photos.set(photo.id, photo); + return photo; + } + + /** Lookups in flight, shared: ‹ › and the viewer ask for the same picture. */ + const inflight = new Map(); + function getPhoto(photoId) { + const id = String(photoId); + const cached = photos.get(id); + if (cached) return Promise.resolve(cached); + let lookup = inflight.get(id); + if (!lookup) { + lookup = source + .getItem(id) + .then((item) => { + const photo = item && remember(item); + if (!photo) + throw new Error('Panoramax has no position for this picture'); + return photo; + }) + .finally(() => inflight.delete(id)); + inflight.set(id, lookup); + } + return lookup; + } + + /** The picture `delta` along its sequence, from its own links (null at an end). */ + async function step(photo, delta) { + if (!photo?.sequenceId) return null; + return (delta < 0 ? photo.prevId : photo.nextId) || null; + } + + const coverage = createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile(await source.getTile(tile, { signal }), tile), + zoomFor: panoramaxZoomFor, + colors: COLORS, + pickPrefix: PICK_PREFIX.sequence, + renderKey: 'panoramax-coverage', + tuning: COVERAGE_TUNING, + messages: { rateLimited: RATE_LIMITED_MESSAGE }, + passes: passesCoverage, + }); + + const cones = createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const anchor = anchors.get(sequenceId); + if (!anchor) return []; + const tiles = pictureTilesAround(anchor); + const lists = await Promise.all( + tiles.map(async (tile) => + decodePictures( + await source.getTile(tile, { signal }), + tile, + sequenceId, + ), + ), + ); + signal?.throwIfAborted(); + drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) }; + const byId = new Map(lists.flat().map((image) => [image.id, image])); + return thinImages( + [...byId.values()].sort((a, b) => a.capturedAt - b.capturedAt), + ); + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:panoramax-cones', + renderKey: 'panoramax-sequence', + onHighlight: (sequenceId, selected) => + coverage.recolorSequence(sequenceId, selected), + }); + + /** + * The cones show the z15 tiles around one picture: once a picture of + * the selected sequence opens outside them (‹ ›, a cone), re-centre. + */ + function recentreCones(photo) { + if (!photo?.sequenceId) return; + if (state.sequence.selectedId !== photo.sequenceId) return; + if (drawn?.sequenceId !== photo.sequenceId) return; + if (drawn.tiles.has(tileKey(pictureTileOf(photo)))) return; + cones.select(photo.sequenceId, { force: true }); + } + + const viewer = createPanoramaxViewer({ + getPhoto: async (photoId) => { + const photo = await getPhoto(photoId); + if (photo.sequenceId) anchors.set(photo.sequenceId, photo); + return photo; + }, + step, + onStep: (photoId) => + context.actions.openImage(photoId, { frame: false }), + onShown: recentreCones, + render: context.services?.render, + ...viewerOptions, + }); + + /** Nearest of `items` within the search radius of a point, or null. */ + function nearestOf(items, point) { + let best = null; + for (const item of items) { + const photo = remember(item); + if (!photo) continue; + const metres = metresBetween(point, photo); + if (metres > NEAREST_RADIUS_M) continue; + if (!best || metres < best.metres) best = { photo, metres }; + } + return best?.photo ?? null; + } + + 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); + drawn = null; + preferred = null; + }, + + destroy(cesiumViewer) { + coverage.detach(); + coverage.clear(); + coverage.resetErrors(); + cones.destroy(cesiumViewer); + }, + + refreshCoverage: () => coverage.refresh(), + + /** The filter changed: redraw. Each draw reads it from the context. */ + setFilter() { + coverage.rebuild(); + cones.rerender(); + }, + + setSurface() { + coverage.setSurface(); + cones.rerender(); + }, + + coverageStats() { + const lines = state.coverage.kind === 'sequence'; + // Density dots are not sequences: they are not counted as such. + const count = lines ? coverage.sequenceCount() : 0; + const loading = state.coverage.pending.size > 0; + const empty = + lines && !loading && count === 0 && !state.coverage.lastError; + const fallback = empty + ? NO_COVERAGE_HINT + : state.coverage.kind === 'overview' + ? OVERVIEW_HINT + : ''; + return { + count, + zoom: state.coverage.zoom, + kind: state.coverage.kind, + loading, + hint: state.coverage.hint || fallback, + error: state.coverage.lastError, + keyRequired: false, + keyRejected: false, + }; + }, + + /** + * A line opens its sequence's picture nearest the click (the cones + * follow once it is on screen); a cone opens its picture. + */ + handlePick(pickId, { groundPoint } = {}) { + if (pickId.startsWith(PICK_PREFIX.image)) { + context.actions.openImage(pickId.slice(PICK_PREFIX.image.length)); + return true; + } + const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence); + if (!sequenceId) return false; + const point = groundPoint?.(); + if (!point) { + // No ground under the click: show the line selected, nothing more. + cones.select(sequenceId); + return true; + } + anchors.set(sequenceId, { ...point, sequenceId }); + preferred = { sequenceId, lon: point.lon, lat: point.lat }; + context.actions.openNearest(point); + return true; + }, + + selectSequence: (sequenceId) => + cones.select(sequenceId, { force: true }), + clearSequence: () => { + cones.clearSelection(); + drawn = null; + }, + sequenceStats: () => ({ + selectedId: state.sequence.selectedId, + images: state.sequence.images.length, + loading: state.sequence.loading, + }), + + /** + * The nearest picture within the search radius that passes the + * imagery filter, or null. After a click on a line, that line's + * sequence is asked first. + */ + async nearestImage({ lat, lon }, { signal } = {}) { + const point = { lat, lon }; + const filter = context.getFilter(); + const wanted = + preferred && metresBetween(preferred, point) < 1 + ? preferred.sequenceId + : null; + preferred = null; + const ask = (collections) => + source.nearestItems( + { + lat, + lon, + radius: NEAREST_RADIUS_M, + limit: NEAREST_LIMIT, + collections, + pano: filter.pano, + sinceMs: filter.sinceMs, + }, + { signal }, + ); + if (wanted) { + const hit = nearestOf(await ask(wanted), point); + if (hit) return hit.id; + } + return nearestOf(await ask(null), point)?.id ?? null; + }, + + viewer, + }; + }, + }); +} diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js new file mode 100644 index 000000000..b163be640 --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -0,0 +1,220 @@ +import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; + +export { NEAREST_RADIUS_M } from '../../policy.js'; + +/** Identity and tuning for the Panoramax street-level provider (keyless). */ +export const PANORAMAX_PROVIDER_ID = 'panoramax'; +export const PANORAMAX_NAME = 'Panoramax'; +export const PANORAMAX_LABEL = 'PANORAMAX'; + +/** + * The Panoramax meta-catalogue: one keyless, CORS-enabled STAC API over + * every federated instance (OpenStreetMap France, IGN, MapComplete…). Its + * pictures are served by the instance that holds them. + */ +export const PANORAMAX_API = 'https://api.panoramax.xyz/api'; +/** The meta-catalogue's web viewer, where the caption link goes. */ +export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; + +/** Stable id prefixes for picked primitives; every one starts with `pnx:`. */ +export const PICK_PREFIX = Object.freeze({ + root: 'pnx:', + sequence: 'pnx:seq:', + image: 'pnx:img:', +}); + +export const COLORS = Object.freeze({ + coverage: PROVIDER_COLORS.panoramax, + selected: SHARED_COLORS.selected, +}); + +/** + * Sequence lines come from z7–15 tiles; z11–14 are drawn (a dense z11 tile + * over Paris is ~1.4 MB and 3,800 sequences, z12 ~800 kB). Higher up, the + * z0–5 `grid` layer's density cells (0.1°, ~10 km) draw as points: below + * 30 km they would be a few dots over a whole city. + */ +export const COVERAGE_Z14_BELOW_M = 1_800; +export const COVERAGE_Z13_BELOW_M = 5_000; +export const COVERAGE_Z12_BELOW_M = 12_000; +export const COVERAGE_LINES_BELOW_M = 30_000; +export const COVERAGE_TUNING = Object.freeze({ + maxTiles: 9, + overviewMaxTiles: 6, + // Each tile draws at most 1,600 lines, its longest first: a dense z11 + // tile over Paris holds 3,800, and a lower cap left the city centre + // looking emptier than its suburbs. (KartaView draws as many.) + maxSequences: 14_400, + lineWidthPx: 2.5, + overviewPointPx: 4, + moveDebounceMs: 320, + viewRangeMinM: 2_500, + viewRangePerHeight: 10, + viewNearM: 1_000, +}); +/** + * Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells + * (1.9 MB), drawn as this many binned points. + */ +export const OVERVIEW_BINS_PER_AXIS = 96; + +export const RATE_LIMITED_MESSAGE = + 'Panoramax is busy — coverage resumes on its own'; +export const NO_COVERAGE_HINT = 'no Panoramax pictures in view'; +/** From high up the dots are where pictures are, not sequences to click. */ +export const OVERVIEW_HINT = 'zoom in to click a Panoramax sequence'; + +/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */ +export const PICTURE_TILE_ZOOM = 15; +/** Within this of a z15 tile's edge, the neighbouring tile's pictures join. */ +export const PICTURE_TILE_EDGE_M = 150; + +export const TILE_TIMEOUT_MS = 20_000; +export const API_TIMEOUT_MS = 12_000; +/** Real tiles are under 6 MB (z6 over France); refuse anything absurd. */ +export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; +/** Requests at once to the API (tiles have their own budget). */ +export const API_CONCURRENCY = 4; +export const TILE_CONCURRENCY = 6; + +/** Pictures asked for around a point; the API returns them nearest first. */ +export const NEAREST_LIMIT = 10; +/** Any picture within the radius, whichever way it looks (2–180°). */ +export const NEAREST_FOV_TOLERANCE_DEG = 180; + +/** A full-resolution panorama is only fetched up to this width (memory). */ +export const PANO_HD_MAX_WIDTH = 8_192; +/** An HD panorama can be several megabytes. */ +export const HD_TIMEOUT_MS = 30_000; +/** Panoramax's SD derivative is 2,048 px wide, its thumbnail 500 px. */ +export const SD_WIDTH = 2_048; + +/** On-globe credit: licences are per picture, named in the caption. */ +export const PANORAMAX_CREDIT_HTML = + 'Street Level: imagery © Panoramax contributors, licence per picture'; + +export const LINE_CLICK_HINT = 'click a line for the photo there'; + +/** Licence ids Panoramax uses, as the caption names them. */ +const LICENSE_LABELS = Object.freeze({ + 'CC-BY-SA-4.0': 'CC BY-SA 4.0', + 'CC-BY-4.0': 'CC BY 4.0', + 'CC0-1.0': 'CC0', + 'etalab-2.0': 'Licence Ouverte 2.0', +}); + +/** The caption's licence name for a STAC licence id, or null. */ +export function licenseLabel(license) { + if (typeof license !== 'string' || !license.trim()) return null; + const id = license.trim(); + if (id === 'proprietary' || id === 'other') return null; + return LICENSE_LABELS[id] || id.slice(0, 40); +} + +/** Deep link to a picture in the Panoramax web viewer. */ +export function panoramaxPictureUrl(pictureId) { + const id = String(pictureId ?? '').trim(); + if (!id) return PANORAMAX_VIEWER; + return `${PANORAMAX_VIEWER}#${new URLSearchParams({ pic: id, focus: 'pic' })}`; +} + +/** Epoch ms for a capture time ('2023-12-12T07:54:47+00:00', '2023-12-12 07:56:37+00'), or null. */ +export function panoramaxTimeMs(value) { + if (typeof value !== 'string' || !value) return null; + // Tile timestamps are Postgres text: a space and an hour-only offset. + const iso = /\d[T ]\d/.test(value) + ? value + .replace(' ', 'T') + .replace(/([+-]\d{2})$/, '$1:00') + .replace(/\.(\d{3})\d+/, '.$1') + : value; + const ms = Date.parse(iso); + return Number.isFinite(ms) && ms > Date.UTC(1990, 0, 1) ? ms : null; +} + +/** An https address, or null (asset links come from federated instances). */ +function httpsUrl(value) { + try { + const url = new URL(String(value)); + return url.protocol === 'https:' ? url.href : null; + } catch { + return null; + } +} + +/** Last path segment of an item's `rel` link: the linked picture's id. */ +function linkedId(item, rel) { + const href = item?.links?.find?.((link) => link?.rel === rel)?.href; + if (typeof href !== 'string') return null; + const id = href.split(/[?#]/)[0].split('/').filter(Boolean).pop(); + return id ? decodeURIComponent(id) : null; +} + +/** + * Who took the picture. Producers are account names, and some accounts + * are named by an email address: those are not repeated in the caption. + */ +function creatorOf(item) { + const props = item.properties || {}; + const names = [ + props['geovisio:producer'], + ...(Array.isArray(item.providers) + ? item.providers + .filter((p) => p?.roles?.includes?.('producer')) + .map((p) => p.name) + : []), + ]; + const name = names.find((value) => typeof value === 'string' && value.trim()); + if (!name || /\S+@\S+\.\S+/.test(name)) return null; + return name.trim().slice(0, 80); +} + +/** A 360° picture: a full-sphere field of view or an equirectangular projection. */ +export function isEquirectangular(item) { + const props = item?.properties || {}; + const orientation = props['pers:interior_orientation']; + if (Number(orientation?.field_of_view) >= 360) return true; + return props['pers:projection'] === 'equirectangular'; +} + +/** + * A STAC item (from search or a collection) in the shape the provider and + * viewer use, or null without an id or a position. + * @returns {{id: string, sequenceId: string|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, creator: string|null, license: string|null, width: number|null, prevId: string|null, nextId: string|null, urls: {thumb: string|null, sd: string|null, hd: string|null}}|null} + */ +export function normalizeItem(item) { + if (!item || item.id == null) return null; + const [lon, lat] = Array.isArray(item.geometry?.coordinates) + ? item.geometry.coordinates.map(Number) + : [NaN, NaN]; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + const props = item.properties || {}; + const assets = item.assets || {}; + const azimuth = + props['view:azimuth'] === '' || props['view:azimuth'] == null + ? NaN + : Number(props['view:azimuth']); + const width = Number( + props['pers:interior_orientation']?.sensor_array_dimensions?.[0], + ); + return { + id: String(item.id), + sequenceId: item.collection != null ? String(item.collection) : null, + lon, + lat, + heading: Number.isFinite(azimuth) ? ((azimuth % 360) + 360) % 360 : null, + isPano: isEquirectangular(item), + capturedAt: panoramaxTimeMs(props.datetime), + creator: creatorOf(item), + license: licenseLabel(props.license), + width: Number.isFinite(width) && width > 0 ? width : null, + prevId: linkedId(item, 'prev'), + nextId: linkedId(item, 'next'), + urls: { + thumb: httpsUrl(assets.thumb?.href), + sd: httpsUrl(assets.sd?.href), + hd: httpsUrl(assets.hd?.href), + }, + }; +} diff --git a/src/layers/streetLevel/providers/panoramax/policy.test.mjs b/src/layers/streetLevel/providers/panoramax/policy.test.mjs new file mode 100644 index 000000000..5d93d7956 --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/policy.test.mjs @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + COLORS, + PICK_PREFIX, + isEquirectangular, + licenseLabel, + normalizeItem, + panoramaxPictureUrl, + panoramaxTimeMs, +} from './policy.js'; +import { PROVIDER_COLORS } from '../../policy.js'; +import { stacItem } from '../../../../testSupport/panoramaxFixtures.mjs'; + +test('Panoramax draws in its own orange, and every pick id starts pnx:', () => { + assert.equal(COLORS.coverage, '#ff6f00'); + assert.equal(PROVIDER_COLORS.panoramax, '#ff6f00'); + for (const prefix of Object.values(PICK_PREFIX)) + assert.ok(prefix.startsWith('pnx:')); + // One colour per source. + assert.equal( + new Set(Object.values(PROVIDER_COLORS)).size, + Object.keys(PROVIDER_COLORS).length, + ); +}); + +test('capture times parse in the API’s ISO form, the tiles’ Postgres form, and as bare days', () => { + assert.equal( + panoramaxTimeMs('2023-12-12T07:54:47+00:00'), + Date.UTC(2023, 11, 12, 7, 54, 47), + ); + assert.equal( + panoramaxTimeMs('2023-12-12 07:56:37+00'), + Date.UTC(2023, 11, 12, 7, 56, 37), + ); + assert.equal( + panoramaxTimeMs('2025-08-13 07:45:51.98+00'), + Date.UTC(2025, 7, 13, 7, 45, 51, 980), + ); + // Microseconds are cut to what Date.parse reads. + assert.equal( + panoramaxTimeMs('2025-08-03T14:38:51.838000+00:00'), + Date.UTC(2025, 7, 3, 14, 38, 51, 838), + ); + // A sequence's day: its trailing "-11" is the day, not a UTC offset. + assert.equal(panoramaxTimeMs('2025-08-11'), Date.UTC(2025, 7, 11)); + assert.equal( + panoramaxTimeMs('2025-08-13 09:00:00+02'), + Date.UTC(2025, 7, 13, 7), + ); + for (const bad of ['', 'junk', '1970-01-01', null, 42]) + assert.equal(panoramaxTimeMs(bad), null, String(bad)); +}); + +test('a STAC item becomes a picture: position, heading, 360°, licence, links and assets', () => { + const photo = normalizeItem( + stacItem({ + id: 'a', + lon: 2.35, + lat: 48.85, + sequenceId: 's', + heading: 370, + pano: true, + license: 'etalab-2.0', + prevId: 'p', + nextId: 'n', + }), + ); + assert.deepEqual( + { + id: photo.id, + sequenceId: photo.sequenceId, + lon: photo.lon, + lat: photo.lat, + heading: photo.heading, + isPano: photo.isPano, + license: photo.license, + width: photo.width, + prevId: photo.prevId, + nextId: photo.nextId, + creator: photo.creator, + }, + { + id: 'a', + sequenceId: 's', + lon: 2.35, + lat: 48.85, + heading: 10, + isPano: true, + license: 'Licence Ouverte 2.0', + width: 5760, + prevId: 'p', + nextId: 'n', + creator: 'tester', + }, + ); + assert.equal( + photo.urls.sd, + 'https://panoramax.example.org/pictures/a/sd.jpg', + ); + assert.equal(photo.capturedAt, Date.UTC(2025, 5, 1, 10)); +}); + +test('a picture without links, heading or a usable position', () => { + const bare = normalizeItem({ + id: 7, + geometry: { coordinates: [1, 2] }, + properties: {}, + }); + assert.equal(bare.id, '7'); + assert.equal(bare.heading, null); + assert.equal(bare.prevId, null); + assert.equal(bare.nextId, null); + assert.equal(bare.sequenceId, null); + assert.equal(bare.isPano, false); + assert.equal(bare.capturedAt, null); + assert.deepEqual(bare.urls, { thumb: null, sd: null, hd: null }); + for (const item of [ + null, + { geometry: { coordinates: [1, 2] } }, + { id: 'x', geometry: { coordinates: ['a', 2] } }, + { id: 'x', geometry: { coordinates: [181, 2] } }, + { id: 'x', geometry: { coordinates: [1, 91] } }, + { id: 'x' }, + ]) + assert.equal(normalizeItem(item), null, JSON.stringify(item)); +}); + +test('asset links that are not https are dropped', () => { + const item = stacItem({ id: 'a', lon: 1, lat: 2 }); + item.assets.sd.href = 'http://insecure.example/sd.jpg'; + item.assets.hd.href = 'javascript:alert(1)'; + const photo = normalizeItem(item); + assert.equal(photo.urls.sd, null); + assert.equal(photo.urls.hd, null); + assert.ok(photo.urls.thumb.startsWith('https://')); +}); + +test('an account named by an email address is not shown as the creator', () => { + const named = (producer) => + normalizeItem(stacItem({ id: 'a', lon: 1, lat: 2, producer })).creator; + assert.equal(named('someone@city.example.fr'), null); + assert.equal(named('Robot8A'), 'Robot8A'); + assert.equal(named(' padded '), 'padded'); + assert.equal(named('x'.repeat(200)).length, 80); + // The producer role among the providers serves when the property is missing. + const item = stacItem({ id: 'a', lon: 1, lat: 2 }); + delete item.properties['geovisio:producer']; + item.providers = [ + { name: 'host', roles: ['host'] }, + { name: 'mapper', roles: ['producer'] }, + ]; + assert.equal(normalizeItem(item).creator, 'mapper'); +}); + +test('360°: a full field of view, or the equirectangular projection', () => { + assert.equal( + isEquirectangular({ + properties: { 'pers:interior_orientation': { field_of_view: 360 } }, + }), + true, + ); + assert.equal( + isEquirectangular({ properties: { 'pers:projection': 'equirectangular' } }), + true, + ); + assert.equal( + isEquirectangular({ + properties: { 'pers:interior_orientation': { field_of_view: 92 } }, + }), + false, + ); + assert.equal(isEquirectangular(null), false); +}); + +test('licences read as the caption names them', () => { + assert.equal(licenseLabel('CC-BY-SA-4.0'), 'CC BY-SA 4.0'); + assert.equal(licenseLabel('etalab-2.0'), 'Licence Ouverte 2.0'); + assert.equal(licenseLabel('CC0-1.0'), 'CC0'); + assert.equal(licenseLabel('ODbL-1.0'), 'ODbL-1.0'); + assert.equal(licenseLabel('proprietary'), null); + assert.equal(licenseLabel(''), null); + assert.equal(licenseLabel(undefined), null); + assert.equal(licenseLabel('x'.repeat(100)).length, 40); +}); + +test('the caption link opens the picture in the Panoramax viewer', () => { + assert.equal( + panoramaxPictureUrl('d5467bbb-4357'), + 'https://api.panoramax.xyz/#pic=d5467bbb-4357&focus=pic', + ); + assert.equal(panoramaxPictureUrl(''), 'https://api.panoramax.xyz/'); + assert.equal(panoramaxPictureUrl(null), 'https://api.panoramax.xyz/'); + assert.ok(panoramaxPictureUrl('a&b=c').includes('pic=a%26b%3Dc')); +}); diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs new file mode 100644 index 000000000..e0d5eae61 --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -0,0 +1,526 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createPanoramaxProvider, + panoramaxZoomFor, + passesCoverage, + pictureTilesAround, +} from './index.js'; +import { NO_COVERAGE_HINT, OVERVIEW_HINT, PICK_PREFIX } from './policy.js'; +import { validateProviders } from '../../registry.js'; +import { tileBounds } from '../../tileMath.js'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; +import { + coverageViewer, + tileCentre, +} from '../../../../testSupport/streetLevelFakes.mjs'; +import { + encodePanoramaxTile, + stacItem, +} from '../../../../testSupport/panoramaxFixtures.mjs'; + +const ORIGIN = { lon: 2.3495, lat: 48.8874 }; +/** A point `metres` east of ORIGIN. */ +const east = (metres) => ({ + lon: ORIGIN.lon + metres / 73_300, + lat: ORIGIN.lat, +}); + +/** + * A sequence `seq` of `count` pictures 5 m apart heading east from ORIGIN + * (`offset` m in), each linked to its neighbours. + */ +function sequence(seq, count, { offset = 0, pano = false } = {}) { + return Array.from({ length: count }, (_, i) => { + const at = east(offset + i * 5); + return stacItem({ + id: `${seq}-${i}`, + sequenceId: seq, + ...at, + pano, + prevId: i ? `${seq}-${i - 1}` : null, + nextId: i < count - 1 ? `${seq}-${i + 1}` : null, + }); + }); +} + +/** A source over `items`, answering nearest lookups by distance, recording calls. */ +function fakeSource({ items = [], tiles = () => new Uint8Array(0) } = {}) { + const calls = { nearest: [], items: [], tiles: [] }; + const metres = (a, b) => + Math.hypot((a.lon - b.lon) * 73_300, (a.lat - b.lat) * 111_200); + return { + calls, + getTile: async (tile) => { + calls.tiles.push(`${tile.z}/${tile.x}/${tile.y}`); + return tiles(tile); + }, + getItem: async (id) => { + calls.items.push(id); + return items.find((item) => item.id === id) ?? null; + }, + nearestItems: async (query) => { + calls.nearest.push(query); + const at = { lon: query.lon, lat: query.lat }; + return items + .filter( + (item) => !query.collections || item.collection === query.collections, + ) + .map((item) => ({ + item, + d: metres(at, { + lon: item.geometry.coordinates[0], + lat: item.geometry.coordinates[1], + }), + })) + .filter(({ d }) => d <= query.radius) + .sort((a, b) => a.d - b.d) + .slice(0, query.limit) + .map(({ item }) => item); + }, + }; +} + +function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { + const actions = { opened: [], nearest: [], errors: [], instance: null }; + return { + actions, + context: { + services: {}, + getFilter: () => filter, + isActive: () => true, + groundCaster: null, + meshSampler: null, + getSurface: () => 'draped', + notify() {}, + actions: { + openImage: async (id, options) => { + actions.opened.push({ id, options }); + // As the core does, errors included (a test may not mount a viewer). + await actions.instance?.viewer.open(id).catch((error) => { + actions.errors.push(error.message); + }); + return true; + }, + openNearest: (point) => actions.nearest.push(point), + reportError: (message) => actions.errors.push(message), + }, + }, + }; +} + +function setup(sourceOptions, contextOptions) { + const source = fakeSource(sourceOptions); + const loads = []; + const definition = createPanoramaxProvider({ + source, + viewerOptions: { + load: async (url) => { + loads.push(url); + return { src: url }; + }, + createRenderer: () => { + throw new Error('no WebGL in Node'); + }, + }, + }); + const { context, actions } = fakeContext(contextOptions); + const instance = definition.create(context); + actions.instance = instance; + return { definition, instance, source, actions, loads }; +} + +test('Panoramax registers keyless and off by default, with both filters and its own wording', () => { + const { definition } = setup(); + assert.equal(definition.id, 'panoramax'); + assert.equal(definition.label, 'PANORAMAX'); + assert.equal(definition.requiresKeyId, null); + assert.equal(definition.defaultOn, false); + assert.equal(definition.filters, undefined, 'pano and since both apply'); + assert.notEqual(definition.highlightsSequence, false, 'lines are sequences'); + assert.match(definition.credit.html, /Panoramax<\/a> contributors/); + assert.equal( + definition.coverageLabel.action, + 'click a line for the photo there', + ); + assert.doesNotThrow(() => validateProviders([definition])); +}); + +test('lines at z11–14 below 30 km; density points from z5 down to z0 above', () => { + assert.deepEqual(panoramaxZoomFor(500), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(1_800), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(4_000), { zoom: 13, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(12_000), { zoom: 12, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(25_000), { zoom: 11, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(30_000), { zoom: 11, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 5, kind: 'overview' }); + assert.deepEqual(panoramaxZoomFor(100_000), { zoom: 5, kind: 'overview' }); + assert.deepEqual(panoramaxZoomFor(2_000_000), { zoom: 3, kind: 'overview' }); + assert.deepEqual(panoramaxZoomFor(20_000_000), { zoom: 0, kind: 'overview' }); + assert.equal(panoramaxZoomFor(NaN), null); +}); + +test('the imagery filter: lines by type and day, density cells by what they hold', () => { + const old = Date.UTC(2020, 0, 1); + const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) }; + assert.equal( + passesCoverage({ isPano: false, capturedAt: old }, since), + false, + ); + assert.equal( + passesCoverage( + { isPano: true, capturedAt: Date.UTC(2025, 0, 1) }, + { + pano: 'pano', + sinceMs: null, + }, + ), + true, + ); + const cell = { hasPano: false, hasFlat: true, capturedAt: null }; + assert.equal(passesCoverage(cell, { pano: 'pano', sinceMs: null }), false); + assert.equal(passesCoverage(cell, { pano: 'flat', sinceMs: null }), true); + assert.equal(passesCoverage(cell, since), true, 'cells are undated: kept'); + assert.equal(passesCoverage(cell, null), true); +}); + +test('cones read the z15 tile around a picture, and its neighbours near an edge', () => { + const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15); + assert.equal(pictureTilesAround(home).length, 1, 'mid-tile: one tile'); + const box = tileBounds(home.tile.x, home.tile.y, 15); + const corner = { lon: box.west + 0.0002, lat: box.north - 0.0002 }; + const tiles = pictureTilesAround(corner).map((t) => `${t.x}/${t.y}`); + const { x, y } = home.tile; + assert.deepEqual(tiles, [ + `${x}/${y}`, + `${x - 1}/${y}`, + `${x}/${y - 1}`, + `${x - 1}/${y - 1}`, + ]); + // Across the antimeridian the column wraps. + const dateLine = pictureTilesAround({ lon: -179.9999, lat: 0.5 }); + assert.equal(dateLine[1].x, 2 ** 15 - 1); +}); + +test('the nearest picture within 50 m that passes the filter; filters go to the API', async () => { + const items = [ + ...sequence('far', 1, { offset: 80 }), + ...sequence('a', 3, { offset: 10 }), + ]; + const { instance, source } = setup( + { items }, + { filter: { pano: 'pano', sinceMs: 123 } }, + ); + const id = await instance.nearestImage(east(0)); + assert.equal(id, 'a-0'); + assert.equal(source.calls.nearest.length, 1); + assert.equal(source.calls.nearest[0].pano, 'pano'); + assert.equal(source.calls.nearest[0].sinceMs, 123); + assert.equal(source.calls.nearest[0].collections, null); + assert.equal(await instance.nearestImage(east(300)), null, 'none near'); +}); + +test('a click on a line opens that sequence’s picture nearest the click, not another’s', async () => { + const items = [ + ...sequence('other', 2, { offset: 0 }), + ...sequence('clicked', 4, { offset: 8 }), + ]; + const { instance, actions, source } = setup({ items }); + assert.equal( + instance.handlePick(`${PICK_PREFIX.sequence}clicked~1`, { + groundPoint: () => east(1), + }), + true, + ); + assert.deepEqual(actions.nearest, [east(1)]); + // The core asks the provider for the nearest picture to that point. + assert.equal(await instance.nearestImage(east(1)), 'clicked-0'); + assert.equal(source.calls.nearest[0].collections, 'clicked'); + // Only once: the next lookup (OPEN, another click) is any sequence's. + assert.equal(await instance.nearestImage(east(1)), 'other-0'); +}); + +test('a line click whose sequence has nothing near falls back to any picture there', async () => { + const items = [ + ...sequence('near', 1), + ...sequence('clicked', 1, { offset: 400 }), + ]; + const { instance, source } = setup({ items }); + instance.handlePick(`${PICK_PREFIX.sequence}clicked`, { + groundPoint: () => east(0), + }); + assert.equal(await instance.nearestImage(east(0)), 'near-0'); + assert.deepEqual( + source.calls.nearest.map((q) => q.collections), + ['clicked', null], + ); +}); + +test('a cone opens its picture; foreign ids are not picked', () => { + const { instance, actions } = setup(); + assert.equal(instance.handlePick(`${PICK_PREFIX.image}abc`), true); + assert.equal(actions.opened[0].id, 'abc'); + assert.equal(instance.handlePick('kv:line:1'), false); + assert.equal(instance.handlePick('pnx:other'), false); +}); + +/** A provider and its viewer mounted, opening pictures through the fake core. */ +async function mounted(sourceOptions, contextOptions) { + const env = setup(sourceOptions, contextOptions); + const poses = []; + env.instance.viewer.onPose((pose) => poses.push(pose.imageId)); + const host = fakeHost(); + await env.instance.viewer.mount(host); + const [, , prev, next] = host.children[0].children; + return { ...env, poses, prev, next }; +} + +test('› and ‹ step along the picture’s links without moving the camera, asking only for the next picture', async () => { + const items = sequence('s', 3); + const env = await mounted({ items }); + await env.instance.viewer.open('s-0'); + assert.ok(await until(() => env.prev.disabled), 'the first picture: no ‹'); + env.next.fire('click'); + assert.ok(await until(() => env.poses.at(-1) === 's-1')); + assert.deepEqual(env.actions.opened.at(-1), { + id: 's-1', + options: { frame: false }, + }); + env.next.fire('click'); + assert.ok(await until(() => env.poses.at(-1) === 's-2')); + assert.ok(await until(() => env.next.disabled), 'the last picture: no ›'); + assert.deepEqual(env.source.calls.items, ['s-0', 's-1', 's-2']); + env.prev.fire('click'); + assert.ok(await until(() => env.poses.at(-1) === 's-1')); + assert.equal(env.source.calls.items.length, 3, 's-1 came from the cache'); +}); + +test('one lookup for a picture asked twice at once', async () => { + const items = sequence('s', 1); + const { instance, source } = setup({ items }); + let release; + const gate = new Promise((resolve) => (release = resolve)); + const getItem = source.getItem; + source.getItem = async (id) => { + await gate; + return getItem(id); + }; + const host = fakeHost(); + await instance.viewer.mount(host); + const first = instance.viewer.open('s-0'); + const second = instance.viewer.open('s-0'); + release(); + await Promise.all([first, second]); + assert.deepEqual(source.calls.items, ['s-0']); +}); + +test('a picture Panoramax does not know fails the open with a reason', async () => { + const env = await mounted({ items: [] }); + await assert.rejects(env.instance.viewer.open('ghost'), /no position/); +}); + +/** Cones on a stand-in Cesium viewer; `count()` reads how many are drawn. */ +async function withCones(t, env) { + const Cesium = await import('cesium'); + // The cone glyph draws on a canvas: any drawing call is fine. + 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 { count: () => collection?.length ?? 0 }; +} + +/** z15 tiles holding every item of `items` as a picture. */ +const pictureTiles = (items) => (tile) => { + if (tile.z !== 15) return new Uint8Array(0); + const box = tileBounds(tile.x, tile.y, tile.z); + const inside = items.filter( + ({ + geometry: { + coordinates: [lon, lat], + }, + }) => + lon >= box.west && lon < box.east && lat <= box.north && lat > box.south, + ); + return encodePanoramaxTile(tile, { + pictures: inside.map((item) => ({ + id: item.id, + lon: item.geometry.coordinates[0], + lat: item.geometry.coordinates[1], + sequenceId: item.collection, + ts: item.properties.datetime.replace('T', ' ').replace('+00:00', '+00'), + })), + }); +}; + +test('once a picture is on screen its sequence’s cones load from the z15 tile around it', async (t) => { + const items = [...sequence('s', 6), ...sequence('x', 3, { offset: 2 })]; + const env = await mounted({ items, tiles: pictureTiles(items) }); + const cones = await withCones(t, env); + await env.instance.viewer.open('s-2'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + assert.equal(env.instance.sequenceStats().selectedId, 's'); + assert.equal(env.instance.sequenceStats().images, 6, 'only sequence s'); + assert.equal(cones.count(), 6); + assert.ok(env.source.calls.tiles.every((key) => key.startsWith('15/'))); +}); + +test('a sequence selected with no picture or click to centre on draws no cones', async (t) => { + const items = sequence('s', 3); + const env = await mounted({ items, tiles: pictureTiles(items) }); + await withCones(t, env); + env.instance.handlePick(`${PICK_PREFIX.sequence}s`, { + groundPoint: () => null, + }); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + assert.equal(env.instance.sequenceStats().selectedId, 's', 'highlighted'); + assert.equal(env.instance.sequenceStats().images, 0); + assert.deepEqual(env.source.calls.tiles, []); + assert.deepEqual(env.actions.nearest, []); +}); + +test('the cones re-centre when › walks into another z15 tile', async (t) => { + // 400 pictures 5 m apart span 2 km: more than two z15 tiles (~800 m). + const items = sequence('long', 400); + const env = await mounted({ items, tiles: pictureTiles(items) }); + await withCones(t, env); + await env.instance.viewer.open('long-0'); + await env.instance.selectSequence('long'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + const firstTiles = new Set(env.source.calls.tiles); + const first = env.instance.sequenceStats().images; + assert.ok(first > 0 && first < 400, `part of the sequence: ${first}`); + // Jump to the far end: the cones follow. + await env.instance.viewer.open('long-399'); + assert.ok( + await until(() => + env.source.calls.tiles.some((key) => !firstTiles.has(key)), + ), + 'a new tile was read', + ); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + assert.ok(env.instance.sequenceStats().images > 0); +}); + +/** The provider drawing coverage on a stand-in viewer above one z14 tile. */ +function coverageSetup( + t, + { sequences = [], grid = [], filter, height = 900 } = {}, +) { + const centre = tileCentre(ORIGIN.lon, ORIGIN.lat); + let hold = null; + const env = setup( + { + tiles: (tile) => { + const bytes = encodePanoramaxTile(tile, { sequences, grid }); + return hold ? hold.then(() => bytes) : bytes; + }, + }, + { filter }, + ); + const viewer = coverageViewer({ ...centre, height }, t); + env.instance.init(viewer); + t.after(() => env.instance.destroy(viewer)); + return { + ...env, + viewer, + centre, + holdTiles() { + let release; + hold = new Promise((resolve) => (release = resolve)); + return () => { + hold = null; + release(); + }; + }, + }; +} + +const line = (id, options = {}) => ({ + id, + parts: [ + [ + [ORIGIN.lon - 0.001, ORIGIN.lat], + [ORIGIN.lon + 0.001, ORIGIN.lat], + ], + ], + ...options, +}); + +test('sequence lines draw near the ground, and the imagery filter thins them', async (t) => { + const env = coverageSetup(t, { + sequences: [ + line('new-pano', { type: 'equirectangular', date: '2025-06-01' }), + line('old-flat', { date: '2019-06-01' }), + ], + }); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 2)); + assert.equal(env.instance.coverageStats().kind, 'sequence'); + assert.equal(env.instance.coverageStats().keyRequired, false); + env.instance.deactivate(); + + const filtered = coverageSetup(t, { + filter: { pano: 'pano', sinceMs: null }, + sequences: [ + line('new-pano', { type: 'equirectangular' }), + line('old-flat'), + ], + }); + filtered.instance.activate(filtered.viewer); + assert.ok(await until(() => filtered.instance.coverageStats().count === 1)); +}); + +test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => { + const env = coverageSetup(t); + const release = env.holdTiles(); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().hint, ''); + release(); + assert.ok(await until(() => !env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); +}); + +test('from high up the density grid draws points, not counted as sequences, and the panel says to zoom in', async (t) => { + const env = coverageSetup(t, { + height: 200_000, + grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 2, flat: 3 }], + }); + env.instance.activate(env.viewer); + assert.ok( + await until(() => env.instance.coverageStats().kind === 'overview'), + ); + assert.ok(await until(() => !env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().zoom, 5); + assert.equal(env.instance.coverageStats().count, 0, 'dots are not sequences'); + assert.equal(env.instance.coverageStats().hint, OVERVIEW_HINT); +}); + +test('switching off clears the cones and forgets a pending line click', async (t) => { + const items = sequence('s', 3); + const env = await mounted({ items, tiles: pictureTiles(items) }); + await withCones(t, env); + env.instance.handlePick(`${PICK_PREFIX.sequence}s`, { + groundPoint: () => east(0), + }); + env.instance.deactivate(); + assert.equal(env.instance.sequenceStats().selectedId, null); + // The click's preference is gone: a later lookup asks any sequence. + await env.instance.nearestImage(east(0)); + assert.equal(env.source.calls.nearest.at(-1).collections, null); +}); diff --git a/src/layers/streetLevel/providers/panoramax/source.js b/src/layers/streetLevel/providers/panoramax/source.js new file mode 100644 index 000000000..3ddb6e1e9 --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/source.js @@ -0,0 +1,184 @@ +import { createLimiter } from '../../limiter.js'; +import { + API_CONCURRENCY, + API_TIMEOUT_MS, + MAX_RESPONSE_BYTES, + NEAREST_FOV_TOLERANCE_DEG, + NEAREST_LIMIT, + NEAREST_RADIUS_M, + PANORAMAX_API, + TILE_CONCURRENCY, + TILE_TIMEOUT_MS, +} from './policy.js'; + +/** An HTTP or API failure; `retryAfterSec` is set while Panoramax is busy. */ +export class PanoramaxSourceError extends Error { + constructor(message, { status = 0, retryAfterSec = null } = {}) { + super(message); + this.name = 'PanoramaxSourceError'; + this.status = status; + this.retryAfterSec = retryAfterSec; + } +} + +const NO_ANSWER = 'Panoramax did not answer in time — try again'; + +/** 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; +} + +/** The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the headers say. */ +async function readBounded(response) { + const tooMuch = () => + new PanoramaxSourceError('Panoramax sent too much data', { + status: response.status, + }); + const length = Number(response.headers?.get?.('content-length')); + if (length > MAX_RESPONSE_BYTES) throw tooMuch(); + const reader = response.body?.getReader?.(); + if (!reader) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_RESPONSE_BYTES) throw tooMuch(); + return bytes; + } + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + reader.cancel().catch(() => {}); + throw tooMuch(); + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +/** + * Every network path the Panoramax provider uses, browser-side and keyless: + * the meta-catalogue allows any origin. Vector tiles for coverage and + * cones, STAC search for single pictures and nearest ones. + * @param {{fetchImpl?: typeof fetch, api?: string}} [options] + */ +export function createPanoramaxSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + api = PANORAMAX_API, +} = {}) { + const root = api.replace(/\/+$/, ''); + const apiLimiter = createLimiter(API_CONCURRENCY, { lifo: true }); + const tileLimiter = createLimiter(TILE_CONCURRENCY); + + /** GET within `timeoutMs`; the caller's abort wins over the timeout. */ + async function get(url, { signal, timeoutMs, empty = [] }) { + 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 PanoramaxSourceError(NO_ANSWER)), + timeoutMs, + ); + try { + const response = await fetchImpl(url, { signal: controller.signal }); + if (empty.includes(response.status)) return null; + if (response.status === 429 || response.status === 503) + throw new PanoramaxSourceError('Panoramax is busy', { + status: response.status, + retryAfterSec: retryAfterSeconds(response) ?? 5, + }); + if (!response.ok) + throw new PanoramaxSourceError( + `Panoramax answered HTTP ${response.status}`, + { status: response.status }, + ); + return await readBounded(response); + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (controller.signal.aborted) throw controller.signal.reason; + if (error instanceof PanoramaxSourceError) throw error; + throw new PanoramaxSourceError(NO_ANSWER); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + } + + /** A STAC search, through the API gate (newest request first). */ + async function search(params, { signal } = {}) { + const url = `${root}/search?${new URLSearchParams(params)}`; + const bytes = await apiLimiter.run( + () => get(url, { signal, timeoutMs: API_TIMEOUT_MS }), + { signal }, + ); + let payload; + try { + payload = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new PanoramaxSourceError('Panoramax sent an unreadable answer'); + } + return Array.isArray(payload?.features) ? payload.features : []; + } + + return { + api: root, + + /** Raw vector-tile bytes; an empty array where there is no coverage. */ + async getTile({ x, y, z }, { signal } = {}) { + const url = `${root}/map/${z}/${x}/${y}.mvt`; + const bytes = await tileLimiter.run( + () => + get(url, { signal, timeoutMs: TILE_TIMEOUT_MS, empty: [204, 404] }), + { signal }, + ); + return bytes ?? new Uint8Array(0); + }, + + /** One picture (a STAC item) by id, or null. */ + async getItem(id, { signal } = {}) { + const [item] = await search({ ids: String(id), limit: '1' }, { signal }); + return item && String(item.id) === String(id) ? item : null; + }, + + /** + * Pictures within `radius` metres of a point, nearest first, whichever + * way they look; `collections` keeps one sequence's, `pano` and + * `sinceMs` filter as the imagery filter does. + */ + async nearestItems( + { + lat, + lon, + radius = NEAREST_RADIUS_M, + limit = NEAREST_LIMIT, + collections = null, + pano = 'all', + sinceMs = null, + }, + { signal } = {}, + ) { + const params = { + place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`, + place_distance: `0-${Math.min(1000, Math.max(1, Math.round(radius)))}`, + place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG), + limit: String(Math.min(100, Math.max(1, limit))), + }; + if (collections) params.collections = String(collections); + if (pano === 'pano') params.filter = 'field_of_view=360'; + else if (pano === 'flat') params.filter = 'field_of_view<360'; + if (Number.isFinite(sinceMs) && sinceMs > 0) + params.datetime = `${new Date(sinceMs).toISOString()}/..`; + return search(params, { signal }); + }, + }; +} diff --git a/src/layers/streetLevel/providers/panoramax/source.test.mjs b/src/layers/streetLevel/providers/panoramax/source.test.mjs new file mode 100644 index 000000000..7f2e87237 --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/source.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PanoramaxSourceError, createPanoramaxSource } from './source.js'; +import { MAX_RESPONSE_BYTES } from './policy.js'; + +/** A fetch answering from `respond(url)`, recording every URL asked. */ +function fakeFetch(respond) { + const urls = []; + const fetchImpl = async (url, { signal } = {}) => { + urls.push(url); + const answer = await respond(url, signal); + if (answer instanceof Response) return answer; + const { status = 200, body = '', headers = {} } = answer; + return new Response(status === 204 ? null : body, { status, headers }); + }; + return { urls, fetchImpl }; +} + +const json = (value) => ({ body: JSON.stringify(value) }); + +test('a tile is the raw vector-tile bytes; 204 and 404 mean no coverage', async () => { + const { urls, fetchImpl } = fakeFetch((url) => + url.includes('/14/') + ? { body: new Uint8Array([1, 2, 3]) } + : { status: 204 }, + ); + const source = createPanoramaxSource({ fetchImpl }); + assert.deepEqual( + [...(await source.getTile({ x: 8299, y: 5634, z: 14 }))], + [1, 2, 3], + ); + assert.equal(urls[0], 'https://api.panoramax.xyz/api/map/14/8299/5634.mvt'); + assert.equal((await source.getTile({ x: 1, y: 1, z: 13 })).length, 0); + const missing = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ status: 404 })).fetchImpl, + }); + assert.equal((await missing.getTile({ x: 1, y: 1, z: 13 })).length, 0); +}); + +test('a busy API says so and when to come back; other failures name the status', async () => { + const busy = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ + status: 429, + headers: { 'retry-after': '12' }, + })).fetchImpl, + }); + await assert.rejects(busy.getTile({ x: 0, y: 0, z: 0 }), (error) => { + assert.ok(error instanceof PanoramaxSourceError); + assert.equal(error.status, 429); + assert.equal(error.retryAfterSec, 12); + return true; + }); + const down = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ status: 503 })).fetchImpl, + }); + await assert.rejects(down.getTile({ x: 0, y: 0, z: 0 }), (error) => { + assert.equal(error.retryAfterSec, 5, 'a default wait without the header'); + return true; + }); + const broken = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ status: 500 })).fetchImpl, + }); + await assert.rejects(broken.getItem('x'), /HTTP 500/); +}); + +test('an oversized answer is refused while it streams', async () => { + let pulled = 0; + const huge = new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(new Uint8Array(4 * 1024 * 1024)); + }, + }); + const source = createPanoramaxSource({ + fetchImpl: async () => new Response(huge, { status: 200 }), + }); + await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), /too much data/); + assert.ok(pulled * 4 * 1024 * 1024 <= MAX_RESPONSE_BYTES + 8 * 1024 * 1024); + // A declared length over the cap is refused before reading. + const declared = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ + body: 'x', + headers: { 'content-length': String(MAX_RESPONSE_BYTES + 1) }, + })).fetchImpl, + }); + await assert.rejects(declared.getItem('a'), /too much data/); +}); + +test('a picture by id is a STAC search; another id in the answer is not taken for it', async () => { + const { urls, fetchImpl } = fakeFetch((url) => + url.includes('ids=good') + ? json({ features: [{ id: 'good' }] }) + : json({ features: [{ id: 'other' }] }), + ); + const source = createPanoramaxSource({ fetchImpl }); + assert.deepEqual(await source.getItem('good'), { id: 'good' }); + assert.equal(await source.getItem('bad'), null); + const asked = new URL(urls[0]); + assert.equal(asked.pathname, '/api/search'); + assert.equal(asked.searchParams.get('ids'), 'good'); + assert.equal(asked.searchParams.get('limit'), '1'); +}); + +test('nearest pictures: any direction within the radius, filtered as the imagery filter asks', async () => { + const { urls, fetchImpl } = fakeFetch(() => json({ features: [] })); + const source = createPanoramaxSource({ fetchImpl }); + await source.nearestItems({ lat: 48.85, lon: 2.35 }); + await source.nearestItems({ + lat: 48.85, + lon: 2.35, + collections: 'seq', + pano: 'pano', + sinceMs: Date.UTC(2025, 0, 1), + radius: 5000, + limit: 500, + }); + await source.nearestItems({ lat: 48.85, lon: 2.35, pano: 'flat' }); + const [plain, filtered, flat] = urls.map((u) => new URL(u).searchParams); + assert.equal(plain.get('place_position'), '2.3500000,48.8500000'); + assert.equal(plain.get('place_distance'), '0-50'); + assert.equal(plain.get('place_fov_tolerance'), '180'); + assert.equal(plain.get('collections'), null); + assert.equal(plain.get('filter'), null); + assert.equal(plain.get('datetime'), null); + assert.equal(filtered.get('collections'), 'seq'); + assert.equal(filtered.get('filter'), 'field_of_view=360'); + assert.equal(filtered.get('datetime'), '2025-01-01T00:00:00.000Z/..'); + assert.equal(filtered.get('place_distance'), '0-1000', 'capped'); + assert.equal(filtered.get('limit'), '100', 'capped'); + assert.equal(flat.get('filter'), 'field_of_view<360'); +}); + +test('an unreadable search answer, or one with no features, is handled', async () => { + const garbled = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ body: '' })).fetchImpl, + }); + await assert.rejects(garbled.getItem('a'), /unreadable/); + const empty = createPanoramaxSource({ + fetchImpl: fakeFetch(() => json({ type: 'FeatureCollection' })).fetchImpl, + }); + assert.deepEqual(await empty.nearestItems({ lat: 0, lon: 0 }), []); +}); + +test('the caller’s abort wins; a silent API times out with a readable message', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const hang = (url, signal) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + const source = createPanoramaxSource({ + fetchImpl: fakeFetch(hang).fetchImpl, + }); + const controller = new AbortController(); + const asked = source.getItem('a', { signal: controller.signal }); + await Promise.resolve(); + controller.abort(new Error('overtaken')); + await assert.rejects(asked, /overtaken/); + const slow = source.getTile({ x: 0, y: 0, z: 0 }); + // Let the request start before the clock runs out. + for (let i = 0; i < 5; i++) await Promise.resolve(); + t.mock.timers.tick(60_000); + await assert.rejects(slow, /did not answer in time/); +}); + +test('a network failure reads as no answer, not as a stack trace', async () => { + const source = createPanoramaxSource({ + fetchImpl: async () => { + throw new TypeError('Failed to fetch'); + }, + }); + await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), (error) => { + assert.ok(error instanceof PanoramaxSourceError); + assert.match(error.message, /did not answer/); + return true; + }); +}); diff --git a/src/layers/streetLevel/providers/panoramax/viewer.js b/src/layers/streetLevel/providers/panoramax/viewer.js new file mode 100644 index 000000000..df666237b --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/viewer.js @@ -0,0 +1,63 @@ +import { createPhotoViewer, loadImage } from '../../photoViewer.js'; +import { + HD_TIMEOUT_MS, + PANORAMAX_NAME, + PANORAMAX_PROVIDER_ID, + PANO_HD_MAX_WIDTH, + SD_WIDTH, + panoramaxPictureUrl, +} from './policy.js'; + +/** + * Where Panoramax pictures come from: the instance holding each one serves + * a 2,048 px SD derivative (equirectangular for a 360° picture) and the HD + * original. SD shows first; HD only once the viewer is wide enough to use + * it. A 360° original is shrunk to the texture size while it decodes. + * @type {import('../../photoViewer.js').PhotoSources} + */ +export const PANORAMAX_SOURCES = Object.freeze({ + hasPano: (photo) => Boolean(photo.isPano && (photo.urls.sd || photo.urls.hd)), + panoFirst: (photo) => + photo.urls.sd + ? { url: photo.urls.sd, width: SD_WIDTH } + : { url: photo.urls.hd, width: photo.width || SD_WIDTH }, + panoSharp: (photo, size) => { + const width = photo.width || 0; + if (!photo.urls.hd || !photo.urls.sd) return null; + // An original no wider than the SD one adds nothing; a huge one would + // take hundreds of megabytes to decode. + if (width <= SD_WIDTH || width > PANO_HD_MAX_WIDTH) return null; + const target = Math.min(size[0], width); + return { + url: photo.urls.hd, + resize: [target, target / 2], + timeoutMs: HD_TIMEOUT_MS, + }; + }, + // A 360° picture shown flat (no WebGL) is its whole equirectangular image. + flatFirst: (photo) => [photo.urls.sd, photo.urls.hd, photo.urls.thumb], + flatSharp: (photo, pixels) => + photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null, + describe: (photo) => ({ + title: null, + creator: photo.creator, + license: photo.license, + externalUrl: panoramaxPictureUrl(photo.id), + }), +}); + +/** + * Viewer adapter for Panoramax pictures: flat in an , 360° on the + * shared WebGL panorama, ‹ › along the sequence (photoViewer.js). + * @param {object} options See photoViewer.js `createPhotoViewer`. + * @returns {import('../../registry.js').ViewerAdapter} + */ +export function createPanoramaxViewer({ load = loadImage, ...options }) { + return createPhotoViewer({ + providerId: PANORAMAX_PROVIDER_ID, + name: PANORAMAX_NAME, + sources: PANORAMAX_SOURCES, + load, + ...options, + }); +} diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs new file mode 100644 index 000000000..d1ec2732e --- /dev/null +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PANORAMAX_SOURCES, createPanoramaxViewer } from './viewer.js'; +import { normalizeItem } from './policy.js'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; +import { stacItem } from '../../../../testSupport/panoramaxFixtures.mjs'; + +const photo = (options) => + normalizeItem(stacItem({ lon: 2.35, lat: 48.85, ...options })); + +const PHOTOS = { + flat: photo({ id: 'flat', prevId: 'p0', nextId: 'flat2', heading: 30 }), + flat2: photo({ id: 'flat2', prevId: 'flat', heading: 40 }), + pano: photo({ id: 'pano', pano: true, heading: 100, width: 5760 }), + bigPano: photo({ id: 'bigPano', pano: true, width: 11_000 }), + smallPano: photo({ id: 'smallPano', pano: true, width: 2048 }), + email: photo({ id: 'email', producer: 'someone@city.example.fr' }), +}; + +function fakeRenderer() { + const view = { yaw: 0, pitch: 0, fov: 80 }; + const images = []; + return { + images, + setImage: (image) => images.push(image), + setView(next) { + Object.assign(view, next); + }, + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }; +} + +function setup() { + const loads = []; + const renderers = []; + const stepped = []; + const viewer = createPanoramaxViewer({ + getPhoto: async (id) => { + if (!PHOTOS[id]) throw new Error('no such picture'); + return PHOTOS[id]; + }, + step: async (from, delta) => (delta < 0 ? from.prevId : from.nextId), + onStep: (id) => { + stepped.push(id); + viewer.open(id).catch(() => {}); + }, + load: async (url, options) => { + loads.push({ url, ...options }); + return { src: url, width: 1, height: 1 }; + }, + createRenderer: () => { + const renderer = fakeRenderer(); + renderers.push(renderer); + return renderer; + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose)); + return { viewer, poses, loads, renderers, stepped, host: fakeHost() }; +} + +const parts = (host) => { + const root = host.children[0]; + const [picture, canvas, prev, next] = root.children; + return { root, picture, canvas, prev, next }; +}; + +test('a flat picture shows its SD version with author, licence and a Panoramax link', async () => { + const { viewer, poses, loads, host } = setup(); + await viewer.mount(host); + const { root, picture, canvas } = parts(host); + assert.equal(root.dataset.provider, 'panoramax', 'its colour in the CSS'); + assert.match(root.attributes['aria-label'], /^Panoramax photo/); + await viewer.open('flat'); + assert.equal(picture.hidden, false); + assert.equal(canvas.hidden, true); + assert.equal( + picture.src, + 'https://panoramax.example.org/pictures/flat/sd.jpg', + ); + assert.deepEqual( + loads.map((l) => l.url), + [PHOTOS.flat.urls.sd], + ); + const pose = poses.at(-1); + assert.equal(pose.providerId, 'panoramax'); + assert.equal(pose.bearing, 30); + assert.equal(pose.isPano, false); + assert.equal(pose.creator, 'tester'); + assert.equal(pose.license, 'CC BY-SA 4.0'); + assert.equal(pose.sequenceId, 'seq-1'); + assert.equal( + pose.externalUrl, + 'https://api.panoramax.xyz/#pic=flat&focus=pic', + ); + assert.equal(viewer.supportsRenderModes(), true); +}); + +test('no creator in the caption for an account named by an email address', async () => { + const { viewer, poses, host } = setup(); + await viewer.mount(host); + await viewer.open('email'); + assert.equal(poses.at(-1).creator, null); +}); + +test('HD replaces SD only once the viewer is wide enough to use it', async (t) => { + const { viewer, loads, host } = setup(); + await viewer.mount(host); + const { picture, root } = parts(host); + t.after(() => delete globalThis.devicePixelRatio); + globalThis.devicePixelRatio = 1; + picture.clientWidth = 400; + await viewer.open('flat'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(loads.length, 1, 'docked: SD is sharp enough'); + // EXPAND on a wide screen. + picture.clientWidth = root.clientWidth = 2600; + viewer.resize(); + assert.ok(await until(() => picture.src === PHOTOS.flat.urls.hd)); + viewer.resize(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + loads.filter((l) => l.url === PHOTOS.flat.urls.hd).length, + 1, + 'asked once', + ); +}); + +test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,096 when expanded', async (t) => { + const { viewer, poses, loads, renderers, host } = setup(); + await viewer.mount(host); + const { canvas, picture } = parts(host); + t.after(() => delete globalThis.devicePixelRatio); + globalThis.devicePixelRatio = 1; + canvas.clientWidth = 400; + await viewer.open('pano'); + assert.equal(canvas.hidden, false); + assert.equal(picture.hidden, true); + assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit'); + assert.deepEqual(loads[0], { + url: PHOTOS.pano.urls.sd, + crossOrigin: true, + failure: 'The Panoramax photo could not be loaded', + }); + assert.equal(poses.at(-1).isPano, true); + assert.equal(poses.at(-1).bearing, 100); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(loads.length, 1, 'docked: the 2,048 px SD is enough'); + canvas.clientWidth = 1800; + // EXPAND resizes more than once while the panel settles. + viewer.resize(); + viewer.resize(); + viewer.resize(); + assert.ok(await until(() => renderers[0].images.length === 2)); + assert.equal( + loads.filter((l) => l.url === PHOTOS.pano.urls.hd).length, + 1, + 'the original is fetched once', + ); + const hd = loads.at(-1); + assert.equal(hd.url, PHOTOS.pano.urls.hd); + assert.deepEqual(hd.resize, [4096, 2048]); + assert.equal(hd.timeoutMs, 30_000); +}); + +test('HD panoramas too big to decode, or no bigger than SD, are never fetched', () => { + const size = [4096, 2048]; + assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size), null); + assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size), null); + assert.deepEqual( + PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size).resize, + [3000, 1500], + 'never enlarged', + ); + // Without an SD derivative the HD one is the first and only. + const hdOnly = { ...PHOTOS.pano, urls: { ...PHOTOS.pano.urls, sd: null } }; + assert.deepEqual(PANORAMAX_SOURCES.panoFirst(hdOnly), { + url: hdOnly.urls.hd, + width: 5760, + }); + assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size), null); + assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null); + assert.equal( + PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2049), + PHOTOS.flat.urls.hd, + ); + assert.equal( + PANORAMAX_SOURCES.hasPano({ isPano: true, urls: { sd: null, hd: null } }), + false, + ); +}); + +test('› and ‹ follow the picture’s own links; the ends disable the buttons', async () => { + const { viewer, poses, stepped, host } = setup(); + await viewer.mount(host); + const { next, prev } = parts(host); + await viewer.open('flat'); + next.fire('click'); + assert.ok(await until(() => poses.at(-1)?.imageId === 'flat2')); + assert.deepEqual(stepped, ['flat2']); + assert.ok(await until(() => next.disabled), 'flat2 is the last'); + assert.equal(prev.disabled, false); +}); + +test('without WebGL a 360° picture shows its whole equirectangular image flat', async () => { + const loads = []; + const viewer = createPanoramaxViewer({ + getPhoto: async () => PHOTOS.pano, + step: async () => null, + onStep() {}, + load: async (url) => { + loads.push(url); + return { src: url }; + }, + createRenderer: () => { + throw new Error('no WebGL'); + }, + }); + const host = fakeHost(); + await viewer.mount(host); + await viewer.open('pano'); + const { picture } = parts(host); + assert.equal(picture.hidden, false); + assert.equal(picture.src, PHOTOS.pano.urls.sd); +}); + +test('a picture whose SD fails falls back to HD, then the thumbnail', async () => { + const tried = []; + const viewer = createPanoramaxViewer({ + getPhoto: async () => PHOTOS.flat, + step: async () => null, + onStep() {}, + load: async (url) => { + tried.push(url); + if (!url.endsWith('thumb.jpg')) throw new Error('gone'); + return { src: url }; + }, + }); + const host = fakeHost(); + await viewer.mount(host); + await viewer.open('flat'); + assert.deepEqual(tried, [ + PHOTOS.flat.urls.sd, + PHOTOS.flat.urls.hd, + PHOTOS.flat.urls.thumb, + ]); + assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 68fff8444..0e5d40143 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -82,6 +82,7 @@ export function filtersOf(provider) { * @property {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day'). * @property {string|null} [title] A place name for the caption, e.g. the address. * @property {string|null} creator + * @property {string|null} [license] The image's licence when it is per image, e.g. 'CC BY-SA 4.0'. * @property {string|null} sequenceId * @property {string} externalUrl Deep link to the image on the provider's site. */ diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index fd92e05ec..a7bf85e37 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -26,6 +26,7 @@ export function freshStreet() { renderModes: true, sequenceId: null, creator: null, + license: null, externalUrl: null, loading: false, error: null, diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 3a562c21e..8992d4905 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -38,6 +38,7 @@ export function createViewerHost({ state, parts }) { title: pose.title || null, sequenceId: pose.sequenceId ?? null, creator: pose.creator || null, + license: pose.license || null, externalUrl: pose.externalUrl || null, // FIT / FILL, unless this image has no frame to fit (a panorama). renderModes: diff --git a/src/testSupport/panoramaxFixtures.mjs b/src/testSupport/panoramaxFixtures.mjs new file mode 100644 index 000000000..47e807604 --- /dev/null +++ b/src/testSupport/panoramaxFixtures.mjs @@ -0,0 +1,129 @@ +// Panoramax stand-ins for tests and the hermetic QA gate: vector tiles in +// the meta-catalogue's shape, and STAC items. +import { PbfWriter } from 'pbf'; +import { + layerWriter, + lineGeometry, + lonLatToTileLocal, + zigzag, +} from '../layers/streetLevel/providers/mapillary/coverageFixture.mjs'; + +/** + * Encode a Panoramax `/api/map/{z}/{x}/{y}.mvt` tile from lon/lat features. + * @param {{x: number, y: number, z: number}} tile + * @param {{sequences?: Array<{id: string, date?: string, type?: string, parts: Array>}>, grid?: Array<{lon: number, lat: number, pano?: number, flat?: number}>, pictures?: Array<{id: string, lon: number, lat: number, sequenceId: string, heading?: number, ts?: string, type?: string}>, extent?: number}} [content] + */ +export function encodePanoramaxTile( + tile, + { sequences = [], grid = [], pictures = [], extent = 4096 } = {}, +) { + const pbf = new PbfWriter(); + if (sequences.length) { + const layer = layerWriter('sequences', extent); + for (const sequence of sequences) + layer.add( + 2, + lineGeometry( + sequence.parts.map((part) => + part.map((point) => lonLatToTileLocal(point, tile, extent)), + ), + ), + { + id: sequence.id, + type: sequence.type ?? 'flat', + date: sequence.date ?? '2025-06-01', + model: 'Test camera', + }, + ); + pbf.writeMessage(3, (_, out) => layer.write(out), null); + } + const point = (lon, lat) => { + const [px, py] = lonLatToTileLocal([lon, lat], tile, extent); + return [9, zigzag(px), zigzag(py)]; + }; + if (grid.length) { + const layer = layerWriter('grid', extent); + for (const cell of grid) { + const pano = cell.pano ?? 0; + const flat = cell.flat ?? 1; + layer.add(1, point(cell.lon, cell.lat), { + nb_pictures: pano + flat, + nb_360_pictures: pano, + nb_flat_pictures: flat, + }); + } + pbf.writeMessage(3, (_, out) => layer.write(out), null); + } + if (pictures.length) { + const layer = layerWriter('pictures', extent); + for (const picture of pictures) + layer.add(1, point(picture.lon, picture.lat), { + id: picture.id, + ts: picture.ts ?? '2025-06-01 10:00:00+00', + heading: picture.heading ?? 90, + sequences: JSON.stringify([picture.sequenceId]), + first_sequence: picture.sequenceId, + type: picture.type ?? 'flat', + }); + pbf.writeMessage(3, (_, out) => layer.write(out), null); + } + return new Uint8Array(pbf.finish()); +} + +/** + * A STAC item as the meta-catalogue returns it. + * @param {{id: string, lon: number, lat: number, sequenceId?: string, heading?: number, pano?: boolean, datetime?: string, producer?: string, license?: string, width?: number, prevId?: string|null, nextId?: string|null, host?: string}} options + */ +export function stacItem({ + id, + lon, + lat, + sequenceId = 'seq-1', + heading = 90, + pano = false, + datetime = '2025-06-01T10:00:00+00:00', + producer = 'tester', + license = 'CC-BY-SA-4.0', + width = pano ? 5760 : 4032, + prevId = null, + nextId = null, + host = 'https://panoramax.example.org', +}) { + const api = 'https://api.panoramax.xyz/api'; + const links = [ + { rel: 'self', href: `${api}/collections/${sequenceId}/items/${id}` }, + ]; + if (prevId) + links.push({ + rel: 'prev', + href: `${api}/collections/${sequenceId}/items/${prevId}`, + }); + if (nextId) + links.push({ + rel: 'next', + href: `${api}/collections/${sequenceId}/items/${nextId}`, + }); + return { + type: 'Feature', + id, + collection: sequenceId, + geometry: { type: 'Point', coordinates: [lon, lat] }, + providers: [{ name: producer, roles: ['producer'] }], + properties: { + datetime, + license, + 'view:azimuth': heading, + 'geovisio:producer': producer, + 'pers:interior_orientation': { + ...(pano ? { field_of_view: 360 } : {}), + sensor_array_dimensions: [width, pano ? width / 2 : 3024], + }, + }, + assets: { + hd: { href: `${host}/pictures/${id}/hd.jpg` }, + sd: { href: `${host}/pictures/${id}/sd.jpg` }, + thumb: { href: `${host}/pictures/${id}/thumb.jpg` }, + }, + links, + }; +} diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 11680c5d3..354ca3edf 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -743,3 +743,97 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st 'GET api.kartaview.org/2.0/photo/{id}', ); }); + +test('Panoramax fixtures: two sequences on one street, searches honour sequence and filters, unknown calls are flagged', async () => { + const { + answerPanoramaxRequest, + describePanoramaxCall, + PNX_LINE, + PNX_SEQUENCES, + panoramaxHdDownloads, + panoramaxPictures, + panoramaxTile, + resetPanoramaxFixture, + } = await import('../../scripts/fixtures/street-level/panoramax.mjs'); + const { decodeCoverageTile, decodePictures } = await import( + '../layers/streetLevel/providers/panoramax/decode.js' + ); + const { normalizeItem } = await import( + '../layers/streetLevel/providers/panoramax/policy.js' + ); + resetPanoramaxFixture(); + const pictures = panoramaxPictures(); + assert.deepEqual( + [...new Set(pictures.map((p) => p.sequenceId))], + [...PNX_SEQUENCES], + ); + assert.ok(pictures.some((p) => p.isPano)); + // Coverage: both lines in a z14 tile on the street; cones in z15. + const z = 14; + const x = lonToTileX((PNX_LINE.west + PNX_LINE.east) / 2, z); + const y = latToTileY(PNX_LINE.lat, z); + const lines = decodeCoverageTile(panoramaxTile(z, x, y), { x, y, z }); + assert.deepEqual( + lines.sequences.map((s) => s.id).sort(), + [...PNX_SEQUENCES].sort(), + ); + const t15 = { + x: lonToTileX(pictures[20].lon, 15), + y: latToTileY(PNX_LINE.lat, 15), + z: 15, + }; + assert.ok( + decodePictures(panoramaxTile(15, t15.x, t15.y), t15, PNX_SEQUENCES[0]) + .length > 0, + ); + // A search for one sequence near the street gives only its pictures, nearest first. + const at = pictures[30]; + const ask = (query) => + JSON.parse( + answerPanoramaxRequest({ + method: 'GET', + url: `https://api.panoramax.xyz/api/search?${query}`, + }).body, + ).features.map(normalizeItem); + const own = ask( + `place_position=${at.lon},${at.lat}&place_distance=0-50&limit=5&collections=${PNX_SEQUENCES[1]}`, + ); + assert.ok(own.length > 0); + assert.ok(own.every((p) => p.sequenceId === PNX_SEQUENCES[1])); + assert.equal(own[0].nextId && typeof own[0].nextId, 'string'); + const panos = ask( + `place_position=${at.lon},${at.lat}&place_distance=0-200&limit=50&filter=field_of_view%3D360`, + ); + assert.ok(panos.length > 0 && panos.every((p) => p.isPano)); + assert.deepEqual( + ask( + `place_position=${at.lon},${at.lat}&place_distance=0-200&limit=50&datetime=2024-01-01T00:00:00.000Z/..`, + ).every((p) => p.sequenceId === PNX_SEQUENCES[0]), + true, + 'the second sequence is older', + ); + const one = ask(`ids=${at.id}&limit=1`); + assert.equal(one[0].id, at.id); + // Its pictures, and only those, are served; HD downloads are counted. + const file = (name) => + answerPanoramaxRequest({ + method: 'GET', + url: `https://panoramax.openstreetmap.fr/derivates/${name}`, + }); + assert.equal(file(`${at.id}/sd.jpg`).status, 200); + assert.equal(file(`${at.id}/hd.jpg`).status, 200); + assert.equal(panoramaxHdDownloads(), 1); + assert.equal(file('nope/sd.jpg').known, false); + const unknown = answerPanoramaxRequest({ + method: 'GET', + url: 'https://api.panoramax.xyz/api/collections/x/items', + }); + assert.equal(unknown.known, false); + assert.equal( + describePanoramaxCall( + 'GET', + new URL('https://api.panoramax.xyz/api/map/14/1/2.mvt'), + ), + 'GET api.panoramax.xyz/api/map/{z}/{x}/{y}.mvt', + ); +}); diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 16e32eec6..0bfa5c2df 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -15,9 +15,9 @@ 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), Google Street View and - * KartaView. Everything from the app's - * own server comes through the MCP server. + * coverage tiles come through the app's server), Google Street View, + * KartaView and Panoramax. Everything from the app's own server comes + * through the MCP server. */ const PROVIDER_ORIGINS = Object.freeze([ 'https://tile.googleapis.com', @@ -49,6 +49,14 @@ const PROVIDER_ORIGINS = Object.freeze([ 'https://api.kartaview.org', 'https://cdn.kartaview.org', 'https://*.openstreetcam.org', + // Panoramax's meta-catalogue, and the federated instances holding most of + // its pictures (a picture on another instance shows only in the app). + 'https://api.panoramax.xyz', + 'https://panoramax.openstreetmap.fr', + 'https://panoramax.ign.fr', + // IGN's pictures redirect to its OVH object storage. + 'https://panoramax-storage-public-fast.s3.gra.perf.cloud.ovh.net', + 'https://panoramax.mapcomplete.org', ]); export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 9ecfd077e..f0b844532 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -133,6 +133,7 @@ function presentViewer(state) { const left = []; if (street.title) left.push(street.title); if (street.creator) left.push(`Image by ${street.creator}`); + if (street.license) left.push(street.license); return { open: street.open === true, loading: street.loading === true && !street.imageId, diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 0e7258978..ea9efee1c 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -7,6 +7,9 @@ /* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */ --sl-kartaview: #635bff; --sl-kartaview-text: #8f8aff; + /* Panoramax's orange; the darker one sits behind white text. */ + --sl-panoramax: #ff6f00; + --sl-panoramax-fill: #b84e00; --sl-text: 0.62rem; --sl-text-small: 0.6rem; } @@ -621,8 +624,11 @@ button.sl-status:focus-visible { font-size: 1.3rem; } -/* ── KartaView photo viewer ──────────────────────────────────────────────── */ -.sl-kv-viewer { +/* ── Photo viewer (KartaView, Panoramax) ─────────────────────────────────── */ +.sl-photo-viewer { + /* The lit source's colour: a focus ring, and behind the white ‹ › glyphs. */ + --sl-photo-ring: var(--sl-kartaview-text); + --sl-photo-fill: color-mix(in srgb, var(--sl-kartaview) 85%, transparent); position: absolute; inset: 0; background: #000; @@ -630,23 +636,29 @@ button.sl-status:focus-visible { overflow: hidden; } -.sl-kv-viewer:focus-visible { +.sl-photo-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 { +.sl-photo-viewer:focus-visible::after { content: ''; position: absolute; inset: 0; z-index: 3; - border: 2px solid var(--sl-kartaview-text); + border: 2px solid var(--sl-photo-ring); pointer-events: none; } -.sl-kv-photo, -.sl-kv-pano { +/* White on #ff6f00 is 2.9:1; the darker orange behind ‹ › is 4.6:1. */ +.sl-photo-viewer[data-provider='panoramax'] { + --sl-photo-ring: var(--sl-panoramax); + --sl-photo-fill: var(--sl-panoramax-fill); +} + +.sl-photo-flat, +.sl-photo-pano { position: absolute; inset: 0; width: 100%; @@ -654,25 +666,25 @@ button.sl-status:focus-visible { user-select: none; } -.sl-kv-photo { +.sl-photo-flat { object-fit: contain; } -.sl-kv-pano { +.sl-photo-pano { cursor: grab; touch-action: none; } -.sl-kv-pano.sl-kv-dragging { +.sl-photo-pano.sl-photo-dragging { cursor: grabbing; } -.sl-kv-photo[hidden], -.sl-kv-pano[hidden] { +.sl-photo-flat[hidden], +.sl-photo-pano[hidden] { display: none; } -.sl-kv-step { +.sl-photo-step { position: absolute; top: 50%; z-index: 2; @@ -688,27 +700,27 @@ button.sl-status:focus-visible { cursor: pointer; } -.sl-kv-step-prev { +.sl-photo-step-prev { left: 6px; } -.sl-kv-step-next { +.sl-photo-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); +.sl-photo-step:hover:not(:disabled), +.sl-photo-step:focus-visible { + background: var(--sl-photo-fill); outline: none; } -.sl-kv-step:disabled { +.sl-photo-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 { +.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-photo-step { display: none; } From bce3044e0c25fb5aaea6146984f768d24275caec Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 13:34:25 +0100 Subject: [PATCH 07/17] =?UTF-8?q?fix(street-level):=20Panoramax=20function?= =?UTF-8?q?al=20QA=20=E2=80=94=20nearest=20lookup,=20line=20cap=20order,?= =?UTF-8?q?=20hostile=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From an adversarial functional QA round against the live API: - The nearest lookup missed pictures looking away from the click (place_position leaves them out at any tolerance): it now runs a bounding-box search beside it and ranks both by distance. A line clicked from high up searches its sequence within height / 100 (50–400 m). - The per-tile line cap kept the newest sequences, not the longest (lineCoverage re-sorted them): providers can now keep their decoded order (`keepOrder`). - A long producer name froze the tab (quadratic email regex): linear check. A malformed link escape no longer fails the lookup; null or string coordinates no longer land at 0,0. - A line with no camera type or day stays drawn under the filters. - A sky click on a line changes nothing (as KartaView), instead of selecting a sequence with no cones. - An unreadable tile reads as such; a failed neighbour z15 tile only thins the cones; unknown or refused ids say the picture is gone. - Shared viewer: the sharp-panorama guard is per open (a late download of the previous photo no longer lets a second one start); an HD-only 360° picture is shrunk or skipped like any original; panorama.js read the bitmap size after closing it, so 2:1 power-of-two panoramas clamped instead of wrapping. - Tests for each, and for the gaps mutation testing found (preferred point, cone order, no reload within drawn tiles, newest-first gate, stale bitmap release, flat upgrade retry). Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 14 +- scripts/qa-street-level.mjs | 2 +- src/layers/streetLevel/lineCoverage.js | 12 +- src/layers/streetLevel/panorama.js | 4 +- src/layers/streetLevel/panorama.test.mjs | 30 +++ src/layers/streetLevel/photoViewer.js | 23 ++- src/layers/streetLevel/photoViewer.test.mjs | 134 +++++++++++++ .../streetLevel/providers/panoramax/decode.js | 8 +- .../providers/panoramax/decode.test.mjs | 18 ++ .../streetLevel/providers/panoramax/index.js | 123 +++++++++--- .../streetLevel/providers/panoramax/policy.js | 42 +++- .../providers/panoramax/policy.test.mjs | 32 +++ .../providers/panoramax/provider.test.mjs | 186 +++++++++++++++++- .../streetLevel/providers/panoramax/source.js | 55 ++++-- .../providers/panoramax/source.test.mjs | 57 +++++- .../streetLevel/providers/panoramax/viewer.js | 36 +++- .../providers/panoramax/viewer.test.mjs | 11 +- src/tooling/streetLevelQa.test.mjs | 10 +- 18 files changed, 699 insertions(+), 98 deletions(-) create mode 100644 src/layers/streetLevel/photoViewer.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7b28ca40b..7cf173b2c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3262,10 +3262,16 @@ density cells, binned to at most 96×96 points a tile (a z0 tile holds filters apply: lines carry their day and camera type, and a cell stays while it holds pictures of the kind asked for (cells are undated). A click on a line asks the core (`actions.openNearest`) for that sequence's picture -nearest the click (`/api/search?place_position…&collections=`, any -direction: `place_fov_tolerance=180`), without framing, then any sequence's -if it has none within 50 m; the filters go to the API (`filter= -field_of_view=360`, `datetime=/..`). Once the picture is on screen +nearest the click, within the camera height / 100 (50–400 m: from high up +a click lands well off a line), without framing, then any sequence's within +50 m. Each lookup is two searches side by side, ranked by distance in the +browser: `place_position` (nearest first, but even at +`place_fov_tolerance=180` it leaves out pictures looking away from the +point) and a bounding box (those too, but in no order); the filters go to +both (`filter=field_of_view=360`, `datetime=/..`). A line is dated +by its sequence's first day, so a sequence still growing can hide under +SINCE; a line with no camera type or day stays drawn. Lines keep the +decoded longest-first order under the per-tile cap (`keepOrder`). Once the picture is on screen its sequence is highlighted and drawn as cones from the z15 tiles around it (their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC item), plus a neighbour within 150 m of an edge; the cones re-centre when a diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 7437b2be8..52251ba2c 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -2809,7 +2809,7 @@ async function main() { ); if (fixtures) await step( - 'every Mapillary and KartaView request was answered from a fixture', + 'every Mapillary, KartaView and Panoramax request was answered from a fixture', () => { console.log( ` (answered: ${[...fixture.calls].sort().join(' | ')})`, diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 85edd97c1..9c363a07e 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -72,6 +72,7 @@ export function meshBoxInRange(box, centre) { * @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. + * @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first). */ /** @@ -559,10 +560,13 @@ export function createLineCoverage({ 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), - ); + // Newest first, unless the provider ordered them itself; the + // per-tile cap applies to what passes the filter. + entry.sequenceList = tuning.keepOrder + ? decoded.sequences + : decoded.sequences.sort( + (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), + ); entry.sequences = new Map(); entry.total = decoded.sequences.length; } else { diff --git a/src/layers/streetLevel/panorama.js b/src/layers/streetLevel/panorama.js index 50f348a9b..72f5718a0 100644 --- a/src/layers/streetLevel/panorama.js +++ b/src/layers/streetLevel/panorama.js @@ -188,13 +188,15 @@ export function createPanoramaRenderer(canvas) { gl.UNSIGNED_BYTE, image, ); + // Read before the close: a closed bitmap measures 0×0. + const { width, height } = 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) + isPowerOfTwo(width) && isPowerOfTwo(height) ? gl.REPEAT : gl.CLAMP_TO_EDGE; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat); diff --git a/src/layers/streetLevel/panorama.test.mjs b/src/layers/streetLevel/panorama.test.mjs index 5bb6380d1..6a0c7c8b8 100644 --- a/src/layers/streetLevel/panorama.test.mjs +++ b/src/layers/streetLevel/panorama.test.mjs @@ -167,3 +167,33 @@ test('an image uploads as RGBA and its bitmap is released; closing hands the tex ); assert.equal(renderer.hasImage(), false); }); + +test('a power-of-two bitmap wraps in longitude even though it is closed once uploaded', (t) => { + const { gl, renderer } = stubPanorama(t); + const wraps = []; + gl.texParameteri = (target, name, value) => wraps.push([name, value]); + gl.TEXTURE_WRAP_S = 'WRAP_S'; + gl.REPEAT = 'REPEAT'; + gl.CLAMP_TO_EDGE = 'CLAMP'; + const bitmap = { + width: 2048, + height: 1024, + // A closed ImageBitmap measures 0×0. + close() { + this.width = 0; + this.height = 0; + }, + }; + renderer.setImage(bitmap); + assert.deepEqual( + wraps.find(([name]) => name === 'WRAP_S'), + ['WRAP_S', 'REPEAT'], + ); + wraps.length = 0; + renderer.setImage({ width: 2000, height: 1000 }); + assert.deepEqual( + wraps.find(([name]) => name === 'WRAP_S'), + ['WRAP_S', 'CLAMP'], + 'other sizes clamp (WebGL 1 repeats powers of two only)', + ); +}); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index 49dac9949..705519446 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -90,8 +90,8 @@ export function panoSizeFor(canvasPixels, fovDeg) { /** * @typedef {object} PhotoSources Where a provider's photo comes from. * @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image. - * @property {(photo: object) => {url: string, width: number}|null} panoFirst - * The panorama shown first, and its texture width (0 when it is only a preview). + * @property {(photo: object) => {url: string, width: number, resize?: [number, number]|null}|null} panoFirst + * The panorama shown first, its texture width (0 when it is only a preview), and a size to shrink it to. * @property {(photo: object, size: [number, number]) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp * A sharper panorama for a canvas that needs `size`, or null. * @property {(photo: object) => Array} flatFirst Flat versions to try in turn. @@ -158,8 +158,8 @@ export function createPhotoViewer({ let restoreView = null; /** Width of the panorama texture on screen (0: the preview). */ let panoWidth = 0; - /** Width of a sharper panorama on its way (resizes come in bursts). */ - let panoComing = 0; + /** The sharper panorama on its way, for which open (resizes come in bursts). */ + let panoComing = null; /** The sharper flat version on screen or on its way (one download each). */ let flatSharpUrl = null; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ @@ -520,10 +520,12 @@ export function createPhotoViewer({ if (kind === 'flat' && target.isPano) return; if (kind === 'pano') { const size = panoSizeNow(); - if (size[0] <= Math.max(panoWidth, panoComing)) return; + if (size[0] <= panoWidth) return; + if (panoComing?.seq === seq && size[0] <= panoComing.width) return; const sharp = sources.panoSharp(target, size); if (!sharp?.url) return; - panoComing = size[0]; + const coming = { seq, width: size[0] }; + panoComing = coming; try { const image = await loadAs(sharp.url, { crossOrigin: true, @@ -544,7 +546,7 @@ export function createPhotoViewer({ } catch { /* the first version stays */ } finally { - if (panoComing === size[0]) panoComing = 0; + if (panoComing === coming) panoComing = null; } return; } @@ -574,7 +576,10 @@ export function createPhotoViewer({ // waiting on it twice. const first = sources.panoFirst(target); if (!first?.url) throw new Error(LOAD_FAILED); - const image = await loadAs(first.url, { crossOrigin: true }); + const image = await loadAs(first.url, { + crossOrigin: true, + resize: first.resize ?? null, + }); if (seq !== openSeq) { image.close?.(); return false; @@ -592,7 +597,7 @@ export function createPhotoViewer({ show('pano'); renderer.setImage(image); panoWidth = first.width || 0; - panoComing = 0; + panoComing = null; return true; } diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs new file mode 100644 index 000000000..5276ea375 --- /dev/null +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPhotoViewer } from './photoViewer.js'; +import { fakeHost, until } from '../../testSupport/kartaViewFakes.mjs'; + +const PHOTOS = { + a: { id: 'a', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true }, + b: { id: 'b', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true }, + f: { id: 'f', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false }, +}; + +/** Sources whose panoramas and sharper flat versions are named after the photo. */ +const SOURCES = { + hasPano: (photo) => photo.isPano, + panoFirst: (photo) => ({ url: `${photo.id}-first`, width: 2048 }), + panoSharp: (photo) => ({ url: `${photo.id}-sharp` }), + flatFirst: (photo) => [`${photo.id}-first`], + flatSharp: (photo, pixels) => (pixels > 1000 ? `${photo.id}-sharp` : null), + describe: () => ({ externalUrl: 'https://example.org' }), +}; + +/** A viewer whose loads wait until `release(url)` (or fail with `fail(url)`). */ +function setup() { + const pending = new Map(); + const loads = []; + const images = []; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + load: (url) => + new Promise((resolve, reject) => { + loads.push(url); + pending.set(url, { resolve, reject }); + }), + createRenderer: () => ({ + setImage: (image) => images.push(image.src), + setView() {}, + getView: () => ({ yaw: 0, pitch: 0, fov: 80 }), + resize() {}, + clear() {}, + destroy() {}, + }), + }); + const release = (url) => { + const closed = { value: false }; + pending.get(url).resolve({ src: url, close: () => (closed.value = true) }); + pending.delete(url); + return closed; + }; + const fail = (url) => { + pending.get(url).reject(new Error('gone')); + pending.delete(url); + }; + return { viewer, loads, images, release, fail, host: fakeHost() }; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const parts = (host) => { + const [picture, canvas] = host.children[0].children; + return { picture, canvas }; +}; + +test('a panorama that arrives after another photo opened is released, not shown', async () => { + const env = setup(); + await env.viewer.mount(env.host); + const first = env.viewer.open('a'); + await tick(); + const second = env.viewer.open('b'); + await tick(); + const stale = env.release('a-first'); + await first; + assert.equal(stale.value, true, 'its bitmap is closed'); + env.release('b-first'); + await second; + assert.deepEqual(env.images, ['b-first']); +}); + +test('one sharp panorama download per open, even across a quick switch and a burst of resizes', async (t) => { + const env = setup(); + t.after(() => delete globalThis.devicePixelRatio); + globalThis.devicePixelRatio = 1; + await env.viewer.mount(env.host); + const { canvas } = parts(env.host); + canvas.clientWidth = 1800; + const opening = env.viewer.open('a'); + await tick(); + env.release('a-first'); + await opening; + await tick(); + assert.ok(env.loads.includes('a-sharp'), 'expanded: the sharp one is asked'); + // Photo b opens while a's sharp version is still on its way. + const next = env.viewer.open('b'); + await tick(); + env.release('b-first'); + await next; + await tick(); + assert.ok(env.loads.includes('b-sharp')); + // a's download finishes late; resizes must not ask for b's again. + env.release('a-sharp'); + await tick(); + env.viewer.resize(); + env.viewer.resize(); + await tick(); + assert.equal(env.loads.filter((url) => url === 'b-sharp').length, 1); + env.release('b-sharp'); + await tick(); + assert.deepEqual(env.images, ['a-first', 'b-first', 'b-sharp']); +}); + +test('a sharper flat version that failed is tried again on the next resize', async (t) => { + const env = setup(); + t.after(() => delete globalThis.devicePixelRatio); + globalThis.devicePixelRatio = 1; + await env.viewer.mount(env.host); + const { picture } = parts(env.host); + picture.clientWidth = 1200; + const opening = env.viewer.open('f'); + await tick(); + env.release('f-first'); + await opening; + await tick(); + env.fail('f-sharp'); + await tick(); + assert.equal(picture.src, 'f-first'); + env.viewer.resize(); + await tick(); + assert.equal(env.loads.filter((url) => url === 'f-sharp').length, 2); + env.release('f-sharp'); + assert.ok(await until(() => picture.src === 'f-sharp')); +}); diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index 02ff0579f..19b470846 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -54,7 +54,13 @@ export function decodeCoverageTile(bytes, address) { id: String(props.id), parts, capturedAt: dayMs(props.date), - isPano: props.type === 'equirectangular', + // A sequence with no camera type is neither (the filters keep it). + isPano: + props.type === 'equirectangular' + ? true + : props.type === 'flat' + ? false + : null, }; lengths.set(sequence, length); result.sequences.push(sequence); diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index 397380dad..4347463b0 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -47,6 +47,24 @@ test('sequence lines keep their parts apart, with their day and camera type', () assert.equal(pano.parts.length, 2, 'the gap is not bridged'); assert.ok(near(pano.parts[1][1][0], lon + 0.001)); assert.equal(flat.isPano, false); + const untyped = decodeCoverageTile( + encodePanoramaxTile(tile, { + sequences: [ + { + id: 'u', + type: '', + parts: [ + [ + [lon, lat], + [lon + 0.001, lat], + ], + ], + }, + ], + }), + tile, + ).sequences[0]; + assert.equal(untyped.isPano, null, 'no camera type: neither'); }); test('density cells bin to a bounded number of points that keep the counts and kinds', () => { diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index cda9015c0..1fa65fd51 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -18,7 +18,10 @@ import { COVERAGE_Z12_BELOW_M, COVERAGE_Z13_BELOW_M, COVERAGE_Z14_BELOW_M, + GONE_MESSAGE, LINE_CLICK_HINT, + LINE_CLICK_RADIUS_MAX_M, + LINE_CLICK_RADIUS_PER_HEIGHT, NEAREST_LIMIT, NEAREST_RADIUS_M, NO_COVERAGE_HINT, @@ -31,13 +34,31 @@ import { PICTURE_TILE_EDGE_M, PICTURE_TILE_ZOOM, RATE_LIMITED_MESSAGE, + UNREADABLE_TILE_MESSAGE, normalizeItem, } from './policy.js'; export { PANORAMAX_PROVIDER_ID } from './policy.js'; +/** + * A tile's bytes decoded by `decode`; a body that is not a vector tile + * (an HTML error page, a cut-off answer) reads as such, not as a parser's + * "Unimplemented type: 4". + */ +async function readable(decode) { + try { + return await decode(); + } catch (error) { + if (error?.name === 'AbortError' || error?.status !== undefined) + throw error; + throw new Error(UNREADABLE_TILE_MESSAGE); + } +} + /** Pictures kept in memory by id (lookups and ‹ › fill it). */ const PHOTO_CACHE_SIZE = 2000; +/** Sequences whose cone centre is remembered. */ +const ANCHOR_CACHE_SIZE = 50; /** * Sequence lines at z11–14 below 30 km; above, the density grid at z5 and @@ -63,17 +84,23 @@ export function panoramaxZoomFor(heightM) { } /** - * The imagery filter for lines (dated, typed) and grid cells (undated, with - * 360° and flat counts): a cell stays while it holds pictures of the kind - * asked for. + * The imagery filter for lines and grid cells. A cell (undated, with 360° + * and flat counts) stays while it holds pictures of the kind asked for. A + * line is dated by its sequence's first day, so a sequence still growing + * may hide under "since" while its newer pictures can be opened; a line + * with no camera type or no day is kept (a click finds out). */ export function passesCoverage(record, filter) { + if (!filter) return true; if ('hasPano' in record) { - if (filter?.pano === 'pano') return record.hasPano; - if (filter?.pano === 'flat') return record.hasFlat; + if (filter.pano === 'pano') return record.hasPano; + if (filter.pano === 'flat') return record.hasFlat; return true; } - return passesImageryFilter(record, filter); + return passesImageryFilter(record, { + pano: typeof record.isPano === 'boolean' ? filter.pano : 'all', + sinceMs: record.capturedAt ? filter.sinceMs : null, + }); } /** Least-recently-used map: reading an entry renews it. */ @@ -193,7 +220,7 @@ export function createPanoramaxProvider({ }; const photos = createLru(PHOTO_CACHE_SIZE); /** Where each sequence's cones centre: the picture last opened in it, or a click. */ - const anchors = new Map(); + const anchors = createLru(ANCHOR_CACHE_SIZE); /** The z15 tiles the cones were drawn from. */ let drawn = null; /** A line click: its sequence's pictures win the next nearest lookup there. */ @@ -215,10 +242,14 @@ export function createPanoramaxProvider({ if (!lookup) { lookup = source .getItem(id) + .catch((error) => { + // Ids are UUIDs: the API answers 400 to anything else. + if (error?.status === 400) return null; + throw error; + }) .then((item) => { const photo = item && remember(item); - if (!photo) - throw new Error('Panoramax has no position for this picture'); + if (!photo) throw new Error(GONE_MESSAGE); return photo; }) .finally(() => inflight.delete(id)); @@ -227,6 +258,19 @@ export function createPanoramaxProvider({ return lookup; } + /** + * How far from a line click its sequence's pictures are looked for: + * from high up the click may land tens of metres off the line. + */ + function lineClickRadius() { + const height = state.viewer?.camera?.positionCartographic?.height; + if (!Number.isFinite(height)) return NEAREST_RADIUS_M; + return Math.min( + LINE_CLICK_RADIUS_MAX_M, + Math.max(NEAREST_RADIUS_M, height * LINE_CLICK_RADIUS_PER_HEIGHT), + ); + } + /** The picture `delta` along its sequence, from its own links (null at an end). */ async function step(photo, delta) { if (!photo?.sequenceId) return null; @@ -236,7 +280,9 @@ export function createPanoramaxProvider({ const coverage = createLineCoverage({ state, fetchTile: async (tile, { signal }) => - decodeCoverageTile(await source.getTile(tile, { signal }), tile), + readable(async () => + decodeCoverageTile(await source.getTile(tile, { signal }), tile), + ), zoomFor: panoramaxZoomFor, colors: COLORS, pickPrefix: PICK_PREFIX.sequence, @@ -252,18 +298,26 @@ export function createPanoramaxProvider({ const anchor = anchors.get(sequenceId); if (!anchor) return []; const tiles = pictureTilesAround(anchor); - const lists = await Promise.all( - tiles.map(async (tile) => - decodePictures( - await source.getTile(tile, { signal }), - tile, - sequenceId, + // The home tile must load; a neighbour that fails only thins the cones. + const lists = await Promise.allSettled( + tiles.map((tile) => + readable(async () => + decodePictures( + await source.getTile(tile, { signal }), + tile, + sequenceId, + ), ), ), ); signal?.throwIfAborted(); + if (lists[0].status === 'rejected') throw lists[0].reason; drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) }; - const byId = new Map(lists.flat().map((image) => [image.id, image])); + const byId = new Map( + lists + .flatMap((list) => list.value ?? []) + .map((image) => [image.id, image]), + ); return thinImages( [...byId.values()].sort((a, b) => a.capturedAt - b.capturedAt), ); @@ -302,14 +356,14 @@ export function createPanoramaxProvider({ ...viewerOptions, }); - /** Nearest of `items` within the search radius of a point, or null. */ - function nearestOf(items, point) { + /** Nearest of `items` within `radius` metres of a point, or null. */ + function nearestOf(items, point, radius = NEAREST_RADIUS_M) { let best = null; for (const item of items) { const photo = remember(item); if (!photo) continue; const metres = metresBetween(point, photo); - if (metres > NEAREST_RADIUS_M) continue; + if (metres > radius) continue; if (!best || metres < best.metres) best = { photo, metres }; } return best?.photo ?? null; @@ -398,13 +452,15 @@ export function createPanoramaxProvider({ const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence); if (!sequenceId) return false; const point = groundPoint?.(); - if (!point) { - // No ground under the click: show the line selected, nothing more. - cones.select(sequenceId); - return true; - } + // No ground under the click (the sky beyond a line): nothing to open. + if (!point) return true; anchors.set(sequenceId, { ...point, sequenceId }); - preferred = { sequenceId, lon: point.lon, lat: point.lat }; + preferred = { + sequenceId, + lon: point.lon, + lat: point.lat, + radius: lineClickRadius(), + }; context.actions.openNearest(point); return true; }, @@ -429,17 +485,16 @@ export function createPanoramaxProvider({ async nearestImage({ lat, lon }, { signal } = {}) { const point = { lat, lon }; const filter = context.getFilter(); + // Only the lookup the click itself asked for (the same point). const wanted = - preferred && metresBetween(preferred, point) < 1 - ? preferred.sequenceId - : null; + preferred && metresBetween(preferred, point) < 1 ? preferred : null; preferred = null; - const ask = (collections) => + const ask = (collections, radius = NEAREST_RADIUS_M) => source.nearestItems( { lat, lon, - radius: NEAREST_RADIUS_M, + radius, limit: NEAREST_LIMIT, collections, pano: filter.pano, @@ -448,7 +503,11 @@ export function createPanoramaxProvider({ { signal }, ); if (wanted) { - const hit = nearestOf(await ask(wanted), point); + const hit = nearestOf( + await ask(wanted.sequenceId, wanted.radius), + point, + wanted.radius, + ); if (hit) return hit.id; } return nearestOf(await ask(null), point)?.id ?? null; diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index b163be640..f7629b5aa 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -41,10 +41,12 @@ export const COVERAGE_LINES_BELOW_M = 30_000; export const COVERAGE_TUNING = Object.freeze({ maxTiles: 9, overviewMaxTiles: 6, - // Each tile draws at most 1,600 lines, its longest first: a dense z11 + // Each tile draws at most 1,600 lines, its longest first (as decoded, + // `keepOrder`): a dense z11 // tile over Paris holds 3,800, and a lower cap left the city centre // looking emptier than its suburbs. (KartaView draws as many.) maxSequences: 14_400, + keepOrder: true, lineWidthPx: 2.5, overviewPointPx: 4, moveDebounceMs: 320, @@ -61,6 +63,9 @@ export const OVERVIEW_BINS_PER_AXIS = 96; export const RATE_LIMITED_MESSAGE = 'Panoramax is busy — coverage resumes on its own'; export const NO_COVERAGE_HINT = 'no Panoramax pictures in view'; +export const UNREADABLE_TILE_MESSAGE = + 'Panoramax sent a map tile that could not be read'; +export const GONE_MESSAGE = 'This Panoramax picture is no longer available'; /** From high up the dots are where pictures are, not sequences to click. */ export const OVERVIEW_HINT = 'zoom in to click a Panoramax sequence'; @@ -79,8 +84,20 @@ export const TILE_CONCURRENCY = 6; /** Pictures asked for around a point; the API returns them nearest first. */ export const NEAREST_LIMIT = 10; -/** Any picture within the radius, whichever way it looks (2–180°). */ +/** + * The widest the API allows: the point may be beside a picture, but a + * picture looking away from it is still left out (the bounding-box search + * beside it has those). + */ export const NEAREST_FOV_TOLERANCE_DEG = 180; +/** Pictures in the bounding box around a click, at most. */ +export const NEAREST_BOX_LIMIT = 100; +/** + * A line clicked from high up may be tens of metres off the click: its + * sequence is searched within the camera height / 100 (50–400 m). + */ +export const LINE_CLICK_RADIUS_PER_HEIGHT = 0.01; +export const LINE_CLICK_RADIUS_MAX_M = 400; /** A full-resolution panorama is only fetched up to this width (memory). */ export const PANO_HD_MAX_WIDTH = 8_192; @@ -147,7 +164,19 @@ function linkedId(item, rel) { const href = item?.links?.find?.((link) => link?.rel === rel)?.href; if (typeof href !== 'string') return null; const id = href.split(/[?#]/)[0].split('/').filter(Boolean).pop(); - return id ? decodeURIComponent(id) : null; + if (!id) return null; + try { + return decodeURIComponent(id); + } catch { + // A malformed escape is no link, not a failed lookup. + return null; + } +} + +/** An email address anywhere in a name (linear: names come from any instance). */ +function looksLikeEmail(name) { + const at = name.indexOf('@'); + return at > 0 && name.indexOf('.', at + 2) !== -1; } /** @@ -165,7 +194,7 @@ function creatorOf(item) { : []), ]; const name = names.find((value) => typeof value === 'string' && value.trim()); - if (!name || /\S+@\S+\.\S+/.test(name)) return null; + if (!name || looksLikeEmail(name)) return null; return name.trim().slice(0, 80); } @@ -184,8 +213,11 @@ export function isEquirectangular(item) { */ export function normalizeItem(item) { if (!item || item.id == null) return null; + // Only numbers: Number(null) and Number('') would put a picture at 0,0. const [lon, lat] = Array.isArray(item.geometry?.coordinates) - ? item.geometry.coordinates.map(Number) + ? item.geometry.coordinates.map((value) => + typeof value === 'number' ? value : NaN, + ) : [NaN, NaN]; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; diff --git a/src/layers/streetLevel/providers/panoramax/policy.test.mjs b/src/layers/streetLevel/providers/panoramax/policy.test.mjs index 5d93d7956..605bcf7ad 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/policy.test.mjs @@ -193,3 +193,35 @@ test('the caption link opens the picture in the Panoramax viewer', () => { assert.equal(panoramaxPictureUrl(null), 'https://api.panoramax.xyz/'); assert.ok(panoramaxPictureUrl('a&b=c').includes('pic=a%26b%3Dc')); }); + +test('hostile item data: a malformed link escape, null coordinates, a huge producer name', () => { + const item = stacItem({ id: 'a', lon: 1, lat: 2, nextId: 'n' }); + item.links.push({ + rel: 'prev', + href: 'https://api.panoramax.xyz/api/collections/s/items/%E0%A4%A', + }); + const photo = normalizeItem(item); + assert.equal(photo.prevId, null, 'no link, no throw'); + assert.equal(photo.nextId, 'n'); + for (const coordinates of [[null, null], ['', ''], ['2.35', '48.85'], [2.35]]) + assert.equal( + normalizeItem({ id: 'x', geometry: { coordinates } }), + null, + JSON.stringify(coordinates), + ); + // A million characters without spaces is checked in linear time. + const started = Date.now(); + const huge = normalizeItem( + stacItem({ id: 'a', lon: 1, lat: 2, producer: 'a'.repeat(1_000_000) }), + ); + assert.ok(Date.now() - started < 500, `${Date.now() - started} ms`); + assert.equal(huge.creator.length, 80); + const named = (producer) => + normalizeItem(stacItem({ id: 'a', lon: 1, lat: 2, producer })).creator; + assert.equal(named(`${'x'.repeat(200_000)}@example.org`), null); + assert.equal( + named('Carto@Cité'), + 'Carto@Cité', + 'an @ without a domain is a name', + ); +}); diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index e0d5eae61..2ae2c8091 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -160,6 +160,20 @@ test('lines at z11–14 below 30 km; density points from z5 down to z0 above', ( assert.equal(panoramaxZoomFor(NaN), null); }); +test('a line with no camera type or no day passes the filters: a click finds out', () => { + const filter = { pano: 'pano', sinceMs: Date.UTC(2024, 0, 1) }; + assert.equal( + passesCoverage({ isPano: null, capturedAt: Date.UTC(2025, 0, 1) }, filter), + true, + ); + assert.equal(passesCoverage({ isPano: true, capturedAt: 0 }, filter), true); + assert.equal(passesCoverage({ isPano: false, capturedAt: 0 }, filter), false); + assert.equal( + passesCoverage({ isPano: null, capturedAt: Date.UTC(2020, 0, 1) }, filter), + false, + ); +}); + test('the imagery filter: lines by type and day, density cells by what they hold', () => { const old = Date.UTC(2020, 0, 1); const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) }; @@ -314,9 +328,25 @@ test('one lookup for a picture asked twice at once', async () => { assert.deepEqual(source.calls.items, ['s-0']); }); -test('a picture Panoramax does not know fails the open with a reason', async () => { +test('a picture Panoramax does not know, or an id it refuses, fails the open with a reason', async () => { const env = await mounted({ items: [] }); - await assert.rejects(env.instance.viewer.open('ghost'), /no position/); + await assert.rejects( + env.instance.viewer.open('ghost'), + /no longer available/, + ); + env.source.getItem = async () => { + throw Object.assign(new Error('Panoramax answered HTTP 400'), { + status: 400, + }); + }; + await assert.rejects( + env.instance.viewer.open('not-a-uuid'), + /no longer available/, + ); + env.source.getItem = async () => { + throw Object.assign(new Error('Panoramax is busy'), { status: 503 }); + }; + await assert.rejects(env.instance.viewer.open('busy'), /busy/); }); /** Cones on a stand-in Cesium viewer; `count()` reads how many are drawn. */ @@ -340,7 +370,13 @@ async function withCones(t, env) { }, camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, }); - return { count: () => collection?.length ?? 0 }; + return { + count: () => collection?.length ?? 0, + ids: () => + Array.from({ length: collection?.length ?? 0 }, (_, i) => + String(collection.get(i).id).replace(PICK_PREFIX.image, ''), + ), + }; } /** z15 tiles holding every item of `items` as a picture. */ @@ -379,18 +415,148 @@ test('once a picture is on screen its sequence’s cones load from the z15 tile assert.ok(env.source.calls.tiles.every((key) => key.startsWith('15/'))); }); -test('a sequence selected with no picture or click to centre on draws no cones', async (t) => { +test('a click on a line with no ground under it changes nothing: the open sequence keeps its cones', async (t) => { + const items = [...sequence('x', 6), ...sequence('y', 3, { offset: 3 })]; + const env = await mounted({ items, tiles: pictureTiles(items) }); + const cones = await withCones(t, env); + await env.instance.viewer.open('x-1'); + await env.instance.selectSequence('x'); + assert.ok(await until(() => cones.count() === 6)); + const asked = env.source.calls.tiles.length; + assert.equal( + env.instance.handlePick(`${PICK_PREFIX.sequence}y`, { + groundPoint: () => null, + }), + true, + 'the click is the provider’s, even with nothing to open', + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(env.instance.sequenceStats().selectedId, 'x'); + assert.equal(cones.count(), 6); + assert.equal(env.source.calls.tiles.length, asked); + assert.deepEqual(env.actions.nearest, []); +}); + +test('a sequence selected with nothing to centre on draws no cones, and a picture opening in it then does not throw', async (t) => { const items = sequence('s', 3); const env = await mounted({ items, tiles: pictureTiles(items) }); await withCones(t, env); - env.instance.handlePick(`${PICK_PREFIX.sequence}s`, { - groundPoint: () => null, - }); - assert.ok(await until(() => !env.instance.sequenceStats().loading)); - assert.equal(env.instance.sequenceStats().selectedId, 's', 'highlighted'); + await env.instance.selectSequence('s'); assert.equal(env.instance.sequenceStats().images, 0); assert.deepEqual(env.source.calls.tiles, []); - assert.deepEqual(env.actions.nearest, []); + // Its first picture opens: the cones load around it. + await env.instance.viewer.open('s-0'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.instance.sequenceStats().images === 3)); +}); + +test('a line clicked from high up looks for its sequence further off; the radius grows with height, up to 400 m', async () => { + const items = [ + ...sequence('near', 1), + ...sequence('line', 1, { offset: 220 }), + ]; + const { instance, source } = setup({ items }); + const height = (h) => + instance.init({ + camera: { positionCartographic: { height: h } }, + scene: { + primitives: { add() {}, remove() {} }, + preRender: { addEventListener: () => () => {} }, + }, + }); + const click = () => + instance.handlePick(`${PICK_PREFIX.sequence}line`, { + groundPoint: () => east(0), + }); + height(25_000); + click(); + assert.equal( + await instance.nearestImage(east(0)), + 'line-0', + '220 m off, from 25 km', + ); + assert.equal(source.calls.nearest.at(-1).radius, 250); + height(1_000); + click(); + assert.equal( + await instance.nearestImage(east(0)), + 'near-0', + 'from 1 km only 50 m: any sequence’s nearer picture', + ); + height(900_000); + click(); + await instance.nearestImage(east(0)); + assert.equal( + source.calls.nearest.filter((q) => q.collections === 'line').at(-1).radius, + 400, + 'capped', + ); +}); + +test('a lookup at another point does not take a line click’s sequence', async () => { + const items = [ + ...sequence('other', 1), + ...sequence('clicked', 1, { offset: 8 }), + ]; + const { instance, source } = setup({ items }); + instance.handlePick(`${PICK_PREFIX.sequence}clicked`, { + groundPoint: () => east(1), + }); + // OPEN at the view centre, a few metres away, overtakes the click. + assert.equal(await instance.nearestImage(east(3)), 'other-0'); + assert.deepEqual( + source.calls.nearest.map((q) => q.collections), + [null], + ); +}); + +test('the cones come in capture order, and a broken neighbour tile only thins them', async (t) => { + const items = sequence('s', 5).map((item, i) => ({ + ...item, + properties: { + ...item.properties, + // Captured in reverse of their position along the street. + datetime: `2025-06-01T10:00:0${9 - i}+00:00`, + }, + })); + const tiles = pictureTiles(items); + const env = await mounted({ items, tiles }); + const cones = await withCones(t, env); + await env.instance.viewer.open('s-0'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => cones.count() === 5)); + assert.deepEqual(cones.ids(), ['s-4', 's-3', 's-2', 's-1', 's-0']); + // A neighbour answers with garbage: the home tile's cones still draw. + const home = new Set(); + env.source.getTile = async (tile) => { + const key = `${tile.z}/${tile.x}/${tile.y}`; + if (!home.size) home.add(key); + if (!home.has(key)) return new TextEncoder().encode('oops'); + return tiles(tile); + }; + await env.instance.viewer.open('s-0'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + // The home tile itself unreadable: a readable error, not a parser's. + env.source.getTile = async () => + new TextEncoder().encode('oops'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.actions.errors.some(Boolean))); + assert.match(env.actions.errors.filter(Boolean).at(-1), /could not be read/); +}); + +test('› within the tiles the cones came from reloads nothing', async (t) => { + const items = sequence('s', 4); + const env = await mounted({ items, tiles: pictureTiles(items) }); + await withCones(t, env); + await env.instance.viewer.open('s-0'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.instance.sequenceStats().images === 4)); + const asked = env.source.calls.tiles.length; + env.next.fire('click'); + assert.ok(await until(() => env.poses.at(-1) === 's-1')); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(env.source.calls.tiles.length, asked); }); test('the cones re-centre when › walks into another z15 tile', async (t) => { diff --git a/src/layers/streetLevel/providers/panoramax/source.js b/src/layers/streetLevel/providers/panoramax/source.js index 3ddb6e1e9..7d3c86e6c 100644 --- a/src/layers/streetLevel/providers/panoramax/source.js +++ b/src/layers/streetLevel/providers/panoramax/source.js @@ -3,6 +3,7 @@ import { API_CONCURRENCY, API_TIMEOUT_MS, MAX_RESPONSE_BYTES, + NEAREST_BOX_LIMIT, NEAREST_FOV_TOLERANCE_DEG, NEAREST_LIMIT, NEAREST_RADIUS_M, @@ -151,9 +152,13 @@ export function createPanoramaxSource({ }, /** - * Pictures within `radius` metres of a point, nearest first, whichever - * way they look; `collections` keeps one sequence's, `pano` and - * `sinceMs` filter as the imagery filter does. + * Pictures within `radius` metres of a point, unranked: the caller ranks + * them by distance. Two searches side by side, since neither finds them + * all: `place_position` (nearest first) leaves out pictures looking + * away from the point, whatever the tolerance; a bounding box has them + * but in no order, so in a dense spot its page may miss the nearest. + * `collections` keeps one sequence's, `pano` and `sinceMs` filter as + * the imagery filter does. */ async nearestItems( { @@ -167,18 +172,40 @@ export function createPanoramaxSource({ }, { signal } = {}, ) { - const params = { - place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`, - place_distance: `0-${Math.min(1000, Math.max(1, Math.round(radius)))}`, - place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG), - limit: String(Math.min(100, Math.max(1, limit))), - }; - if (collections) params.collections = String(collections); - if (pano === 'pano') params.filter = 'field_of_view=360'; - else if (pano === 'flat') params.filter = 'field_of_view<360'; + const metres = Math.min(1000, Math.max(1, Math.round(radius))); + const shared = {}; + if (collections) shared.collections = String(collections); + if (pano === 'pano') shared.filter = 'field_of_view=360'; + else if (pano === 'flat') shared.filter = 'field_of_view<360'; if (Number.isFinite(sinceMs) && sinceMs > 0) - params.datetime = `${new Date(sinceMs).toISOString()}/..`; - return search(params, { signal }); + shared.datetime = `${new Date(sinceMs).toISOString()}/..`; + const dLat = metres / 111_320; + const dLon = + metres / (111_320 * Math.max(0.01, Math.cos((lat * Math.PI) / 180))); + const box = [lon - dLon, lat - dLat, lon + dLon, lat + dLat].map((v) => + v.toFixed(7), + ); + const [facing, around] = await Promise.all([ + search( + { + place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`, + place_distance: `0-${metres}`, + place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG), + limit: String(Math.min(100, Math.max(1, limit))), + ...shared, + }, + { signal }, + ), + search( + { bbox: box.join(','), limit: String(NEAREST_BOX_LIMIT), ...shared }, + { signal }, + ), + ]); + const byId = new Map(); + for (const item of [...facing, ...around]) + if (item?.id != null && !byId.has(String(item.id))) + byId.set(String(item.id), item); + return [...byId.values()]; }, }; } diff --git a/src/layers/streetLevel/providers/panoramax/source.test.mjs b/src/layers/streetLevel/providers/panoramax/source.test.mjs index 7f2e87237..a068a3eff 100644 --- a/src/layers/streetLevel/providers/panoramax/source.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/source.test.mjs @@ -101,7 +101,7 @@ test('a picture by id is a STAC search; another id in the answer is not taken fo assert.equal(asked.searchParams.get('limit'), '1'); }); -test('nearest pictures: any direction within the radius, filtered as the imagery filter asks', async () => { +test('nearest pictures: a place search and a box search side by side, both filtered as the imagery filter asks', async () => { const { urls, fetchImpl } = fakeFetch(() => json({ features: [] })); const source = createPanoramaxSource({ fetchImpl }); await source.nearestItems({ lat: 48.85, lon: 2.35 }); @@ -115,7 +115,13 @@ test('nearest pictures: any direction within the radius, filtered as the imagery limit: 500, }); await source.nearestItems({ lat: 48.85, lon: 2.35, pano: 'flat' }); - const [plain, filtered, flat] = urls.map((u) => new URL(u).searchParams); + const asked = urls.map((u) => new URL(u).searchParams); + assert.equal(asked.length, 6, 'two searches a lookup'); + const place = asked.filter((q) => q.has('place_position')); + const box = asked.filter((q) => q.has('bbox')); + assert.equal(place.length, 3); + assert.equal(box.length, 3); + const [plain, filtered, flat] = place; assert.equal(plain.get('place_position'), '2.3500000,48.8500000'); assert.equal(plain.get('place_distance'), '0-50'); assert.equal(plain.get('place_fov_tolerance'), '180'); @@ -128,6 +134,53 @@ test('nearest pictures: any direction within the radius, filtered as the imagery assert.equal(filtered.get('place_distance'), '0-1000', 'capped'); assert.equal(filtered.get('limit'), '100', 'capped'); assert.equal(flat.get('filter'), 'field_of_view<360'); + // The box spans the radius each way, and carries the same filters. + const [w, s, e, n] = box[0].get('bbox').split(',').map(Number); + assert.ok(Math.abs((n - s) * 111_320 - 100) < 1, `${n - s}°`); + assert.ok( + Math.abs((e - w) * 111_320 * Math.cos((48.85 * Math.PI) / 180) - 100) < 1, + ); + assert.equal(box[1].get('collections'), 'seq'); + assert.equal(box[1].get('filter'), 'field_of_view=360'); + assert.equal(box[1].get('datetime'), '2025-01-01T00:00:00.000Z/..'); + assert.equal(box[2].get('filter'), 'field_of_view<360'); +}); + +test('a picture both searches find is listed once', async () => { + const source = createPanoramaxSource({ + fetchImpl: fakeFetch((url) => + url.includes('bbox') + ? json({ features: [{ id: 'b' }, { id: 'a' }, { id: null }] }) + : json({ features: [{ id: 'a' }] }), + ).fetchImpl, + }); + const items = await source.nearestItems({ lat: 1, lon: 2 }); + assert.deepEqual( + items.map((i) => i.id), + ['a', 'b'], + ); +}); + +test('the newest search goes first when the API gate is full', async () => { + const waiting = []; + const order = []; + const source = createPanoramaxSource({ + fetchImpl: async (url) => { + order.push(new URL(url).searchParams.get('ids')); + await new Promise((resolve) => waiting.push(resolve)); + return new Response(JSON.stringify({ features: [] })); + }, + }); + const all = ['1', '2', '3', '4', '5', '6'].map((id) => source.getItem(id)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(order, ['1', '2', '3', '4'], 'four at once'); + waiting.shift()(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(order.at(-1), '6', 'the latest waiting one next'); + while (waiting.length) waiting.shift()(); + await new Promise((resolve) => setTimeout(resolve, 0)); + while (waiting.length) waiting.shift()(); + await Promise.all(all); }); test('an unreadable search answer, or one with no features, is handled', async () => { diff --git a/src/layers/streetLevel/providers/panoramax/viewer.js b/src/layers/streetLevel/providers/panoramax/viewer.js index df666237b..90ef88c97 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.js +++ b/src/layers/streetLevel/providers/panoramax/viewer.js @@ -1,4 +1,8 @@ -import { createPhotoViewer, loadImage } from '../../photoViewer.js'; +import { + PANO_FULL_SIZE, + createPhotoViewer, + loadImage, +} from '../../photoViewer.js'; import { HD_TIMEOUT_MS, PANORAMAX_NAME, @@ -8,6 +12,13 @@ import { panoramaxPictureUrl, } from './policy.js'; +/** An HD original small enough to decode (its width is known and under the cap). */ +function hdDecodable(photo) { + return Boolean( + photo.urls.hd && photo.width && photo.width <= PANO_HD_MAX_WIDTH, + ); +} + /** * Where Panoramax pictures come from: the instance holding each one serves * a 2,048 px SD derivative (equirectangular for a 360° picture) and the HD @@ -16,11 +27,15 @@ import { * @type {import('../../photoViewer.js').PhotoSources} */ export const PANORAMAX_SOURCES = Object.freeze({ - hasPano: (photo) => Boolean(photo.isPano && (photo.urls.sd || photo.urls.hd)), - panoFirst: (photo) => - photo.urls.sd - ? { url: photo.urls.sd, width: SD_WIDTH } - : { url: photo.urls.hd, width: photo.width || SD_WIDTH }, + hasPano: (photo) => + Boolean(photo.isPano && (photo.urls.sd || hdDecodable(photo))), + // Without an SD derivative the original goes straight to the texture size. + panoFirst: (photo) => { + if (photo.urls.sd) return { url: photo.urls.sd, width: SD_WIDTH }; + if (!hdDecodable(photo)) return null; + const width = Math.min(photo.width, PANO_FULL_SIZE[0]); + return { url: photo.urls.hd, width, resize: [width, width / 2] }; + }, panoSharp: (photo, size) => { const width = photo.width || 0; if (!photo.urls.hd || !photo.urls.sd) return null; @@ -34,8 +49,13 @@ export const PANORAMAX_SOURCES = Object.freeze({ timeoutMs: HD_TIMEOUT_MS, }; }, - // A 360° picture shown flat (no WebGL) is its whole equirectangular image. - flatFirst: (photo) => [photo.urls.sd, photo.urls.hd, photo.urls.thumb], + // A 360° picture shown flat (no WebGL) is its whole equirectangular image; + // an original too big to decode is skipped for the thumbnail. + flatFirst: (photo) => [ + photo.urls.sd, + !photo.isPano || hdDecodable(photo) ? photo.urls.hd : null, + photo.urls.thumb, + ], flatSharp: (photo, pixels) => photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null, describe: (photo) => ({ diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs index d1ec2732e..3dd5e7a1b 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -143,6 +143,7 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09 assert.deepEqual(loads[0], { url: PHOTOS.pano.urls.sd, crossOrigin: true, + resize: null, failure: 'The Panoramax photo could not be loaded', }); assert.equal(poses.at(-1).isPano, true); @@ -179,8 +180,16 @@ test('HD panoramas too big to decode, or no bigger than SD, are never fetched', const hdOnly = { ...PHOTOS.pano, urls: { ...PHOTOS.pano.urls, sd: null } }; assert.deepEqual(PANORAMAX_SOURCES.panoFirst(hdOnly), { url: hdOnly.urls.hd, - width: 5760, + width: 4096, + resize: [4096, 2048], }); + // …unless it is too big to decode: then no panorama, and the thumbnail. + const hugeOnly = { ...hdOnly, width: 11_904 }; + assert.equal(PANORAMAX_SOURCES.panoFirst(hugeOnly), null); + assert.equal(PANORAMAX_SOURCES.hasPano(hugeOnly), false); + assert.deepEqual(PANORAMAX_SOURCES.flatFirst(hugeOnly).filter(Boolean), [ + hugeOnly.urls.thumb, + ]); assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size), null); assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null); assert.equal( diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 354ca3edf..c4eb22dd3 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -755,12 +755,10 @@ test('Panoramax fixtures: two sequences on one street, searches honour sequence panoramaxTile, resetPanoramaxFixture, } = await import('../../scripts/fixtures/street-level/panoramax.mjs'); - const { decodeCoverageTile, decodePictures } = await import( - '../layers/streetLevel/providers/panoramax/decode.js' - ); - const { normalizeItem } = await import( - '../layers/streetLevel/providers/panoramax/policy.js' - ); + const { decodeCoverageTile, decodePictures } = + await import('../layers/streetLevel/providers/panoramax/decode.js'); + const { normalizeItem } = + await import('../layers/streetLevel/providers/panoramax/policy.js'); resetPanoramaxFixture(); const pictures = panoramaxPictures(); assert.deepEqual( From 552666689453e03976af4be55a57e477e26b0691 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 14:37:45 +0100 Subject: [PATCH 08/17] =?UTF-8?q?fix(street-level):=20Panoramax=20UX=20QA?= =?UTF-8?q?=20=E2=80=94=20connection=20budget,=20coverage=20to=2060=20km,?= =?UTF-8?q?=20Esc,=20attribution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a UX QA round in the browser against the live API: - A line click could time out while coverage loaded: the meta-catalogue speaks HTTP/1.1 only (six connections), and six tiles plus four searches queued the lookup behind megabyte tiles. Tiles now take three connections and searches two; a search that timed out is asked once more. - Between 30 and ~100 km Paris read as empty: the density grid is ~10 km a cell. Lines now draw to 60 km (z10), as Mapillary's do; above, dots grow with the pictures they stand for. - Esc in the docked viewer collapsed the whole panel instead of clearing the sequence the panel says it clears (shared photo viewer: KartaView too). The viewer now offers Esc to the provider first; expanded, the dialog keeps it. - A picture whose producer is an email address is credited to the instance holding it (its `via` host), so Licence Ouverte pictures name their source. - Empty states say why: "no Panoramax photos here match the filters" when the filter hid every line, and nothing to zoom into over empty sea. Wording follows KartaView's ("photos", "click a line for its nearest photo"). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- docs/CURRENT-STATE.md | 7 +- scripts/qa-street-level.mjs | 2 +- src/layers/streetLevel/lineCoverage.js | 4 +- src/layers/streetLevel/photoViewer.js | 14 +++ src/layers/streetLevel/photoViewer.test.mjs | 32 +++++++ .../streetLevel/providers/kartaview/index.js | 7 ++ .../streetLevel/providers/panoramax/index.js | 48 ++++++++--- .../streetLevel/providers/panoramax/policy.js | 46 +++++++--- .../providers/panoramax/policy.test.mjs | 15 ++++ .../providers/panoramax/provider.test.mjs | 86 ++++++++++++++++++- .../streetLevel/providers/panoramax/source.js | 21 +++-- .../providers/panoramax/source.test.mjs | 76 ++++++++++++++-- 14 files changed, 317 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c347fa44..61acfa6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,7 +97,7 @@ - Street Level: Panoramax is the fourth provider, keyless. Light its chip (orange; off by default, one provider at a time): from orbit dots show - where the federated commons has pictures, and below about 30 km its + where the federated commons has pictures, and below about 60 km its sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it and ‹ › / diff --git a/README.md b/README.md index 8fef6c826..100e156b4 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**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. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 30 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's author and licence (mostly CC BY-SA 4.0; IGN's are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. 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. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's author and licence (mostly CC BY-SA 4.0; IGN's are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. 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, `0.m.0_0.r.panoramax-1` Panoramax 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`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7cf173b2c..2b389b675 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3254,9 +3254,10 @@ each picture is served by the instance holding it (OpenStreetMap France, IGN, MapComplete…; IGN's redirect to its OVH object storage). Its chip (orange, `#ff6f00`, Panoramax's own) starts off. Coverage comes from its vector tiles (`/api/map/{z}/{x}/{y}.mvt`, an hour's HTTP cache): sequence -lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km and z11 -to 30 km (a dense z11 tile over Paris is ~1.4 MB and 3,800 sequences; each -tile draws its 1,600 longest), and above 30 km the `grid` layer's 0.1° +lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km, z11 +to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is +~2.1 MB and 8,600 sequences; each tile draws its 1,600 longest), and above +60 km the `grid` layer's 0.1° density cells, binned to at most 96×96 points a tile (a z0 tile holds ~70,000 cells) and not counted as sequences: the panel says to zoom in. Both filters apply: lines carry their day and camera type, and a cell stays while diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 52251ba2c..f230fbdc8 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1200,7 +1200,7 @@ async function main() { ); assert.match( await page.$eval('#sl-coverage-meta', (node) => node.textContent), - /sequences in view · click a line for the photo there/, + /sequences in view · click a line for its nearest photo/, ); await page.waitForFunction( () => diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 9c363a07e..1a8d73b6f 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -95,6 +95,7 @@ export function meshBoxInRange(box, centre) { * @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. + * @param {(point: object) => number} [options.overviewPointSize] An overview point's size in pixels (default `tuning.overviewPointPx`). */ export function createLineCoverage({ state, @@ -106,6 +107,7 @@ export function createLineCoverage({ tuning, messages, passes = passesImageryFilter, + overviewPointSize = null, }) { const { render } = state.services; const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles); @@ -427,7 +429,7 @@ export function createLineCoverage({ collection.add({ position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), color: tint, - pixelSize: tuning.overviewPointPx, + pixelSize: overviewPointSize?.(point) ?? 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, diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index 705519446..bb1e28dde 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -117,6 +117,7 @@ export function panoSizeFor(canvasPixels, fovDeg) { * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). * @param {(photo: object) => void} [options.onShown] A photo is on screen. + * @param {() => boolean} [options.onEscape] Esc in the docked viewer: true when it was used (e.g. a selection cleared). * @param {object} [options.render] Render governor. * @param {(url: string, options?: object) => Promise} [options.load] * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] @@ -130,6 +131,7 @@ export function createPhotoViewer({ step, onStep, onShown = () => {}, + onEscape = () => false, render, load = loadImage, createRenderer = createPanoramaRenderer, @@ -417,6 +419,18 @@ export function createPhotoViewer({ event.metaKey ) return; + // Docked, Esc is the viewer's before the panel's (which would collapse); + // expanded, the dialog's own Esc shrinks it. + if ( + event.key === 'Escape' && + !event.shiftKey && + !root?.closest?.('.sl-viewer-wrap-expanded') && + onEscape() + ) { + event.preventDefault(); + event.stopPropagation(); + return; + } const pano = showing === 'pano' && renderer; const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key]; // Shift+← → look around a panorama; ← → step along the sequence. diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 5276ea375..704a2f98c 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -132,3 +132,35 @@ test('a sharper flat version that failed is tried again on the next resize', asy env.release('f-sharp'); assert.ok(await until(() => picture.src === 'f-sharp')); }); + +test('Esc: the docked viewer offers it to the provider first; expanded, it is the dialog’s', async () => { + let asked = 0; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + onEscape: () => ++asked > 0, + load: async (url) => ({ src: url }), + }); + const host = fakeHost(); + await viewer.mount(host); + const root = host.children[0]; + const press = () => { + const event = { key: 'Escape', prevented: false, stopped: false }; + event.preventDefault = () => (event.prevented = true); + event.stopPropagation = () => (event.stopped = true); + root.fire('keydown', event); + return event; + }; + const docked = press(); + assert.equal(asked, 1); + assert.equal(docked.prevented && docked.stopped, true); + root.closest = (selector) => + selector.includes('sl-viewer-wrap-expanded') ? {} : null; + const expanded = press(); + assert.equal(asked, 1, 'not asked while expanded'); + assert.equal(expanded.prevented, false); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 07e9672cf..1b377479d 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -406,6 +406,13 @@ export function createKartaViewProvider({ step, onStep: openStepped, onShown: recentreCones, + // Esc in the viewer clears the selected drive, as the panel says. + onEscape: () => { + if (!state.sequence.selectedId) return false; + cones.clearSelection(); + drawnPage = null; + return true; + }, render: context.services?.render, ...viewerOptions, }); diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index 1fa65fd51..b6eac8297 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -15,6 +15,7 @@ import { COLORS, COVERAGE_LINES_BELOW_M, COVERAGE_TUNING, + COVERAGE_Z11_BELOW_M, COVERAGE_Z12_BELOW_M, COVERAGE_Z13_BELOW_M, COVERAGE_Z14_BELOW_M, @@ -24,6 +25,7 @@ import { LINE_CLICK_RADIUS_PER_HEIGHT, NEAREST_LIMIT, NEAREST_RADIUS_M, + FILTERED_HINT, NO_COVERAGE_HINT, OVERVIEW_HINT, PANORAMAX_CREDIT_HTML, @@ -55,13 +57,22 @@ async function readable(decode) { } } +/** + * A density dot's size: busier places draw bigger (2.5 px for one picture, + * 8 px from a few hundred thousand), so a city does not read like farmland. + */ +export function overviewPointSize(point) { + const weight = Math.max(1, Number(point?.weight) || 1); + return Math.min(8, 2.5 + Math.log10(weight)); +} + /** Pictures kept in memory by id (lookups and ‹ › fill it). */ const PHOTO_CACHE_SIZE = 2000; /** Sequences whose cone centre is remembered. */ const ANCHOR_CACHE_SIZE = 50; /** - * Sequence lines at z11–14 below 30 km; above, the density grid at z5 and + * Sequence lines at z10–14 below 60 km; above, the density grid at z5 and * coarser. */ export function panoramaxZoomFor(heightM) { @@ -74,7 +85,9 @@ export function panoramaxZoomFor(heightM) { ? 13 : heightM <= COVERAGE_Z12_BELOW_M ? 12 - : 11; + : heightM <= COVERAGE_Z11_BELOW_M + ? 11 + : 10; return { zoom, kind: 'sequence' }; } return { @@ -290,6 +303,7 @@ export function createPanoramaxProvider({ tuning: COVERAGE_TUNING, messages: { rateLimited: RATE_LIMITED_MESSAGE }, passes: passesCoverage, + overviewPointSize, }); const cones = createSequenceCones({ @@ -352,6 +366,13 @@ export function createPanoramaxProvider({ onStep: (photoId) => context.actions.openImage(photoId, { frame: false }), onShown: recentreCones, + // Esc in the viewer clears the selected sequence, as the panel says. + onEscape: () => { + if (!state.sequence.selectedId) return false; + cones.clearSelection(); + drawn = null; + return true; + }, render: context.services?.render, ...viewerOptions, }); @@ -417,17 +438,22 @@ export function createPanoramaxProvider({ }, coverageStats() { - const lines = state.coverage.kind === 'sequence'; + const { kind } = state.coverage; + const lines = kind === 'sequence'; + const drawn = coverage.sequenceCount(); // Density dots are not sequences: they are not counted as such. - const count = lines ? coverage.sequenceCount() : 0; + const count = lines ? drawn : 0; const loading = state.coverage.pending.size > 0; - const empty = - lines && !loading && count === 0 && !state.coverage.lastError; - const fallback = empty - ? NO_COVERAGE_HINT - : state.coverage.kind === 'overview' - ? OVERVIEW_HINT - : ''; + const settled = !loading && !state.coverage.lastError; + // Lines in view that the filter hid all of: say so, not "none here". + const filteredOut = + lines && + [...state.coverage.tiles.values()].some((tile) => tile.total > 0); + let fallback = ''; + if (kind === 'overview') + fallback = drawn > 0 || !settled ? OVERVIEW_HINT : NO_COVERAGE_HINT; + else if (lines && settled && count === 0) + fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT; return { count, zoom: state.coverage.zoom, diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index f7629b5aa..0a122242f 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -29,15 +29,17 @@ export const COLORS = Object.freeze({ }); /** - * Sequence lines come from z7–15 tiles; z11–14 are drawn (a dense z11 tile - * over Paris is ~1.4 MB and 3,800 sequences, z12 ~800 kB). Higher up, the - * z0–5 `grid` layer's density cells (0.1°, ~10 km) draw as points: below - * 30 km they would be a few dots over a whole city. + * Sequence lines come from z7–15 tiles; z10–14 are drawn below 60 km, as + * Mapillary's are (a dense tile over Paris: z10 ~2.1 MB and 8,600 + * sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up, the z0–5 `grid` layer's + * density cells (0.1°, ~10 km) draw as points: lower down they would be a + * handful of dots over a whole city, which reads as no coverage. */ export const COVERAGE_Z14_BELOW_M = 1_800; export const COVERAGE_Z13_BELOW_M = 5_000; export const COVERAGE_Z12_BELOW_M = 12_000; -export const COVERAGE_LINES_BELOW_M = 30_000; +export const COVERAGE_Z11_BELOW_M = 30_000; +export const COVERAGE_LINES_BELOW_M = 60_000; export const COVERAGE_TUNING = Object.freeze({ maxTiles: 9, overviewMaxTiles: 6, @@ -62,12 +64,14 @@ export const OVERVIEW_BINS_PER_AXIS = 96; export const RATE_LIMITED_MESSAGE = 'Panoramax is busy — coverage resumes on its own'; -export const NO_COVERAGE_HINT = 'no Panoramax pictures in view'; +export const NO_COVERAGE_HINT = 'no Panoramax photos in view'; +/** Lines in view, all hidden by the imagery filter. */ +export const FILTERED_HINT = 'no Panoramax photos here match the filters'; export const UNREADABLE_TILE_MESSAGE = 'Panoramax sent a map tile that could not be read'; export const GONE_MESSAGE = 'This Panoramax picture is no longer available'; /** From high up the dots are where pictures are, not sequences to click. */ -export const OVERVIEW_HINT = 'zoom in to click a Panoramax sequence'; +export const OVERVIEW_HINT = 'zoom in to see Panoramax sequences'; /** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */ export const PICTURE_TILE_ZOOM = 15; @@ -78,9 +82,13 @@ export const TILE_TIMEOUT_MS = 20_000; export const API_TIMEOUT_MS = 12_000; /** Real tiles are under 6 MB (z6 over France); refuse anything absurd. */ export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; -/** Requests at once to the API (tiles have their own budget). */ -export const API_CONCURRENCY = 4; -export const TILE_CONCURRENCY = 6; +/** + * The meta-catalogue speaks HTTP/1.1 only, so the browser opens six + * connections to it: tiles take three, searches two, so a click's lookup + * never queues behind megabyte tiles (it timed out doing so). + */ +export const API_CONCURRENCY = 2; +export const TILE_CONCURRENCY = 3; /** Pictures asked for around a point; the API returns them nearest first. */ export const NEAREST_LIMIT = 10; @@ -110,7 +118,7 @@ export const SD_WIDTH = 2_048; export const PANORAMAX_CREDIT_HTML = 'Street Level: imagery © Panoramax contributors, licence per picture'; -export const LINE_CLICK_HINT = 'click a line for the photo there'; +export const LINE_CLICK_HINT = 'click a line for its nearest photo'; /** Licence ids Panoramax uses, as the caption names them. */ const LICENSE_LABELS = Object.freeze({ @@ -194,8 +202,20 @@ function creatorOf(item) { : []), ]; const name = names.find((value) => typeof value === 'string' && value.trim()); - if (!name || looksLikeEmail(name)) return null; - return name.trim().slice(0, 80); + if (name && !looksLikeEmail(name)) return name.trim().slice(0, 80); + // Licences ask for the source to be named: the instance that holds it. + return instanceOf(item); +} + +/** The federated instance holding a picture (its `via` link's host), or null. */ +function instanceOf(item) { + const href = item?.links?.find?.((link) => link?.rel === 'via')?.href; + try { + const url = new URL(String(href)); + return url.protocol === 'https:' ? url.hostname.slice(0, 80) : null; + } catch { + return null; + } } /** A 360° picture: a full-sphere field of view or an equirectangular projection. */ diff --git a/src/layers/streetLevel/providers/panoramax/policy.test.mjs b/src/layers/streetLevel/providers/panoramax/policy.test.mjs index 605bcf7ad..57665f025 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/policy.test.mjs @@ -225,3 +225,18 @@ test('hostile item data: a malformed link escape, null coordinates, a huge produ 'an @ without a domain is a name', ); }); + +test('a picture whose producer is hidden is credited to the instance holding it', () => { + const item = stacItem({ + id: 'a', + lon: 1, + lat: 2, + producer: 'someone@ign.example.fr', + }); + item.links.push({ rel: 'via', href: 'https://panoramax.ign.fr' }); + assert.equal(normalizeItem(item).creator, 'panoramax.ign.fr'); + item.links.at(-1).href = 'http://insecure.example'; + assert.equal(normalizeItem(item).creator, null); + item.links.at(-1).href = 'not a url'; + assert.equal(normalizeItem(item).creator, null); +}); diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 2ae2c8091..3ce1ee490 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -2,11 +2,17 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createPanoramaxProvider, + overviewPointSize, panoramaxZoomFor, passesCoverage, pictureTilesAround, } from './index.js'; -import { NO_COVERAGE_HINT, OVERVIEW_HINT, PICK_PREFIX } from './policy.js'; +import { + FILTERED_HINT, + NO_COVERAGE_HINT, + OVERVIEW_HINT, + PICK_PREFIX, +} from './policy.js'; import { validateProviders } from '../../registry.js'; import { tileBounds } from '../../tileMath.js'; import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; @@ -141,19 +147,21 @@ test('Panoramax registers keyless and off by default, with both filters and its assert.match(definition.credit.html, /Panoramax<\/a> contributors/); assert.equal( definition.coverageLabel.action, - 'click a line for the photo there', + 'click a line for its nearest photo', ); assert.doesNotThrow(() => validateProviders([definition])); }); -test('lines at z11–14 below 30 km; density points from z5 down to z0 above', () => { +test('lines at z10–14 below 60 km, as Mapillary’s; density points from z5 down to z0 above', () => { assert.deepEqual(panoramaxZoomFor(500), { zoom: 14, kind: 'sequence' }); assert.deepEqual(panoramaxZoomFor(1_800), { zoom: 14, kind: 'sequence' }); assert.deepEqual(panoramaxZoomFor(4_000), { zoom: 13, kind: 'sequence' }); assert.deepEqual(panoramaxZoomFor(12_000), { zoom: 12, kind: 'sequence' }); assert.deepEqual(panoramaxZoomFor(25_000), { zoom: 11, kind: 'sequence' }); assert.deepEqual(panoramaxZoomFor(30_000), { zoom: 11, kind: 'sequence' }); - assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 5, kind: 'overview' }); + assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 10, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(60_000), { zoom: 10, kind: 'sequence' }); + assert.deepEqual(panoramaxZoomFor(60_001), { zoom: 5, kind: 'overview' }); assert.deepEqual(panoramaxZoomFor(100_000), { zoom: 5, kind: 'overview' }); assert.deepEqual(panoramaxZoomFor(2_000_000), { zoom: 3, kind: 'overview' }); assert.deepEqual(panoramaxZoomFor(20_000_000), { zoom: 0, kind: 'overview' }); @@ -690,3 +698,73 @@ test('switching off clears the cones and forgets a pending line click', async (t await env.instance.nearestImage(east(0)); assert.equal(env.source.calls.nearest.at(-1).collections, null); }); + +test('lines in view that the filter hides all of say so, not that there is nothing here', async (t) => { + const env = coverageSetup(t, { + filter: { pano: 'pano', sinceMs: null }, + sequences: [line('flat-only')], + }); + env.instance.activate(env.viewer); + assert.ok( + await until( + () => + !env.instance.coverageStats().loading && + env.instance.coverageStats().zoom !== null, + ), + ); + assert.ok( + await until(() => env.instance.coverageStats().hint === FILTERED_HINT), + ); + assert.equal(env.instance.coverageStats().count, 0); +}); + +test('from high up over empty sea the panel says there is nothing, not to zoom in', async (t) => { + const env = coverageSetup(t, { height: 200_000, grid: [] }); + env.instance.activate(env.viewer); + assert.ok( + await until(() => env.instance.coverageStats().kind === 'overview'), + ); + assert.ok( + await until(() => env.instance.coverageStats().hint === NO_COVERAGE_HINT), + ); +}); + +test('density dots grow with the pictures they stand for', () => { + assert.equal(overviewPointSize({ weight: 1 }), 2.5); + assert.equal(overviewPointSize({ weight: 100 }), 4.5); + assert.equal(overviewPointSize({ weight: 1e9 }), 8); + assert.equal(overviewPointSize({}), 2.5); + assert.ok( + overviewPointSize({ weight: 50_000 }) > + overviewPointSize({ weight: 50 }) + 2, + ); +}); + +test('Esc in the docked viewer clears the selected sequence before the panel can collapse', async (t) => { + const items = sequence('s', 3); + const env = await mounted({ items, tiles: pictureTiles(items) }); + await withCones(t, env); + await env.instance.viewer.open('s-0'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.instance.sequenceStats().images === 3)); + const root = env.next.parentNode; + const event = { + key: 'Escape', + stopped: false, + stopPropagation() { + this.stopped = true; + }, + }; + root.fire('keydown', event); + assert.equal(env.instance.sequenceStats().selectedId, null); + // Nothing selected: Esc goes on to the panel. + const again = { key: 'Escape', defaultPrevented: false }; + const result = { prevented: false }; + root.fire('keydown', { + ...again, + preventDefault() { + result.prevented = true; + }, + }); + assert.equal(result.prevented, false); +}); diff --git a/src/layers/streetLevel/providers/panoramax/source.js b/src/layers/streetLevel/providers/panoramax/source.js index 7d3c86e6c..58052d83c 100644 --- a/src/layers/streetLevel/providers/panoramax/source.js +++ b/src/layers/streetLevel/providers/panoramax/source.js @@ -115,13 +115,24 @@ export function createPanoramaxSource({ } } - /** A STAC search, through the API gate (newest request first). */ + /** + * A STAC search, through the API gate (newest request first). One that + * timed out is asked once more: a search is quick once the connection + * is free. + */ async function search(params, { signal } = {}) { const url = `${root}/search?${new URLSearchParams(params)}`; - const bytes = await apiLimiter.run( - () => get(url, { signal, timeoutMs: API_TIMEOUT_MS }), - { signal }, - ); + const once = () => + apiLimiter.run(() => get(url, { signal, timeoutMs: API_TIMEOUT_MS }), { + signal, + }); + let bytes; + try { + bytes = await once(); + } catch (error) { + if (signal?.aborted || error?.message !== NO_ANSWER) throw error; + bytes = await once(); + } let payload; try { payload = JSON.parse(new TextDecoder().decode(bytes)); diff --git a/src/layers/streetLevel/providers/panoramax/source.test.mjs b/src/layers/streetLevel/providers/panoramax/source.test.mjs index a068a3eff..e0111a33e 100644 --- a/src/layers/streetLevel/providers/panoramax/source.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/source.test.mjs @@ -173,14 +173,16 @@ test('the newest search goes first when the API gate is full', async () => { }); const all = ['1', '2', '3', '4', '5', '6'].map((id) => source.getItem(id)); await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepEqual(order, ['1', '2', '3', '4'], 'four at once'); + assert.deepEqual(order, ['1', '2'], 'two at once'); waiting.shift()(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(order.at(-1), '6', 'the latest waiting one next'); - while (waiting.length) waiting.shift()(); - await new Promise((resolve) => setTimeout(resolve, 0)); - while (waiting.length) waiting.shift()(); - await Promise.all(all); + let done = false; + Promise.allSettled(all).then(() => (done = true)); + while (!done) { + waiting.splice(0).forEach((go) => go()); + await new Promise((resolve) => setTimeout(resolve, 0)); + } }); test('an unreadable search answer, or one with no features, is handled', async () => { @@ -227,3 +229,67 @@ test('a network failure reads as no answer, not as a stack trace', async () => { return true; }); }); + +test('a search that timed out is asked once more; a second timeout is reported', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const hang = (signal) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + const source = createPanoramaxSource({ + fetchImpl: async (url, { signal }) => { + calls++; + if (calls === 1) return hang(signal); + return new Response(JSON.stringify({ features: [{ id: 'a' }] })); + }, + }); + const asked = source.getItem('a'); + for (let i = 0; i < 5; i++) await Promise.resolve(); + t.mock.timers.tick(60_000); + assert.deepEqual(await asked, { id: 'a' }); + assert.equal(calls, 2); + const stuck = createPanoramaxSource({ + fetchImpl: async (url, { signal }) => hang(signal), + }); + const never = stuck.getItem('b'); + for (let round = 0; round < 2; round++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + t.mock.timers.tick(60_000); + } + await assert.rejects(never, /did not answer in time/); +}); + +test('the HTTP/1.1 API never gets more than five requests at once: three tiles, two searches', async () => { + const { API_CONCURRENCY, TILE_CONCURRENCY } = await import('./policy.js'); + assert.ok(API_CONCURRENCY + TILE_CONCURRENCY <= 5); + let open = 0; + let most = 0; + const waiting = []; + const source = createPanoramaxSource({ + fetchImpl: async (url) => { + open++; + most = Math.max(most, open); + await new Promise((resolve) => waiting.push(resolve)); + open--; + return url.includes('/map/') + ? new Response(null, { status: 204 }) + : new Response(JSON.stringify({ features: [] })); + }, + }); + const all = [ + ...Array.from({ length: 8 }, (_, i) => + source.getTile({ x: i, y: 0, z: 14 }), + ), + ...Array.from({ length: 4 }, (_, i) => source.getItem(String(i))), + ]; + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(most, 5); + let done = false; + Promise.allSettled(all).then(() => (done = true)); + while (!done) { + waiting.splice(0).forEach((go) => go()); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.equal(most, 5, 'never more'); +}); From cf502429a33e9b11c5a474e0161150db50eeebae Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:24:13 +0100 Subject: [PATCH 09/17] =?UTF-8?q?fix(street-level):=20Panoramax=20IC8=20re?= =?UTF-8?q?view=20=E2=80=94=20line=20geometry=20budget,=20caches,=20attrib?= =?UTF-8?q?ution,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a five-lens IC8 review (correctness, security, tests with mutation testing, performance, UX): - P0: over Paris at 12–60 km the unsimplified z10–z12 lines sent ~2M points to Cesium's ground-polyline builder (gigabytes of geometry; the tab hung). decode.js now simplifies lines (Douglas–Peucker, 4 tile units: 6–10% of the points), and lineCoverage gains an opt-in point budget (`maxPoints`, 10,000 a tile for Panoramax). - Decoded coverage tiles are kept (KartaView's tile cache moves to the shared tileCache.js); a sequence's z15 picture lists are kept and load on their own connection; cones know their tiles from the start of a load, so a picture opening meanwhile re-centres them and ‹ › no longer restarts the load. - The HD panorama loads only for the expanded viewer (a docked one on a dense screen asked for 3 MB originals) and is aborted when another photo opens; picture files over 48 MB or 16,384 px are refused; a bitmap no renderer took after a context loss is closed. - A nearest lookup survives one of its two searches failing; only a timeout is retried; offline reads as unreachable; Retry-After is capped at five minutes; the search box stays on the map at ±180°; cones near both edges of a narrow (high-latitude) tile read both. - Every producer is credited (app and photographer); with none to name, the caption says "via " rather than crediting the host. - Esc anywhere in the docked viewer (toolbar, caption) clears the sequence before the panel takes it. A filter that hides every density dot says so; wording aligned ("coverage", "photo"). - Tests for every surviving mutant (Esc consumed, keepOrder and dot-size wiring, licence in the core, limiter aborts, radius floor, bbox corner, first-producer-only, byte and size caps), a limiter test, and a gate fixture that answers box searches and leaves out pictures facing away; the gate now clicks over the other sequence's pictures, so only the line click's own-sequence lookup can pass. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- README.md | 2 +- docs/CURRENT-STATE.md | 20 +- scripts/fixtures/street-level/panoramax.mjs | 34 ++- scripts/format-scope.json | 4 + scripts/package-boundaries.json | 3 + scripts/qa-street-level.mjs | 29 ++- src/layers/streetLevel/limiter.test.mjs | 80 +++++++ src/layers/streetLevel/lineCoverage.js | 18 +- src/layers/streetLevel/lineCoverage.test.mjs | 99 ++++++++- src/layers/streetLevel/photoViewer.js | 84 +++++-- src/layers/streetLevel/photoViewer.test.mjs | 141 ++++++++++++ .../streetLevel/providers/kartaview/index.js | 51 +---- .../providers/kartaview/provider.test.mjs | 21 ++ .../streetLevel/providers/panoramax/decode.js | 50 ++++- .../providers/panoramax/decode.test.mjs | 37 +++- .../streetLevel/providers/panoramax/index.js | 116 +++++----- .../streetLevel/providers/panoramax/policy.js | 40 +++- .../providers/panoramax/policy.test.mjs | 37 +++- .../providers/panoramax/provider.test.mjs | 209 ++++++++++++++++-- .../streetLevel/providers/panoramax/source.js | 76 +++++-- .../providers/panoramax/source.test.mjs | 69 +++++- .../streetLevel/providers/panoramax/viewer.js | 9 +- .../providers/panoramax/viewer.test.mjs | 38 +++- src/layers/streetLevel/tileCache.js | 48 ++++ src/layers/streetLevel/viewerHost.test.mjs | 12 + src/tooling/streetLevelQa.test.mjs | 12 + src/ui/streetLevelPresentation.test.mjs | 19 ++ src/ui/styles/street-level.css | 5 +- 29 files changed, 1153 insertions(+), 216 deletions(-) create mode 100644 src/layers/streetLevel/limiter.test.mjs create mode 100644 src/layers/streetLevel/tileCache.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 61acfa6f8..5bc2965cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,7 +106,11 @@ and licence (CC BY-SA 4.0, Licence Ouverte 2.0…) and links to it in the Panoramax viewer; HD originals load only for an expanded viewer, once. Share links carry it as `0.m.0_0.r.panoramax-1`. KartaView's photo viewer - is now shared (`photoViewer.js`, `panorama.js`). + is now shared (`photoViewer.js`, `panorama.js`); in it, for KartaView too, + Esc clears the selected drive before it collapses the panel. Lines are + simplified and drawn within a point budget (a tile's unsimplified lines + over Paris hung the tab), decoded tiles are kept, and the cones' tiles + have their own connection. - 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 diff --git a/README.md b/README.md index 100e156b4..0611c6487 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**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. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's author and licence (mostly CC BY-SA 4.0; IGN's are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. 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. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. 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, `0.m.0_0.r.panoramax-1` Panoramax 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`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2b389b675..a9fc1f5fb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3275,16 +3275,22 @@ SINCE; a line with no camera type or day stays drawn. Lines keep the decoded longest-first order under the per-tile cap (`keepOrder`). Once the picture is on screen its sequence is highlighted and drawn as cones from the z15 tiles around it (their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC -item), plus a neighbour within 150 m of an edge; the cones re-centre when a -picture opens outside them. ‹ › follow the picture's own `prev`/`next` +item), plus the neighbours within 150 m of an edge, on their own connection +(coverage tiles take three, searches two); each tile's list for the sequence +is kept, and the cones re-centre when a picture opens outside the tiles +they were drawn from, judged from the moment their load starts. ‹ › follow the picture's own `prev`/`next` links, so stepping costs one small search per picture. The viewer shows the 2,048 px SD derivative (equirectangular for a 360° picture) and swaps in the -HD original only when the viewer is wider than SD (flat) or needs a 4,096 px -panorama (expanded), once, shrunk while it decodes; originals wider than -8,192 px are never fetched. The caption names the author (not an account -named by an email address) and the picture's licence (CC BY-SA 4.0, Licence +HD original only when the viewer is wider than SD (flat) or, for a +panorama, expanded; once, shrunk while it decodes, and aborted if another +photo opens; originals wider than 8,192 px are never fetched, and any +picture file over 48 MB or 16,384 px is refused. The caption names every +producer (an uploading app and its photographer; never an account named by +an email address), or else the instance holding the picture ("via +panoramax.ign.fr"), and the picture's licence (CC BY-SA 4.0, Licence Ouverte 2.0…; StreetPose `license`), and links to the picture in the -Panoramax viewer. The MCP globe panel allows the meta-catalogue and the +Panoramax viewer. Esc anywhere in the docked viewer clears the selected +sequence before the panel can take it (KartaView too). The MCP globe panel allows the meta-catalogue and the three biggest instances; a picture on another instance shows only in the app. diff --git a/scripts/fixtures/street-level/panoramax.mjs b/scripts/fixtures/street-level/panoramax.mjs index 768a7ff62..7506eb806 100644 --- a/scripts/fixtures/street-level/panoramax.mjs +++ b/scripts/fixtures/street-level/panoramax.mjs @@ -112,7 +112,11 @@ function item(picture, pictures) { sd: { href: `${files}/sd.jpg` }, thumb: { href: `${files}/thumb.jpg` }, }, - links: [...link('prev', sibling(-1)), ...link('next', sibling(1))], + links: [ + ...link('prev', sibling(-1)), + ...link('next', sibling(1)), + { rel: 'via', href: HOST }, + ], }; } @@ -231,11 +235,9 @@ export function answerPanoramaxRequest({ method, url }) { .map((p) => item(p, pictures)), }); } - const [lon, lat] = (q.get('place_position') || '').split(',').map(Number); - const radius = Number((q.get('place_distance') || '0-50').split('-')[1]); const filter = q.get('filter') || ''; const since = q.get('datetime')?.split('/')[0]; - const near = pictures + const kept = pictures .filter( (p) => !q.get('collections') || p.sequenceId === q.get('collections'), ) @@ -246,7 +248,29 @@ export function answerPanoramaxRequest({ method, url }) { ? !p.isPano : true, ) - .filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since)) + .filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since)); + // A box: every picture in it, in no particular order (id order here). + if (q.has('bbox')) { + const [west, south, east, north] = q.get('bbox').split(',').map(Number); + return json({ + type: 'FeatureCollection', + features: kept + .filter( + (p) => + p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north, + ) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, Number(q.get('limit')) || 10) + .map((p) => item(p, pictures)), + }); + } + const [lon, lat] = (q.get('place_position') || '').split(',').map(Number); + const radius = Number((q.get('place_distance') || '0-50').split('-')[1]); + // As the API does, even at the widest tolerance: a place behind a + // picture (every one looks east) is not in it. + const facing = (p) => lon >= p.lon - 1e-7; + const near = kept + .filter(facing) .map((p) => ({ p, metres: metresApart({ lon, lat }, p) })) .filter(({ metres }) => metres <= radius) .sort((a, b) => a.metres - b.metres) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 73959131f..53d6a5b67 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -928,12 +928,16 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/providers/panoramax/policy.test.mjs", "src/layers/streetLevel/providers/panoramax/decode.test.mjs", "src/layers/streetLevel/providers/panoramax/source.test.mjs", "src/layers/streetLevel/providers/panoramax/viewer.test.mjs", "src/layers/streetLevel/providers/panoramax/provider.test.mjs", "src/testSupport/panoramaxFixtures.mjs", + "src/layers/streetLevel/photoViewer.test.mjs", + "src/layers/streetLevel/limiter.test.mjs", + "src/layers/streetLevel/panorama.test.mjs", "src/layers/streetLevel/lineCoverage.test.mjs", "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2e83eb2cc..493bae74a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1241,6 +1241,7 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", "src/layers/streetLevel/sequenceCones.js", @@ -2216,6 +2217,7 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", "src/layers/streetLevel/sequenceCones.js", @@ -2762,6 +2764,7 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", "src/layers/streetLevel/sequenceCones.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index f230fbdc8..9c8c55072 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1255,7 +1255,30 @@ async function main() { .catch(() => null); if (!handle && !fixtures) return skip('no Panoramax line in view'); assert.ok(handle, 'a Panoramax line is pickable'); - const at = await handle.jsonValue(); + let at = await handle.jsonValue(); + if (fixtures) { + // Click the top line where the other sequence's pictures are: + // the nearest picture is then the other's, and only the line + // click's own-sequence lookup opens the clicked line's. + const others = panoramaxPictures() + .filter((p) => p.sequenceId !== at.id) + .slice(40, 100) + .map((p) => [p.lon, p.lat]); + const aimed = await page + .waitForFunction( + findPickPoint, + { timeout: 60_000, polling: 500 }, + { + prefix: 'pnx:seq:', + id: at.id, + points: others, + billboardIds: null, + }, + ) + .catch(() => null); + assert.ok(aimed, 'the line is pickable over the other’s pictures'); + at = await aimed.jsonValue(); + } const parked = await camera(); await page.mouse.click(at.x, at.y); await page.waitForFunction( @@ -1298,8 +1321,8 @@ async function main() { else assert.equal( caption, - 'Licence Ouverte 2.0', - 'an account named by an email address is not shown', + 'via panoramax.openstreetmap.fr · Licence Ouverte 2.0', + 'an account named by an email address is not shown; the instance is', ); const pictures = panoramaxPictures().filter( (p) => p.sequenceId === street.sequenceId, diff --git a/src/layers/streetLevel/limiter.test.mjs b/src/layers/streetLevel/limiter.test.mjs new file mode 100644 index 000000000..c9fabef68 --- /dev/null +++ b/src/layers/streetLevel/limiter.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createLimiter } from './limiter.js'; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** A task that runs until `finish()`. */ +function held(log, name) { + let finish; + const done = new Promise((resolve) => (finish = resolve)); + return { + task: () => { + log.push(name); + return done.then(() => name); + }, + finish: () => finish(), + }; +} + +test('at most `limit` tasks run; queued ones start first in, first out', async () => { + const log = []; + const gate = createLimiter(2); + const tasks = ['a', 'b', 'c', 'd'].map((name) => held(log, name)); + const results = tasks.map(({ task }) => gate.run(task)); + await tick(); + assert.deepEqual(log, ['a', 'b']); + assert.equal(gate.pending, 4); + tasks[0].finish(); + await tick(); + assert.deepEqual(log, ['a', 'b', 'c']); + for (const { finish } of tasks) finish(); + assert.deepEqual(await Promise.all(results), ['a', 'b', 'c', 'd']); + assert.equal(gate.pending, 0); +}); + +test('lifo starts the newest queued task next', async () => { + const log = []; + const gate = createLimiter(1, { lifo: true }); + const tasks = ['a', 'b', 'c'].map((name) => held(log, name)); + const results = tasks.map(({ task }) => gate.run(task)); + await tick(); + tasks[0].finish(); + await tick(); + assert.deepEqual(log, ['a', 'c']); + tasks[2].finish(); + tasks[1].finish(); + await Promise.all(results); +}); + +test('a queued task whose signal aborts never runs, leaves the queue and rejects with the reason', async () => { + const log = []; + const gate = createLimiter(1); + const first = held(log, 'a'); + const running = gate.run(first.task); + const controller = new AbortController(); + const queued = gate.run(held(log, 'b').task, { signal: controller.signal }); + await tick(); + assert.equal(gate.pending, 2); + controller.abort(new Error('overtaken')); + await assert.rejects(queued, /overtaken/); + assert.equal(gate.pending, 1, 'out of the queue'); + first.finish(); + await running; + await tick(); + assert.deepEqual(log, ['a'], 'b never ran'); +}); + +test('a task already aborted when its turn comes is skipped', async () => { + const log = []; + const gate = createLimiter(1); + const first = held(log, 'a'); + const running = gate.run(first.task); + const signal = { aborted: false, reason: new Error('gone'), addEventListener() {} }; + const queued = gate.run(held(log, 'b').task, { signal }); + signal.aborted = true; + first.finish(); + await running; + await assert.rejects(queued, /gone/); + assert.deepEqual(log, ['a']); +}); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 1a8d73b6f..2eb634c9c 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -73,6 +73,7 @@ export function meshBoxInRange(box, centre) { * @property {number} viewRangePerHeight …or camera height times this. * @property {number} viewNearM Ground always covered around a tilted camera. * @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first). + * @property {number} [maxPoints] Line points drawn at once, over every tile (default: no limit). */ /** @@ -111,6 +112,9 @@ export function createLineCoverage({ }) { const { render } = state.services; const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles); + const perTilePointCap = tuning.maxPoints + ? Math.floor(tuning.maxPoints / tuning.maxTiles) + : Infinity; /** Colour for a line: the provider's, GEV cyan while selected. */ function sequenceColor({ selected = false } = {}) { @@ -151,13 +155,25 @@ export function createLineCoverage({ function drawnSequences(entry) { const drawn = []; const current = filter(); + let points = 0; for (const sequence of entry.sequenceList) { if (drawn.length >= perTileSequenceCap) break; - if (passes(sequence, current)) drawn.push(sequence); + if (!passes(sequence, current)) continue; + // A line past the point budget is skipped; a shorter one may still fit. + const size = pointsOf(sequence); + if (points + size > perTilePointCap) continue; + points += size; + drawn.push(sequence); } return drawn; } + function pointsOf(sequence) { + let count = 0; + for (const part of sequence.parts) count += part.length; + return count; + } + /** * Batched primitives for a tile's sequence parts: cast polylines where * terrain heights are cached, draped ground lines otherwise. Each batch diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 88308673c..db4b5449f 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -25,7 +25,15 @@ const TUNING = Object.freeze({ */ function setup( t, - { zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {}, + { + zoomFor, + messages = { rateLimited: 'busy' }, + passes, + fail, + decoded = null, + tuning = TUNING, + overviewPointSize = null, + } = {}, ) { const centre = tileCentre(-121.4944, 38.5816); const viewer = coverageViewer( @@ -68,6 +76,7 @@ function setup( fetchTile: async (tile) => { asked.push(tile); if (fail) throw fail; + if (decoded) return decoded(centre); return { sequences: [ { @@ -94,9 +103,10 @@ function setup( colors: { coverage: '#635bff', selected: '#00e5ff' }, pickPrefix: 'test:line:', renderKey: 'test-coverage', - tuning: TUNING, + tuning, messages, ...(passes ? { passes } : {}), + ...(overviewPointSize ? { overviewPointSize } : {}), }); t.after(() => { coverage.clear(); @@ -140,3 +150,88 @@ test('a refused key without its own message keeps the error it came with', async assert.ok(await until(() => state.keyRejected === true)); assert.equal(state.coverage.lastError, 'the source refused the key'); }); + +/** Three lines in one tile: an old long one, then two newer short ones. */ +function threeLines(centre) { + const line = (id, capturedAt, span) => ({ + id, + parts: [ + [ + [centre.lon - span, centre.lat], + [centre.lon + span, centre.lat], + ], + ], + capturedAt, + isPano: false, + }); + return { + sequences: [ + line('old-long', 1, 0.002), + line('new-short', 2, 0.0002), + line('newer-short', 3, 0.0001), + ], + overview: [], + }; +} + +test('the per-tile cap keeps the newest lines, or the provider’s own order with keepOrder', async (t) => { + const capped = { ...TUNING, maxTiles: 1, maxSequences: 2 }; + const drawnIds = (state) => + [...state.coverage.tiles.values()].flatMap((tile) => [ + ...tile.sequences.keys(), + ]); + const newest = setup(t, { decoded: threeLines, tuning: capped }); + newest.coverage.refresh(); + assert.ok(await until(() => newest.state.coverage.tiles.size === 1)); + assert.deepEqual(drawnIds(newest.state).sort(), ['new-short', 'newer-short']); + const kept = setup(t, { + decoded: threeLines, + tuning: { ...capped, keepOrder: true }, + }); + kept.coverage.refresh(); + assert.ok(await until(() => kept.state.coverage.tiles.size === 1)); + assert.deepEqual(drawnIds(kept.state), ['old-long', 'new-short']); +}); + +test('overview points take the provider’s size, each its own', async (t) => { + const { state, coverage, viewer } = setup(t, { + zoomFor: () => ({ zoom: 3, kind: 'overview' }), + tuning: { ...TUNING, overviewMaxTiles: 4 }, + decoded: (centre) => ({ + sequences: [], + overview: [ + { id: 'a', lon: centre.lon, lat: centre.lat, weight: 1 }, + { id: 'b', lon: centre.lon + 0.01, lat: centre.lat, weight: 50_000 }, + ], + }), + overviewPointSize: (point) => (point.weight > 1 ? 7 : 3), + }); + coverage.refresh(); + assert.ok(await until(() => state.coverage.tiles.size > 0)); + const sizes = []; + for (const primitive of viewer.scene.primitives.items) + for (let i = 0; i < (primitive.length ?? 0); i++) + sizes.push(primitive.get(i).pixelSize); + assert.deepEqual(sizes.sort(), [3, 7]); +}); + +test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => { + const { state, coverage } = setup(t, { + tuning: { ...TUNING, maxTiles: 1, maxSequences: 10, maxPoints: 5, keepOrder: true }, + decoded: (centre) => { + const line = (id, n) => ({ + id, + parts: [ + Array.from({ length: n }, (_, i) => [centre.lon - 0.001 + i * 0.0001, centre.lat]), + ], + capturedAt: 0, + isPano: false, + }); + return { sequences: [line('three', 3), line('four', 4), line('two', 2)], overview: [] }; + }, + }); + coverage.refresh(); + assert.ok(await until(() => state.coverage.tiles.size === 1)); + const [tile] = state.coverage.tiles.values(); + assert.deepEqual([...tile.sequences.keys()], ['three', 'two']); +}); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index bb1e28dde..94da38f47 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -7,6 +7,10 @@ import { /** A photo download (tens of kilobytes to a few megabytes). */ export const IMAGE_TIMEOUT_MS = 15_000; +/** A picture file or decoded side past these is refused (a photo is a few MB). */ +export const MAX_IMAGE_BYTES = 48 * 1024 * 1024; +export const MAX_IMAGE_SIDE = 16_384; + /** Panorama texture widths: enough for a docked viewer, and for an expanded one. */ export const PANO_MID_SIZE = Object.freeze([2048, 1024]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); @@ -27,6 +31,7 @@ export function loadImage( timeoutMs = IMAGE_TIMEOUT_MS, resize = null, failure = 'The photo could not be loaded', + signal = null, } = {}, ) { if ( @@ -35,7 +40,7 @@ export function loadImage( typeof globalThis.createImageBitmap === 'function' && typeof globalThis.fetch === 'function' ) - return loadBitmap(url, timeoutMs, resize, failure); + return loadBitmap(url, timeoutMs, resize, failure, signal); return new Promise((resolve, reject) => { if (!url) return reject(new Error(failure)); const image = new Image(); @@ -57,27 +62,42 @@ export function loadImage( }); } -async function loadBitmap(url, timeoutMs, resize, failure) { +async function loadBitmap(url, timeoutMs, resize, failure, signal) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + // The caller's abort (the photo changed) stops a multi-megabyte download. + const onAbort = () => controller.abort(); + if (signal?.aborted) controller.abort(); + signal?.addEventListener?.('abort', onAbort, { once: true }); try { const response = await fetch(url, { mode: 'cors', signal: controller.signal, }); if (!response.ok) throw new Error(failure); + // Picture hosts are not ours: refuse a file or an image past any real + // photo's size before it is decoded. + const length = Number(response.headers?.get?.('content-length')); + if (length > MAX_IMAGE_BYTES) throw new Error(failure); const blob = await response.blob(); - return await (resize + if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure); + const bitmap = await (resize ? createImageBitmap(blob, { resizeWidth: resize[0], resizeHeight: resize[1], resizeQuality: 'high', }) : createImageBitmap(blob)); + if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) { + bitmap.close?.(); + throw new Error(failure); + } + return bitmap; } catch { throw new Error(failure); } finally { clearTimeout(timer); + signal?.removeEventListener?.('abort', onAbort); } } @@ -92,8 +112,8 @@ export function panoSizeFor(canvasPixels, fovDeg) { * @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image. * @property {(photo: object) => {url: string, width: number, resize?: [number, number]|null}|null} panoFirst * The panorama shown first, its texture width (0 when it is only a preview), and a size to shrink it to. - * @property {(photo: object, size: [number, number]) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp - * A sharper panorama for a canvas that needs `size`, or null. + * @property {(photo: object, size: [number, number], view: {expanded: boolean}) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp + * A sharper panorama for a canvas that needs `size`, or null; `expanded` while the viewer is. * @property {(photo: object) => Array} flatFirst Flat versions to try in turn. * @property {(photo: object, pixels: number) => string|null} flatSharp * A sharper flat version for a picture this many device pixels wide, or null. @@ -411,6 +431,24 @@ export function createPhotoViewer({ renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) }); } + /** + * Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its + * caption) is the provider's before the panel's, which would collapse; + * expanded, the dialog's own Esc shrinks it. + */ + function onEscapeKey(event) { + if ( + event.key !== 'Escape' || + event.shiftKey || + event.defaultPrevented || + root?.closest?.('.sl-viewer-wrap-expanded') || + !onEscape() + ) + return; + event.preventDefault(); + event.stopPropagation(); + } + function onKeyDown(event) { if ( event.defaultPrevented || @@ -419,18 +457,6 @@ export function createPhotoViewer({ event.metaKey ) return; - // Docked, Esc is the viewer's before the panel's (which would collapse); - // expanded, the dialog's own Esc shrinks it. - if ( - event.key === 'Escape' && - !event.shiftKey && - !root?.closest?.('.sl-viewer-wrap-expanded') && - onEscape() - ) { - event.preventDefault(); - event.stopPropagation(); - return; - } const pano = showing === 'pano' && renderer; const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key]; // Shift+← → look around a panorama; ← → step along the sequence. @@ -502,6 +528,7 @@ export function createPhotoViewer({ root.append(picture, canvas, prevButton, nextButton); host.append(root); listen(root, 'keydown', onKeyDown); + listen(host.closest?.('.sl-viewer-wrap') ?? root, 'keydown', onEscapeKey); applyRenderMode(); } @@ -536,13 +563,18 @@ export function createPhotoViewer({ const size = panoSizeNow(); if (size[0] <= panoWidth) return; if (panoComing?.seq === seq && size[0] <= panoComing.width) return; - const sharp = sources.panoSharp(target, size); + const sharp = sources.panoSharp(target, size, { + expanded: Boolean( + root?.closest?.('.sl-viewer-wrap-expanded'), + ), + }); if (!sharp?.url) return; - const coming = { seq, width: size[0] }; + const coming = { seq, width: size[0], controller: new AbortController() }; panoComing = coming; try { const image = await loadAs(sharp.url, { crossOrigin: true, + signal: coming.controller.signal, resize: sharp.resize ?? null, ...(sharp.timeoutMs ? { timeoutMs: sharp.timeoutMs } : {}), }); @@ -578,6 +610,12 @@ export function createPhotoViewer({ } } + /** Another photo, or none: a sharper panorama still on its way is not needed. */ + function dropSharpPanorama() { + panoComing?.controller?.abort(); + panoComing = null; + } + /** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */ function panoSizeNow() { const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); @@ -600,7 +638,10 @@ export function createPhotoViewer({ } // The context may have been lost while this loaded: build on the new // canvas, or let the caller show the front view. - if (!ensureRenderer()) throw new Error(LOAD_FAILED); + if (!ensureRenderer()) { + image.close?.(); + throw new Error(LOAD_FAILED); + } const restore = restoreView?.id === target.id ? restoreView.view : null; restoreView = null; if (restore) renderer.setView(restore); @@ -670,6 +711,7 @@ export function createPhotoViewer({ if (!root) throw new Error(`${name} viewer is not mounted`); const id = String(imageId); const seq = ++openSeq; + dropSharpPanorama(); const keepView = keepViewFor === id; keepViewFor = null; openingId = id; @@ -682,6 +724,7 @@ export function createPhotoViewer({ close() { openSeq++; + dropSharpPanorama(); openingId = null; keepViewFor = null; photo = null; @@ -693,6 +736,7 @@ export function createPhotoViewer({ unmount() { openSeq++; + dropSharpPanorama(); openingId = null; keepViewFor = null; flatSharpUrl = null; diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 704a2f98c..6f29e711e 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -164,3 +164,144 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th assert.equal(asked, 1, 'not asked while expanded'); assert.equal(expanded.prevented, false); }); + +test('a sharp panorama still downloading when another photo opens is aborted', async (t) => { + const signals = []; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + load: (url, options) => + url.endsWith('-sharp') + ? new Promise((_, reject) => { + signals.push(options.signal); + options.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }) + : Promise.resolve({ src: url }), + createRenderer: () => ({ + setImage() {}, + setView() {}, + getView: () => ({ yaw: 0, pitch: 0, fov: 80 }), + resize() {}, + clear() {}, + destroy() {}, + }), + }); + t.after(() => delete globalThis.devicePixelRatio); + globalThis.devicePixelRatio = 1; + const host = fakeHost(); + await viewer.mount(host); + parts(host).canvas.clientWidth = 1800; + await viewer.open('a'); + await tick(); + assert.equal(signals.length, 1); + assert.equal(signals[0].aborted, false); + await viewer.open('f'); + assert.equal(signals[0].aborted, true, 'not needed any more'); +}); + +test('a lost WebGL context while the first panorama loads releases its bitmap', async () => { + let closed = false; + let built = 0; + let land = null; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + load: (url) => + new Promise((resolve) => { + land = () => resolve({ src: url, close: () => (closed = true) }); + }), + // The first renderer is lost mid-load; a new one cannot be built. + createRenderer: () => { + built++; + if (built > 1) throw new Error('context lost'); + return { + setImage() {}, + setView() {}, + getView: () => ({ yaw: 0, pitch: 0, fov: 80 }), + resize() {}, + clear() {}, + destroy() {}, + }; + }, + }); + const host = fakeHost(); + await viewer.mount(host); + const opening = viewer.open('a'); + assert.ok(await until(() => land !== null), 'the panorama is loading'); + parts(host).canvas.fire('webglcontextlost', {}); + const first = land; + first(); + // The panorama failed: the front view is loaded flat instead. + assert.ok(await until(() => land !== first), 'the flat fallback loads'); + land(); + await opening; + assert.equal(built, 2, 'a new renderer was tried'); + assert.equal(closed, true, 'the bitmap no renderer took is closed'); +}); + +test('docked, Esc anywhere in the viewer’s box (toolbar, caption) is offered to the provider', async () => { + let asked = 0; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + onEscape: () => ++asked > 0, + load: async (url) => ({ src: url }), + }); + const host = fakeHost(); + const wrap = fakeHost(); + host.closest = (selector) => (selector === '.sl-viewer-wrap' ? wrap : null); + await viewer.mount(host); + const flags = { stopped: false, prevented: false }; + // Focus back on EXPAND after a shrink: the key reaches the wrap, not the photo. + wrap.fire('keydown', { + key: 'Escape', + stopPropagation: () => (flags.stopped = true), + preventDefault: () => (flags.prevented = true), + }); + assert.equal(asked, 1); + assert.deepEqual(flags, { stopped: true, prevented: true }); + viewer.unmount(); + wrap.fire('keydown', { key: 'Escape' }); + assert.equal(asked, 1, 'unmounted: the listener is gone'); +}); + +test('a picture file or bitmap past any real photo’s size is refused', async (t) => { + const { loadImage, MAX_IMAGE_BYTES, MAX_IMAGE_SIDE } = await import( + './photoViewer.js' + ); + const saved = { + fetch: globalThis.fetch, + createImageBitmap: globalThis.createImageBitmap, + }; + t.after(() => Object.assign(globalThis, saved)); + let closed = 0; + const bitmap = (width, height) => ({ width, height, close: () => closed++ }); + globalThis.createImageBitmap = async (blob) => blob.bitmap; + const respond = ({ length, size, width = 100, height = 50 }) => + (globalThis.fetch = async () => ({ + ok: true, + headers: new Map([['content-length', String(length ?? '')]]), + blob: async () => ({ size: size ?? 10, bitmap: bitmap(width, height) }), + })); + respond({ length: MAX_IMAGE_BYTES + 1 }); + await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + respond({ size: MAX_IMAGE_BYTES + 1 }); + await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + respond({ width: MAX_IMAGE_SIDE + 1 }); + await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + assert.equal(closed, 1, 'the oversize bitmap is released'); + respond({}); + assert.equal((await loadImage('https://x/a.jpg')).width, 100); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 1b377479d..59f2f8d7d 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -1,4 +1,7 @@ import { createLineCoverage } from '../../lineCoverage.js'; +import { createLru, createTileCache } from '../../tileCache.js'; + +export { createTileCache } from '../../tileCache.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { metresBetween } from '../../view.js'; import { decodeCoverageTile } from './decode.js'; @@ -31,9 +34,6 @@ import { const PHOTO_CACHE_SIZE = 4000; /** Sequence pages kept in memory. */ 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) { @@ -44,51 +44,6 @@ export function kartaViewZoomFor(heightM) { }; } -/** 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, -}) { - 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; diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 9c63e9c18..81749b665 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -746,3 +746,24 @@ test('a photo no page lists steps to the photos either side of its index', async await press(env.prev, env.poses); assert.equal(env.poses.at(-1), 's-4'); }); + +test('Esc in the docked viewer clears the selected drive and is consumed; with none, it goes on to the panel', async (t) => { + const env = await mounted({ sequences: { s: [0, 1, 2, 3] } }); + await withCones(t, env); + await env.instance.viewer.open('s-1'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.instance.sequenceStats().images === 4)); + const root = env.next.parentNode; + const press = () => { + const flags = { stopped: false, prevented: false }; + root.fire('keydown', { + key: 'Escape', + stopPropagation: () => (flags.stopped = true), + preventDefault: () => (flags.prevented = true), + }); + return flags; + }; + assert.deepEqual(press(), { stopped: true, prevented: true }); + assert.equal(env.instance.sequenceStats().selectedId, null, 'drive cleared'); + assert.deepEqual(press(), { stopped: false, prevented: false }); +}); diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index 19b470846..d628dec5c 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -1,7 +1,49 @@ import { PbfReader } from 'pbf'; import { VectorTile } from '@mapbox/vector-tile'; import { tileLocalToLonLat } from '../../tileMath.js'; -import { OVERVIEW_BINS_PER_AXIS, panoramaxTimeMs } from './policy.js'; +import { + OVERVIEW_BINS_PER_AXIS, + SIMPLIFY_TOLERANCE_UNITS, + panoramaxTimeMs, +} from './policy.js'; + +/** + * Douglas–Peucker in tile units: the points of `line` ({x, y}) that keep + * it within `tolerance` of the original, first and last always kept. + * Iterative, so a long track cannot overflow the stack. + */ +export function simplifyLine(line, tolerance) { + if (line.length < 3 || !(tolerance > 0)) return line; + const keep = new Uint8Array(line.length); + keep[0] = 1; + keep[line.length - 1] = 1; + const spans = [[0, line.length - 1]]; + while (spans.length) { + const [from, to] = spans.pop(); + const a = line[from]; + const b = line[to]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const span = Math.hypot(dx, dy); + let far = -1; + let farthest = tolerance; + for (let i = from + 1; i < to; i++) { + const p = line[i]; + // Distance to the chord, or to `a` where the chord has no length. + const d = span + ? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / span + : Math.hypot(p.x - a.x, p.y - a.y); + if (d > farthest) { + farthest = d; + far = i; + } + } + if (far === -1) continue; + keep[far] = 1; + spans.push([from, far], [far, to]); + } + return line.filter((_, i) => keep[i]); +} /** Epoch ms of a sequence's day ('2025-08-11'), or 0 when undated. */ function dayMs(value) { @@ -36,6 +78,8 @@ export function decodeCoverageTile(bytes, address) { const props = feature.properties || {}; const parts = []; let length = 0; + const tolerance = + (SIMPLIFY_TOLERANCE_UNITS * sequences.extent) / 4096; for (const line of feature.loadGeometry()) { if (line.length < 2) continue; for (let k = 1; k < line.length; k++) @@ -43,8 +87,10 @@ export function decodeCoverageTile(bytes, address) { line[k].x - line[k - 1].x, line[k].y - line[k - 1].y, ); + // Simplified before anything else: GPS tracks carry a point every + // few metres, and each becomes ~8 ground-line vertices on the GPU. parts.push( - line.map((point) => + simplifyLine(line, tolerance).map((point) => tileLocalToLonLat(point.x, point.y, sequences.extent, x, y, z), ), ); diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index 4347463b0..159cf3884 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { decodeCoverageTile, decodePictures } from './decode.js'; +import { decodeCoverageTile, decodePictures, simplifyLine } from './decode.js'; import { OVERVIEW_BINS_PER_AXIS } from './policy.js'; import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs'; import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs'; @@ -189,3 +189,38 @@ test('a z15 tile’s pictures of one sequence become cones; other sequences’ a }); assert.deepEqual(decodePictures(lines, tile, 's1'), []); }); + +test('lines are simplified within a few tile units, ends and corners kept', () => { + const zigzag = Array.from({ length: 1000 }, (_, i) => ({ x: i, y: i % 2 ? 1 : 0 })); + assert.deepEqual(simplifyLine(zigzag, 4), [zigzag[0], zigzag.at(-1)], 'a 1-unit wobble goes'); + const corner = [ + { x: 0, y: 0 }, + { x: 50, y: 1 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ]; + assert.deepEqual(simplifyLine(corner, 4), [corner[0], corner[2], corner[3]]); + assert.equal(simplifyLine(corner.slice(0, 2), 4).length, 2); + // A loop back to its start (no chord) keeps its far point. + const loop = [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, + { x: 0, y: 0 }, + ]; + assert.equal(simplifyLine(loop, 4).length, 3); + assert.equal(simplifyLine(zigzag, 0).length, 1000, 'no tolerance, no change'); +}); + +test('a decoded GPS track keeps a fraction of its points', () => { + const { tile, lon, lat } = tileCentre(2.35, 48.85); + const track = Array.from({ length: 500 }, (_, i) => [ + lon - 0.004 + i * 0.000016, + lat + Math.sin(i / 40) * 0.0004, + ]); + const [sequence] = decodeCoverageTile( + encodePanoramaxTile(tile, { sequences: [{ id: 'gps', parts: [track] }] }), + tile, + ).sequences; + assert.ok(sequence.parts[0].length < 100, `${sequence.parts[0].length} points`); + assert.ok(sequence.parts[0].length > 5); +}); diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index b6eac8297..9eb13cdaf 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -8,6 +8,7 @@ import { tileBounds, } from '../../tileMath.js'; import { metresBetween } from '../../view.js'; +import { createLru, createTileCache } from '../../tileCache.js'; import { decodeCoverageTile, decodePictures } from './decode.js'; import { createPanoramaxSource } from './source.js'; import { createPanoramaxViewer } from './viewer.js'; @@ -70,6 +71,10 @@ export function overviewPointSize(point) { const PHOTO_CACHE_SIZE = 2000; /** Sequences whose cone centre is remembered. */ const ANCHOR_CACHE_SIZE = 50; +/** Decoded coverage tiles kept (a simplified dense z10 tile is ~4 MB). */ +const COVERAGE_TILE_CACHE_SIZE = 16; +/** One sequence's pictures in a z15 tile, kept for re-centring. */ +const PICTURE_LIST_CACHE_SIZE = 32; /** * Sequence lines at z10–14 below 60 km; above, the density grid at z5 and @@ -116,26 +121,6 @@ export function passesCoverage(record, filter) { }); } -/** 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), - }; -} - /** The z15 tile holding a point. */ function pictureTileOf({ lon, lat }) { const z = PICTURE_TILE_ZOOM; @@ -143,29 +128,29 @@ function pictureTileOf({ lon, lat }) { } /** - * The z15 tiles whose pictures surround a point: its own, and the - * neighbours it sits within PICTURE_TILE_EDGE_M of (at most four). + * The z15 tiles whose pictures surround a point: its own first, and the + * neighbours it sits within PICTURE_TILE_EDGE_M of (at most nine). */ export function pictureTilesAround(point) { const home = pictureTileOf(point); const box = tileBounds(home.x, home.y, home.z); const near = (lon, lat) => metresBetween(point, { lon, lat }) <= PICTURE_TILE_EDGE_M; - const dx = near(box.west, point.lat) ? -1 : near(box.east, point.lat) ? 1 : 0; - const dy = near(point.lon, box.north) - ? -1 - : near(point.lon, box.south) - ? 1 - : 0; + // Near both edges at once where tiles are narrow (high latitudes). + const dxs = [0]; + if (near(box.west, point.lat)) dxs.push(-1); + if (near(box.east, point.lat)) dxs.push(1); + const dys = [0]; + if (near(point.lon, box.north)) dys.push(-1); + if (near(point.lon, box.south)) dys.push(1); const max = 2 ** home.z; - const tiles = [home]; - const add = (x, y) => { - if (y < 0 || y >= max) return; - tiles.push({ x: (x + max) % max, y, z: home.z }); - }; - if (dx) add(home.x + dx, home.y); - if (dy) add(home.x, home.y + dy); - if (dx && dy) add(home.x + dx, home.y + dy); + const tiles = []; + for (const dy of dys) + for (const dx of dxs) { + const y = home.y + dy; + if (y < 0 || y >= max) continue; + tiles.push({ x: (home.x + dx + max) % max, y, z: home.z }); + } return tiles; } @@ -290,12 +275,36 @@ export function createPanoramaxProvider({ return (delta < 0 ? photo.prevId : photo.nextId) || null; } - const coverage = createLineCoverage({ - state, - fetchTile: async (tile, { signal }) => + // Decoded tiles, kept a while: panning back, or across a zoom band and + // back, neither fetches nor decodes them again. + const coverageTiles = createTileCache({ + size: COVERAGE_TILE_CACHE_SIZE, + load: (tile, { signal }) => readable(async () => decodeCoverageTile(await source.getTile(tile, { signal }), tile), ), + }); + /** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */ + const pictureLists = createLru(PICTURE_LIST_CACHE_SIZE); + function picturesIn(tile, sequenceId, signal) { + const key = `${tileKey(tile)}:${sequenceId}`; + const kept = pictureLists.get(key); + if (kept) return kept; + const list = readable(async () => + decodePictures( + await source.getTile(tile, { signal, lane: 'pictures' }), + tile, + sequenceId, + ), + ); + pictureLists.set(key, list); + list.catch(() => pictureLists.delete(key)); + return list; + } + + const coverage = createLineCoverage({ + state, + fetchTile: (tile, options) => coverageTiles.get(tile, options), zoomFor: panoramaxZoomFor, colors: COLORS, pickPrefix: PICK_PREFIX.sequence, @@ -312,21 +321,15 @@ export function createPanoramaxProvider({ const anchor = anchors.get(sequenceId); if (!anchor) return []; const tiles = pictureTilesAround(anchor); + // Known from the start: a picture opening while these load is + // judged against them (re-centred if outside, left alone inside). + drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) }; // The home tile must load; a neighbour that fails only thins the cones. const lists = await Promise.allSettled( - tiles.map((tile) => - readable(async () => - decodePictures( - await source.getTile(tile, { signal }), - tile, - sequenceId, - ), - ), - ), + tiles.map((tile) => picturesIn(tile, sequenceId, signal)), ); signal?.throwIfAborted(); if (lists[0].status === 'rejected') throw lists[0].reason; - drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) }; const byId = new Map( lists .flatMap((list) => list.value ?? []) @@ -445,13 +448,18 @@ export function createPanoramaxProvider({ const count = lines ? drawn : 0; const loading = state.coverage.pending.size > 0; const settled = !loading && !state.coverage.lastError; - // Lines in view that the filter hid all of: say so, not "none here". - const filteredOut = - lines && - [...state.coverage.tiles.values()].some((tile) => tile.total > 0); let fallback = ''; + // Tiles that held something the filter hid all of. + const filteredOut = [...state.coverage.tiles.values()].some( + (tile) => tile.total > 0, + ); if (kind === 'overview') - fallback = drawn > 0 || !settled ? OVERVIEW_HINT : NO_COVERAGE_HINT; + fallback = + drawn > 0 || !settled + ? OVERVIEW_HINT + : filteredOut + ? FILTERED_HINT + : NO_COVERAGE_HINT; else if (lines && settled && count === 0) fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT; return { diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index 0a122242f..59fc95b3b 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -49,6 +49,10 @@ export const COVERAGE_TUNING = Object.freeze({ // looking emptier than its suburbs. (KartaView draws as many.) maxSequences: 14_400, keepOrder: true, + // Points drawn at most, over every tile (each becomes ~8 ground-line + // vertices, ~1.4 kB of geometry): the simplified Paris z10 tile alone + // holds 57,000, unsimplified 750,000, which hung the tab. + maxPoints: 90_000, lineWidthPx: 2.5, overviewPointPx: 4, moveDebounceMs: 320, @@ -56,6 +60,12 @@ export const COVERAGE_TUNING = Object.freeze({ viewRangePerHeight: 10, viewNearM: 1_000, }); +/** + * Lines are simplified (Douglas–Peucker) to this many tile units of a + * 4,096 extent: under a pixel at every height lines are drawn from, and + * 6–10% of the points GPS tracks carry. + */ +export const SIMPLIFY_TOLERANCE_UNITS = 4; /** * Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells * (1.9 MB), drawn as this many binned points. @@ -69,9 +79,9 @@ export const NO_COVERAGE_HINT = 'no Panoramax photos in view'; export const FILTERED_HINT = 'no Panoramax photos here match the filters'; export const UNREADABLE_TILE_MESSAGE = 'Panoramax sent a map tile that could not be read'; -export const GONE_MESSAGE = 'This Panoramax picture is no longer available'; +export const GONE_MESSAGE = 'This Panoramax photo is no longer available'; /** From high up the dots are where pictures are, not sequences to click. */ -export const OVERVIEW_HINT = 'zoom in to see Panoramax sequences'; +export const OVERVIEW_HINT = 'zoom in to see Panoramax coverage'; /** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */ export const PICTURE_TILE_ZOOM = 15; @@ -84,11 +94,14 @@ export const API_TIMEOUT_MS = 12_000; export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; /** * The meta-catalogue speaks HTTP/1.1 only, so the browser opens six - * connections to it: tiles take three, searches two, so a click's lookup - * never queues behind megabyte tiles (it timed out doing so). + * connections to it: coverage tiles take three, searches two and the + * cones' tiles one, so a click's lookup never queues behind megabyte tiles + * (it timed out doing so). */ export const API_CONCURRENCY = 2; export const TILE_CONCURRENCY = 3; +/** The selected sequence's z15 tiles (cones) take the sixth. */ +export const PICTURE_TILE_CONCURRENCY = 1; /** Pictures asked for around a point; the API returns them nearest first. */ export const NEAREST_LIMIT = 10; @@ -201,10 +214,18 @@ function creatorOf(item) { .map((p) => p.name) : []), ]; - const name = names.find((value) => typeof value === 'string' && value.trim()); - if (name && !looksLikeEmail(name)) return name.trim().slice(0, 80); - // Licences ask for the source to be named: the instance that holds it. - return instanceOf(item); + // Every producer is credited (an uploading app and its photographer), + // each name once, none that is an email address. + const credited = []; + for (const value of names) { + if (typeof value !== 'string') continue; + const name = value.trim().slice(0, 80); + if (!name || looksLikeEmail(value)) continue; + if (credited.some((other) => other.toLowerCase() === name.toLowerCase())) + continue; + credited.push(name); + } + return credited.length ? credited.join(', ').slice(0, 120) : null; } /** The federated instance holding a picture (its `via` link's host), or null. */ @@ -259,6 +280,9 @@ export function normalizeItem(item) { isPano: isEquirectangular(item), capturedAt: panoramaxTimeMs(props.datetime), creator: creatorOf(item), + // Licences ask for the source to be named: the instance holding it is + // named beside an author, or alone where the author is hidden. + via: instanceOf(item), license: licenseLabel(props.license), width: Number.isFinite(width) && width > 0 ? width : null, prevId: linkedId(item, 'prev'), diff --git a/src/layers/streetLevel/providers/panoramax/policy.test.mjs b/src/layers/streetLevel/providers/panoramax/policy.test.mjs index 57665f025..6d1455bfb 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/policy.test.mjs @@ -226,17 +226,34 @@ test('hostile item data: a malformed link escape, null coordinates, a huge produ ); }); -test('a picture whose producer is hidden is credited to the instance holding it', () => { - const item = stacItem({ - id: 'a', - lon: 1, - lat: 2, - producer: 'someone@ign.example.fr', - }); +test('the instance holding a picture is named (its via host), never as its author', () => { + const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: 'someone@ign.example.fr' }); item.links.push({ rel: 'via', href: 'https://panoramax.ign.fr' }); - assert.equal(normalizeItem(item).creator, 'panoramax.ign.fr'); + assert.equal(normalizeItem(item).creator, null); + assert.equal(normalizeItem(item).via, 'panoramax.ign.fr'); item.links.at(-1).href = 'http://insecure.example'; - assert.equal(normalizeItem(item).creator, null); + assert.equal(normalizeItem(item).via, null); item.links.at(-1).href = 'not a url'; - assert.equal(normalizeItem(item).creator, null); + assert.equal(normalizeItem(item).via, null); +}); + +test('every producer is credited once: the uploading app and its photographer', () => { + const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: 'mapcomplete' }); + item.providers = [ + { name: 'mapcomplete', roles: ['producer'] }, + { name: 'pyrog', roles: ['producer'] }, + { name: 'MapComplete', roles: ['producer'] }, + { name: 'someone@example.org', roles: ['producer'] }, + { name: 'host', roles: ['host'] }, + ]; + assert.equal(normalizeItem(item).creator, 'mapcomplete, pyrog'); + const hidden = stacItem({ id: 'b', lon: 1, lat: 2, producer: 'a@b.org' }); + hidden.providers = [{ name: 'c@d.org', roles: ['producer'] }]; + assert.equal(normalizeItem(hidden).creator, null); + const many = stacItem({ id: 'c', lon: 1, lat: 2 }); + many.providers = Array.from({ length: 50 }, (_, i) => ({ + name: `producer-${i}`, + roles: ['producer'], + })); + assert.ok(normalizeItem(many).creator.length <= 120); }); diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 3ce1ee490..f8c57b159 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -535,22 +535,55 @@ test('the cones come in capture order, and a broken neighbour tile only thins th assert.ok(await until(() => cones.count() === 5)); assert.deepEqual(cones.ids(), ['s-4', 's-3', 's-2', 's-1', 's-0']); // A neighbour answers with garbage: the home tile's cones still draw. - const home = new Set(); - env.source.getTile = async (tile) => { - const key = `${tile.z}/${tile.x}/${tile.y}`; - if (!home.size) home.add(key); - if (!home.has(key)) return new TextEncoder().encode('oops'); - return tiles(tile); - }; - await env.instance.viewer.open('s-0'); - await env.instance.selectSequence('s'); +}); + +test('a broken neighbour z15 tile only thins the cones; an unreadable home tile reads as such', async (t) => { + // At a tile corner: the home tile and three neighbours are asked. + const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15); + const box = tileBounds(home.tile.x, home.tile.y, 15); + const at = { lon: box.west + 0.0003, lat: box.north - 0.0002 }; + const items = [0, 1, 2].map((i) => + stacItem({ id: `c-${i}`, sequenceId: 'c', lon: at.lon + i * 0.00005, lat: at.lat }), + ); + const tiles = pictureTiles(items); + const broken = new TextEncoder().encode('oops'); + const homeKey = `15/${home.tile.x}/${home.tile.y}`; + const env = await mounted({ + items, + tiles: (tile) => + `${tile.z}/${tile.x}/${tile.y}` === homeKey ? tiles(tile) : broken, + }); + const cones = await withCones(t, env); + await env.instance.viewer.open('c-0'); + await env.instance.selectSequence('c'); assert.ok(await until(() => !env.instance.sequenceStats().loading)); - // The home tile itself unreadable: a readable error, not a parser's. - env.source.getTile = async () => - new TextEncoder().encode('oops'); - await env.instance.selectSequence('s'); - assert.ok(await until(() => env.actions.errors.some(Boolean))); - assert.match(env.actions.errors.filter(Boolean).at(-1), /could not be read/); + assert.equal(cones.count(), 3, 'the home tile’s cones draw'); + assert.ok(env.source.calls.tiles.length > 1, 'neighbours were asked'); + // Another sequence whose home tile is unreadable too. + const env2 = await mounted({ items, tiles: () => broken }); + await withCones(t, env2); + await env2.instance.viewer.open('c-0'); + await env2.instance.selectSequence('c'); + assert.ok(await until(() => env2.actions.errors.some(Boolean))); + assert.match(env2.actions.errors.filter(Boolean).at(-1), /could not be read/); +}); + +test('cones re-centred into tiles already read do not read them again', async (t) => { + const items = sequence('long', 400); + const env = await mounted({ items, tiles: pictureTiles(items) }); + await withCones(t, env); + await env.instance.viewer.open('long-0'); + await env.instance.selectSequence('long'); + assert.ok(await until(() => env.instance.sequenceStats().images > 0)); + await env.instance.viewer.open('long-399'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + const asked = env.source.calls.tiles.length; + // Back to the start: its tiles are kept. + await env.instance.viewer.open('long-0'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(env.source.calls.tiles.length, asked); + assert.ok(env.instance.sequenceStats().images > 0); }); test('› within the tiles the cones came from reloads nothing', async (t) => { @@ -748,15 +781,18 @@ test('Esc in the docked viewer clears the selected sequence before the panel can await env.instance.selectSequence('s'); assert.ok(await until(() => env.instance.sequenceStats().images === 3)); const root = env.next.parentNode; - const event = { + const flags = { stopped: false, prevented: false }; + root.fire('keydown', { key: 'Escape', - stopped: false, - stopPropagation() { - this.stopped = true; - }, - }; - root.fire('keydown', event); + stopPropagation: () => (flags.stopped = true), + preventDefault: () => (flags.prevented = true), + }); assert.equal(env.instance.sequenceStats().selectedId, null); + assert.deepEqual( + flags, + { stopped: true, prevented: true }, + 'consumed: the panel never sees it', + ); // Nothing selected: Esc goes on to the panel. const again = { key: 'Escape', defaultPrevented: false }; const result = { prevented: false }; @@ -768,3 +804,132 @@ test('Esc in the docked viewer clears the selected sequence before the panel can }); assert.equal(result.prevented, false); }); + +test('cones near the east and south edges read those neighbours; near both edges of a narrow tile, both sides', () => { + const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15); + const box = tileBounds(home.tile.x, home.tile.y, 15); + const { x, y } = home.tile; + const keys = (point) => pictureTilesAround(point).map((t) => `${t.x}/${t.y}`); + assert.deepEqual(keys({ lon: box.east - 0.0002, lat: box.south + 0.0002 }), [ + `${x}/${y}`, + `${x + 1}/${y}`, + `${x}/${y + 1}`, + `${x + 1}/${y + 1}`, + ]); + // Svalbard: a z15 tile is ~250 m wide, so its middle is near both sides. + const north = tileCentre(15.6, 78.22, 15); + const around = keys(north); + for (const dx of [-1, 1]) + assert.ok( + around.includes(`${north.tile.x + dx}/${north.tile.y}`), + `${dx} in ${around}`, + ); + assert.equal(around[0], `${north.tile.x}/${north.tile.y}`, 'its own first'); +}); + +test('a search hit outside the radius (a bounding box’s corner) is not the nearest', async () => { + const corner = stacItem({ id: 'corner', sequenceId: 'c', ...east(65) }); + const { instance, source } = setup({ items: [corner] }); + source.nearestItems = async () => [corner]; + assert.equal(await instance.nearestImage(east(0)), null); +}); + +test('a line clicked from low down still searches its own sequence within 50 m', async () => { + const items = [...sequence('other', 1, { offset: 2 }), ...sequence('clicked', 1, { offset: 30 })]; + const { instance, source } = setup({ items }); + instance.init({ + camera: { positionCartographic: { height: 300 } }, + scene: { + primitives: { add() {}, remove() {} }, + preRender: { addEventListener: () => () => {} }, + }, + }); + instance.handlePick(`${PICK_PREFIX.sequence}clicked`, { + groundPoint: () => east(0), + }); + assert.equal(await instance.nearestImage(east(0)), 'clicked-0'); + assert.equal(source.calls.nearest[0].radius, 50); +}); + +test('cones re-centre on a picture that opens while they are still loading', async (t) => { + const items = sequence('long', 400); + const tiles = pictureTiles(items); + let release = null; + const env = await mounted({ items, tiles }); + await withCones(t, env); + await env.instance.viewer.open('long-0'); + env.source.getTile = async (tile) => { + env.source.calls.tiles.push(`${tile.z}/${tile.x}/${tile.y}`); + if (release === null) await new Promise((go) => (release = go)); + return tiles(tile); + }; + const first = env.instance.selectSequence('long'); + assert.ok(await until(() => release !== null), 'the first load is held'); + // The far end opens while the cones around long-0 still load. + await env.instance.viewer.open('long-399'); + release(); + await first; + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + const [lon, lat] = items[399].geometry.coordinates; + const [far] = pictureTilesAround({ lon, lat }); + assert.ok( + env.source.calls.tiles.includes(`15/${far.x}/${far.y}`), + `the far picture's tile was read: ${env.source.calls.tiles}`, + ); +}); + +test('a filter that hides every density dot says so', async (t) => { + const env = coverageSetup(t, { + height: 200_000, + filter: { pano: 'pano', sinceMs: null }, + grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 5 }], + }); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().hint === FILTERED_HINT)); +}); + +test('dots are drawn at their own sizes, and the busiest is the biggest', async (t) => { + const env = coverageSetup(t, { + height: 200_000, + grid: [ + { lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 1 }, + { lon: ORIGIN.lon + 3, lat: ORIGIN.lat, pano: 30_000, flat: 20_000 }, + ], + }); + env.instance.activate(env.viewer); + const sizes = () => { + const out = []; + for (const primitive of env.viewer.scene.primitives.items) + for (let i = 0; i < (primitive.length ?? 0); i++) + if (primitive.get(i).pixelSize) out.push(primitive.get(i).pixelSize); + return out.sort((a, b) => a - b); + }; + assert.ok(await until(() => sizes().length === 2)); + assert.deepEqual(sizes(), [ + overviewPointSize({ weight: 1 }), + overviewPointSize({ weight: 50_000 }), + ]); +}); + +test('lines keep the decoded (longest-first) order under a budget of points', async () => { + const { COVERAGE_TUNING } = await import('./policy.js'); + assert.equal(COVERAGE_TUNING.keepOrder, true); + assert.ok(COVERAGE_TUNING.maxPoints > 0); +}); + +test('a coverage tile decoded a moment ago is not fetched or decoded again', async (t) => { + const env = coverageSetup(t, { sequences: [line('a')] }); + let asked = 0; + const getTile = env.source.getTile; + env.source.getTile = (tile, options) => { + asked++; + return getTile(tile, options); + }; + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + const first = asked; + env.instance.deactivate(); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(asked, first); +}); diff --git a/src/layers/streetLevel/providers/panoramax/source.js b/src/layers/streetLevel/providers/panoramax/source.js index 58052d83c..1217b19bf 100644 --- a/src/layers/streetLevel/providers/panoramax/source.js +++ b/src/layers/streetLevel/providers/panoramax/source.js @@ -1,4 +1,7 @@ import { createLimiter } from '../../limiter.js'; + +/** Longest wait a Retry-After is honoured for. */ +const RETRY_AFTER_MAX_SEC = 300; import { API_CONCURRENCY, API_TIMEOUT_MS, @@ -8,6 +11,7 @@ import { NEAREST_LIMIT, NEAREST_RADIUS_M, PANORAMAX_API, + PICTURE_TILE_CONCURRENCY, TILE_CONCURRENCY, TILE_TIMEOUT_MS, } from './policy.js'; @@ -23,12 +27,15 @@ export class PanoramaxSourceError extends Error { } const NO_ANSWER = 'Panoramax did not answer in time — try again'; +const UNREACHABLE = 'Panoramax could not be reached — check the connection'; /** 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; + if (!Number.isFinite(value) || value < 0) return null; + // A maintenance page's hour must not freeze coverage for an hour. + return Math.min(RETRY_AFTER_MAX_SEC, Math.max(1, value)); } /** The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the headers say. */ @@ -79,6 +86,11 @@ export function createPanoramaxSource({ const root = api.replace(/\/+$/, ''); const apiLimiter = createLimiter(API_CONCURRENCY, { lifo: true }); const tileLimiter = createLimiter(TILE_CONCURRENCY); + // The cones' z15 tiles have their own connection: a selection never + // waits behind coverage tiles. + const pictureLimiter = createLimiter(PICTURE_TILE_CONCURRENCY, { + lifo: true, + }); /** GET within `timeoutMs`; the caller's abort wins over the timeout. */ async function get(url, { signal, timeoutMs, empty = [] }) { @@ -108,7 +120,8 @@ export function createPanoramaxSource({ if (signal?.aborted) throw signal.reason; if (controller.signal.aborted) throw controller.signal.reason; if (error instanceof PanoramaxSourceError) throw error; - throw new PanoramaxSourceError(NO_ANSWER); + // Offline, or a CORS or TLS failure: not a timeout, not worth a retry. + throw new PanoramaxSourceError(UNREACHABLE); } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); @@ -146,9 +159,10 @@ export function createPanoramaxSource({ api: root, /** Raw vector-tile bytes; an empty array where there is no coverage. */ - async getTile({ x, y, z }, { signal } = {}) { + async getTile({ x, y, z }, { signal, lane = 'coverage' } = {}) { const url = `${root}/map/${z}/${x}/${y}.mvt`; - const bytes = await tileLimiter.run( + const gate = lane === 'pictures' ? pictureLimiter : tileLimiter; + const bytes = await gate.run( () => get(url, { signal, timeoutMs: TILE_TIMEOUT_MS, empty: [204, 404] }), { signal }, @@ -193,27 +207,51 @@ export function createPanoramaxSource({ const dLat = metres / 111_320; const dLon = metres / (111_320 * Math.max(0.01, Math.cos((lat * Math.PI) / 180))); - const box = [lon - dLon, lat - dLat, lon + dLon, lat + dLat].map((v) => - v.toFixed(7), - ); - const [facing, around] = await Promise.all([ - search( - { + // Clamped to the map: the API finds nothing in a box past ±180°. + const box = [ + Math.max(-180, lon - dLon), + Math.max(-90, lat - dLat), + Math.min(180, lon + dLon), + Math.min(90, lat + dLat), + ].map((v) => v.toFixed(7)); + // One search failing (busy, no answer) leaves the other's pictures; + // only both failing fails the lookup. The caller's abort stops both. + const both = new AbortController(); + const onAbort = () => both.abort(signal.reason); + if (signal?.aborted) throw signal.reason; + signal?.addEventListener('abort', onAbort, { once: true }); + const run = (params) => + search(params, { signal: both.signal }).catch((error) => { + if (!both.signal.aborted && !signal?.aborted) failures.push(error); + throw error; + }); + const failures = []; + let settled; + try { + settled = await Promise.allSettled([ + run({ place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`, place_distance: `0-${metres}`, place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG), limit: String(Math.min(100, Math.max(1, limit))), ...shared, - }, - { signal }, - ), - search( - { bbox: box.join(','), limit: String(NEAREST_BOX_LIMIT), ...shared }, - { signal }, - ), - ]); + }), + run({ + bbox: box.join(','), + limit: String(NEAREST_BOX_LIMIT), + ...shared, + }), + ]); + } finally { + signal?.removeEventListener('abort', onAbort); + } + if (signal?.aborted) throw signal.reason; + const found = settled + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value); + if (!found.length) throw failures[0] ?? settled[0].reason; const byId = new Map(); - for (const item of [...facing, ...around]) + for (const item of found.flat()) if (item?.id != null && !byId.has(String(item.id))) byId.set(String(item.id), item); return [...byId.values()]; diff --git a/src/layers/streetLevel/providers/panoramax/source.test.mjs b/src/layers/streetLevel/providers/panoramax/source.test.mjs index e0111a33e..b54134147 100644 --- a/src/layers/streetLevel/providers/panoramax/source.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/source.test.mjs @@ -217,7 +217,7 @@ test('the caller’s abort wins; a silent API times out with a readable message' await assert.rejects(slow, /did not answer in time/); }); -test('a network failure reads as no answer, not as a stack trace', async () => { +test('a network failure reads as unreachable, not as a stack trace', async () => { const source = createPanoramaxSource({ fetchImpl: async () => { throw new TypeError('Failed to fetch'); @@ -225,7 +225,7 @@ test('a network failure reads as no answer, not as a stack trace', async () => { }); await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), (error) => { assert.ok(error instanceof PanoramaxSourceError); - assert.match(error.message, /did not answer/); + assert.match(error.message, /could not be reached/); return true; }); }); @@ -293,3 +293,68 @@ test('the HTTP/1.1 API never gets more than five requests at once: three tiles, } assert.equal(most, 5, 'never more'); }); + +test('one of the two nearest searches failing leaves the other’s pictures; both failing fails the lookup', async () => { + const half = createPanoramaxSource({ + fetchImpl: fakeFetch((url) => + url.includes('bbox') + ? { status: 503 } + : json({ features: [{ id: 'facing' }] }), + ).fetchImpl, + }); + assert.deepEqual( + (await half.nearestItems({ lat: 1, lon: 2 })).map((i) => i.id), + ['facing'], + ); + const none = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ status: 503 })).fetchImpl, + }); + await assert.rejects(none.nearestItems({ lat: 1, lon: 2 }), /busy/); +}); + +test('only a timeout is asked again: a busy or failing API is asked once, an unreachable one says so', async () => { + for (const status of [429, 500, 503]) { + const { urls, fetchImpl } = fakeFetch(() => ({ status })); + const source = createPanoramaxSource({ fetchImpl }); + await assert.rejects(source.getItem('a')); + assert.equal(urls.length, 1, `HTTP ${status}: one fetch`); + } + let calls = 0; + const offline = createPanoramaxSource({ + fetchImpl: async () => { + calls++; + throw new TypeError('Failed to fetch'); + }, + }); + await assert.rejects(offline.getItem('a'), /could not be reached/); + assert.equal(calls, 1, 'not retried'); +}); + +test('a Retry-After is honoured up to five minutes', async () => { + const wait = async (header) => { + const source = createPanoramaxSource({ + fetchImpl: fakeFetch(() => ({ + status: 503, + headers: { 'retry-after': header }, + })).fetchImpl, + }); + return source.getTile({ x: 0, y: 0, z: 0 }).catch((error) => error.retryAfterSec); + }; + assert.equal(await wait('3600'), 300); + assert.equal(await wait('99999999999'), 300); + assert.equal(await wait('0'), 1); + assert.equal(await wait('12'), 12); +}); + +test('a lookup near the antimeridian keeps its box on the map', async () => { + const { urls, fetchImpl } = fakeFetch(() => json({ features: [] })); + const source = createPanoramaxSource({ fetchImpl }); + await source.nearestItems({ lat: -16, lon: 179.9999 }); + const box = urls + .map((u) => new URL(u).searchParams.get('bbox')) + .find(Boolean) + .split(',') + .map(Number); + assert.equal(box[2], 180); + assert.ok(box[0] < 180); +}); diff --git a/src/layers/streetLevel/providers/panoramax/viewer.js b/src/layers/streetLevel/providers/panoramax/viewer.js index 90ef88c97..3e26e4d56 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.js +++ b/src/layers/streetLevel/providers/panoramax/viewer.js @@ -36,9 +36,11 @@ export const PANORAMAX_SOURCES = Object.freeze({ const width = Math.min(photo.width, PANO_FULL_SIZE[0]); return { url: photo.urls.hd, width, resize: [width, width / 2] }; }, - panoSharp: (photo, size) => { + // The original is several megabytes with no server to shrink it: only + // for the expanded viewer (a docked one on a dense screen asks too). + panoSharp: (photo, size, { expanded = false } = {}) => { const width = photo.width || 0; - if (!photo.urls.hd || !photo.urls.sd) return null; + if (!expanded || !photo.urls.hd || !photo.urls.sd) return null; // An original no wider than the SD one adds nothing; a huge one would // take hundreds of megabytes to decode. if (width <= SD_WIDTH || width > PANO_HD_MAX_WIDTH) return null; @@ -59,7 +61,8 @@ export const PANORAMAX_SOURCES = Object.freeze({ flatSharp: (photo, pixels) => photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null, describe: (photo) => ({ - title: null, + // Where no author can be named, the instance holding the photo is. + title: !photo.creator && photo.via ? `via ${photo.via}` : null, creator: photo.creator, license: photo.license, externalUrl: panoramaxPictureUrl(photo.id), diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs index 3dd5e7a1b..c5c1fa19b 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -150,7 +150,15 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09 assert.equal(poses.at(-1).bearing, 100); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(loads.length, 1, 'docked: the 2,048 px SD is enough'); + // Docked on a dense screen the canvas wants more, but the original is + // megabytes: not until the viewer is expanded. canvas.clientWidth = 1800; + viewer.resize(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(loads.length, 1, 'docked: still SD only'); + const root = canvas.parentNode; + root.closest = (selector) => + selector.includes('sl-viewer-wrap-expanded') ? {} : null; // EXPAND resizes more than once while the panel settles. viewer.resize(); viewer.resize(); @@ -165,14 +173,19 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09 assert.equal(hd.url, PHOTOS.pano.urls.hd); assert.deepEqual(hd.resize, [4096, 2048]); assert.equal(hd.timeoutMs, 30_000); + assert.ok(hd.signal, 'abortable'); }); -test('HD panoramas too big to decode, or no bigger than SD, are never fetched', () => { +test('HD panoramas too big to decode, or no bigger than SD, are never fetched; nor while docked', () => { const size = [4096, 2048]; - assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size), null); - assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size), null); + const expanded = { expanded: true }; + assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size, { expanded: false }), null); + assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size), null); + assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size, expanded), null); + assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size, expanded), null); assert.deepEqual( - PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size).resize, + PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size, expanded) + .resize, [3000, 1500], 'never enlarged', ); @@ -190,7 +203,7 @@ test('HD panoramas too big to decode, or no bigger than SD, are never fetched', assert.deepEqual(PANORAMAX_SOURCES.flatFirst(hugeOnly).filter(Boolean), [ hugeOnly.urls.thumb, ]); - assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size), null); + assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size, expanded), null); assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null); assert.equal( PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2049), @@ -258,3 +271,18 @@ test('a picture whose SD fails falls back to HD, then the thumbnail', async () = ]); assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb); }); + +test('a photo with no author to name is captioned with the instance holding it', () => { + const photo = normalizeItem( + stacItem({ id: 'x', lon: 1, lat: 2, producer: 'someone@city.example.fr' }), + ); + photo.via = 'panoramax.ign.fr'; + assert.deepEqual(PANORAMAX_SOURCES.describe(photo), { + title: 'via panoramax.ign.fr', + creator: null, + license: 'CC BY-SA 4.0', + externalUrl: 'https://api.panoramax.xyz/#pic=x&focus=pic', + }); + const named = { ...photo, creator: 'Robot8A' }; + assert.equal(PANORAMAX_SOURCES.describe(named).title, null); +}); diff --git a/src/layers/streetLevel/tileCache.js b/src/layers/streetLevel/tileCache.js new file mode 100644 index 000000000..55d997cdb --- /dev/null +++ b/src/layers/streetLevel/tileCache.js @@ -0,0 +1,48 @@ +/** Decoded coverage tiles kept, and for how long. */ +export const TILE_CACHE_SIZE = 24; +export const TILE_CACHE_TTL_MS = 10 * 60_000; + +/** Least-recently-used map: reading an entry renews it. */ +export 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 (decoded) coverage tiles by `z/x/y`, at most `size`, each for + * `ttlMs`: a pan back, or a zoom across bands and back, neither fetches nor + * decodes them again. A load that fails is not kept. + */ +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; + }, + }; +} diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 070fe3d91..259934b73 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -673,3 +673,15 @@ test('an adapter that can frame but says this image has no frame offers no FIT / await host.open('mapillary', 'flat-1'); assert.equal(state.street.renderModes, true, 'a flat image can be framed'); }); + +test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => { + const { state, host, google } = twoProviders(); + await host.open('google', 'g-1'); + google.emitPose('g-1', { providerId: 'google', license: 'CC BY-SA 4.0' }); + assert.equal(state.street.license, 'CC BY-SA 4.0'); + google.emitPose('g-1', { providerId: 'google' }); + assert.equal(state.street.license, null, 'none given: none shown'); + google.emitPose('g-1', { providerId: 'google', license: 'CC0' }); + host.close(); + assert.equal(state.street.license, null); +}); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index c4eb22dd3..bdd6cf9ac 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -810,6 +810,18 @@ test('Panoramax fixtures: two sequences on one street, searches honour sequence true, 'the second sequence is older', ); + // A box has every picture in it, those looking away included; a place + // search leaves out pictures the point is behind (all look east). + const d = 0.0002; + const boxed = ask( + `bbox=${at.lon - d},${at.lat - d},${at.lon + d},${at.lat + d}&limit=100`, + ); + assert.ok(boxed.some((p) => p.lon < at.lon) && boxed.some((p) => p.lon > at.lon)); + const placed = ask( + `place_position=${at.lon},${at.lat}&place_distance=0-30&limit=50`, + ); + assert.ok(placed.length > 0); + assert.ok(placed.every((p) => p.lon <= at.lon + 1e-7), 'none it is behind'); const one = ask(`ids=${at.id}&limit=1`); assert.equal(one[0].id, at.id); // Its pictures, and only those, are served; HD downloads are counted. diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index 5425afd75..5fba550f7 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -539,3 +539,22 @@ test('KEY REQUIRED points at Street View when it would draw with the Google key' assert.equal(plain.status.text, 'KEY REQUIRED'); assert.notEqual(plain.meta, guidance); }); + +test('the caption names an image’s own licence after its author, and leaves it out when there is none', () => { + const caption = (street) => + presentStreetLevelPanel({ + enabled: true, + providers: [], + coverage: {}, + legend: [], + filter: {}, + sequence: {}, + street: { open: true, imageId: '1', ...street }, + }).viewer.captionLeft; + assert.equal( + caption({ creator: 'Fixture Mapper', license: 'CC BY-SA 4.0' }), + 'Image by Fixture Mapper · CC BY-SA 4.0', + ); + assert.equal(caption({ license: 'Licence Ouverte 2.0' }), 'Licence Ouverte 2.0'); + assert.equal(caption({ creator: 'Someone' }), 'Image by Someone'); +}); diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index ea9efee1c..433beebbc 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -651,7 +651,7 @@ button.sl-status:focus-visible { pointer-events: none; } -/* White on #ff6f00 is 2.9:1; the darker orange behind ‹ › is 4.6:1. */ +/* White on #ff6f00 is 2.8:1; on the darker orange behind ‹ › it is 5.1:1. */ .sl-photo-viewer[data-provider='panoramax'] { --sl-photo-ring: var(--sl-panoramax); --sl-photo-fill: var(--sl-panoramax-fill); @@ -720,7 +720,8 @@ button.sl-status:focus-visible { } /* Nothing to step through until a photo shows. */ -.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-photo-step { +.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano']) + .sl-photo-step { display: none; } From 1a546bec72cdb6f3d6aa50e2c26121a71aec9a1f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:25:58 +0100 Subject: [PATCH 10/17] fix(street-level): one keydown listener on the photo root; format scope listed a test twice Esc moved to the viewer's box (`.sl-viewer-wrap`) in the last commit, but without one it was a second keydown listener on the root; Esc is now taken by the root's own handler there. format-scope.json listed panorama.test.mjs twice, which made the formatter refuse the scope; the files it skipped are formatted. Co-Authored-By: Claude Opus 5.5 --- scripts/format-scope.json | 1 - scripts/qa-street-level.mjs | 5 ++++- src/layers/streetLevel/limiter.test.mjs | 6 +++++- src/layers/streetLevel/lineCoverage.test.mjs | 18 +++++++++++++++--- src/layers/streetLevel/photoViewer.js | 12 ++++++++---- src/layers/streetLevel/photoViewer.test.mjs | 9 +++++---- .../streetLevel/providers/panoramax/decode.js | 3 +-- .../providers/panoramax/decode.test.mjs | 16 +++++++++++++--- .../providers/panoramax/policy.test.mjs | 7 ++++++- .../providers/panoramax/provider.test.mjs | 16 +++++++++++++--- .../providers/panoramax/source.test.mjs | 4 +++- .../providers/panoramax/viewer.test.mjs | 15 ++++++++++++--- src/tooling/streetLevelQa.test.mjs | 9 +++++++-- src/ui/streetLevelPresentation.test.mjs | 5 ++++- 14 files changed, 96 insertions(+), 30 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 53d6a5b67..8cb8b89a8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -937,7 +937,6 @@ "src/testSupport/panoramaxFixtures.mjs", "src/layers/streetLevel/photoViewer.test.mjs", "src/layers/streetLevel/limiter.test.mjs", - "src/layers/streetLevel/panorama.test.mjs", "src/layers/streetLevel/lineCoverage.test.mjs", "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 9c8c55072..7c131a220 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1276,7 +1276,10 @@ async function main() { }, ) .catch(() => null); - assert.ok(aimed, 'the line is pickable over the other’s pictures'); + assert.ok( + aimed, + 'the line is pickable over the other’s pictures', + ); at = await aimed.jsonValue(); } const parked = await camera(); diff --git a/src/layers/streetLevel/limiter.test.mjs b/src/layers/streetLevel/limiter.test.mjs index c9fabef68..1137fe2f6 100644 --- a/src/layers/streetLevel/limiter.test.mjs +++ b/src/layers/streetLevel/limiter.test.mjs @@ -70,7 +70,11 @@ test('a task already aborted when its turn comes is skipped', async () => { const gate = createLimiter(1); const first = held(log, 'a'); const running = gate.run(first.task); - const signal = { aborted: false, reason: new Error('gone'), addEventListener() {} }; + const signal = { + aborted: false, + reason: new Error('gone'), + addEventListener() {}, + }; const queued = gate.run(held(log, 'b').task, { signal }); signal.aborted = true; first.finish(); diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index db4b5449f..51ac366c3 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -217,17 +217,29 @@ test('overview points take the provider’s size, each its own', async (t) => { test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => { const { state, coverage } = setup(t, { - tuning: { ...TUNING, maxTiles: 1, maxSequences: 10, maxPoints: 5, keepOrder: true }, + tuning: { + ...TUNING, + maxTiles: 1, + maxSequences: 10, + maxPoints: 5, + keepOrder: true, + }, decoded: (centre) => { const line = (id, n) => ({ id, parts: [ - Array.from({ length: n }, (_, i) => [centre.lon - 0.001 + i * 0.0001, centre.lat]), + Array.from({ length: n }, (_, i) => [ + centre.lon - 0.001 + i * 0.0001, + centre.lat, + ]), ], capturedAt: 0, isPano: false, }); - return { sequences: [line('three', 3), line('four', 4), line('two', 2)], overview: [] }; + return { + sequences: [line('three', 3), line('four', 4), line('two', 2)], + overview: [], + }; }, }); coverage.refresh(); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index 94da38f47..b4fb67398 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -178,6 +178,8 @@ export function createPhotoViewer({ let documentOf = null; /** A context loss's look direction, for the reopen of that photo. */ let restoreView = null; + /** The panel's viewer box, where Esc is listened for (null: the root). */ + let escapeTarget = null; /** Width of the panorama texture on screen (0: the preview). */ let panoWidth = 0; /** The sharper panorama on its way, for which open (resizes come in bursts). */ @@ -450,6 +452,7 @@ export function createPhotoViewer({ } function onKeyDown(event) { + if (!escapeTarget) onEscapeKey(event); if ( event.defaultPrevented || event.altKey || @@ -528,7 +531,10 @@ export function createPhotoViewer({ root.append(picture, canvas, prevButton, nextButton); host.append(root); listen(root, 'keydown', onKeyDown); - listen(host.closest?.('.sl-viewer-wrap') ?? root, 'keydown', onEscapeKey); + // The viewer's box in the panel; without one, the root's own handler + // takes Esc (one listener per element and type). + escapeTarget = host.closest?.('.sl-viewer-wrap') ?? null; + if (escapeTarget) listen(escapeTarget, 'keydown', onEscapeKey); applyRenderMode(); } @@ -564,9 +570,7 @@ export function createPhotoViewer({ if (size[0] <= panoWidth) return; if (panoComing?.seq === seq && size[0] <= panoComing.width) return; const sharp = sources.panoSharp(target, size, { - expanded: Boolean( - root?.closest?.('.sl-viewer-wrap-expanded'), - ), + expanded: Boolean(root?.closest?.('.sl-viewer-wrap-expanded')), }); if (!sharp?.url) return; const coming = { seq, width: size[0], controller: new AbortController() }; diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 6f29e711e..78397a632 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -178,7 +178,9 @@ test('a sharp panorama still downloading when another photo opens is aborted', a url.endsWith('-sharp') ? new Promise((_, reject) => { signals.push(options.signal); - options.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + options.signal?.addEventListener('abort', () => + reject(new Error('aborted')), + ); }) : Promise.resolve({ src: url }), createRenderer: () => ({ @@ -278,9 +280,8 @@ test('docked, Esc anywhere in the viewer’s box (toolbar, caption) is offered t }); test('a picture file or bitmap past any real photo’s size is refused', async (t) => { - const { loadImage, MAX_IMAGE_BYTES, MAX_IMAGE_SIDE } = await import( - './photoViewer.js' - ); + const { loadImage, MAX_IMAGE_BYTES, MAX_IMAGE_SIDE } = + await import('./photoViewer.js'); const saved = { fetch: globalThis.fetch, createImageBitmap: globalThis.createImageBitmap, diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index d628dec5c..c75a01768 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -78,8 +78,7 @@ export function decodeCoverageTile(bytes, address) { const props = feature.properties || {}; const parts = []; let length = 0; - const tolerance = - (SIMPLIFY_TOLERANCE_UNITS * sequences.extent) / 4096; + const tolerance = (SIMPLIFY_TOLERANCE_UNITS * sequences.extent) / 4096; for (const line of feature.loadGeometry()) { if (line.length < 2) continue; for (let k = 1; k < line.length; k++) diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index 159cf3884..104471a1e 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -191,8 +191,15 @@ test('a z15 tile’s pictures of one sequence become cones; other sequences’ a }); test('lines are simplified within a few tile units, ends and corners kept', () => { - const zigzag = Array.from({ length: 1000 }, (_, i) => ({ x: i, y: i % 2 ? 1 : 0 })); - assert.deepEqual(simplifyLine(zigzag, 4), [zigzag[0], zigzag.at(-1)], 'a 1-unit wobble goes'); + const zigzag = Array.from({ length: 1000 }, (_, i) => ({ + x: i, + y: i % 2 ? 1 : 0, + })); + assert.deepEqual( + simplifyLine(zigzag, 4), + [zigzag[0], zigzag.at(-1)], + 'a 1-unit wobble goes', + ); const corner = [ { x: 0, y: 0 }, { x: 50, y: 1 }, @@ -221,6 +228,9 @@ test('a decoded GPS track keeps a fraction of its points', () => { encodePanoramaxTile(tile, { sequences: [{ id: 'gps', parts: [track] }] }), tile, ).sequences; - assert.ok(sequence.parts[0].length < 100, `${sequence.parts[0].length} points`); + assert.ok( + sequence.parts[0].length < 100, + `${sequence.parts[0].length} points`, + ); assert.ok(sequence.parts[0].length > 5); }); diff --git a/src/layers/streetLevel/providers/panoramax/policy.test.mjs b/src/layers/streetLevel/providers/panoramax/policy.test.mjs index 6d1455bfb..770f52a26 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/policy.test.mjs @@ -227,7 +227,12 @@ test('hostile item data: a malformed link escape, null coordinates, a huge produ }); test('the instance holding a picture is named (its via host), never as its author', () => { - const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: 'someone@ign.example.fr' }); + const item = stacItem({ + id: 'a', + lon: 1, + lat: 2, + producer: 'someone@ign.example.fr', + }); item.links.push({ rel: 'via', href: 'https://panoramax.ign.fr' }); assert.equal(normalizeItem(item).creator, null); assert.equal(normalizeItem(item).via, 'panoramax.ign.fr'); diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index f8c57b159..8cf3911d9 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -543,7 +543,12 @@ test('a broken neighbour z15 tile only thins the cones; an unreadable home tile const box = tileBounds(home.tile.x, home.tile.y, 15); const at = { lon: box.west + 0.0003, lat: box.north - 0.0002 }; const items = [0, 1, 2].map((i) => - stacItem({ id: `c-${i}`, sequenceId: 'c', lon: at.lon + i * 0.00005, lat: at.lat }), + stacItem({ + id: `c-${i}`, + sequenceId: 'c', + lon: at.lon + i * 0.00005, + lat: at.lat, + }), ); const tiles = pictureTiles(items); const broken = new TextEncoder().encode('oops'); @@ -835,7 +840,10 @@ test('a search hit outside the radius (a bounding box’s corner) is not the nea }); test('a line clicked from low down still searches its own sequence within 50 m', async () => { - const items = [...sequence('other', 1, { offset: 2 }), ...sequence('clicked', 1, { offset: 30 })]; + const items = [ + ...sequence('other', 1, { offset: 2 }), + ...sequence('clicked', 1, { offset: 30 }), + ]; const { instance, source } = setup({ items }); instance.init({ camera: { positionCartographic: { height: 300 } }, @@ -885,7 +893,9 @@ test('a filter that hides every density dot says so', async (t) => { grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 5 }], }); env.instance.activate(env.viewer); - assert.ok(await until(() => env.instance.coverageStats().hint === FILTERED_HINT)); + assert.ok( + await until(() => env.instance.coverageStats().hint === FILTERED_HINT), + ); }); test('dots are drawn at their own sizes, and the busiest is the biggest', async (t) => { diff --git a/src/layers/streetLevel/providers/panoramax/source.test.mjs b/src/layers/streetLevel/providers/panoramax/source.test.mjs index b54134147..63eb6ff9e 100644 --- a/src/layers/streetLevel/providers/panoramax/source.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/source.test.mjs @@ -338,7 +338,9 @@ test('a Retry-After is honoured up to five minutes', async () => { headers: { 'retry-after': header }, })).fetchImpl, }); - return source.getTile({ x: 0, y: 0, z: 0 }).catch((error) => error.retryAfterSec); + return source + .getTile({ x: 0, y: 0, z: 0 }) + .catch((error) => error.retryAfterSec); }; assert.equal(await wait('3600'), 300); assert.equal(await wait('99999999999'), 300); diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs index c5c1fa19b..78c39f316 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -179,10 +179,19 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09 test('HD panoramas too big to decode, or no bigger than SD, are never fetched; nor while docked', () => { const size = [4096, 2048]; const expanded = { expanded: true }; - assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size, { expanded: false }), null); + assert.equal( + PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size, { expanded: false }), + null, + ); assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size), null); - assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size, expanded), null); - assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size, expanded), null); + assert.equal( + PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size, expanded), + null, + ); + assert.equal( + PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size, expanded), + null, + ); assert.deepEqual( PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size, expanded) .resize, diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index bdd6cf9ac..564cecadc 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -816,12 +816,17 @@ test('Panoramax fixtures: two sequences on one street, searches honour sequence const boxed = ask( `bbox=${at.lon - d},${at.lat - d},${at.lon + d},${at.lat + d}&limit=100`, ); - assert.ok(boxed.some((p) => p.lon < at.lon) && boxed.some((p) => p.lon > at.lon)); + assert.ok( + boxed.some((p) => p.lon < at.lon) && boxed.some((p) => p.lon > at.lon), + ); const placed = ask( `place_position=${at.lon},${at.lat}&place_distance=0-30&limit=50`, ); assert.ok(placed.length > 0); - assert.ok(placed.every((p) => p.lon <= at.lon + 1e-7), 'none it is behind'); + assert.ok( + placed.every((p) => p.lon <= at.lon + 1e-7), + 'none it is behind', + ); const one = ask(`ids=${at.id}&limit=1`); assert.equal(one[0].id, at.id); // Its pictures, and only those, are served; HD downloads are counted. diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index 5fba550f7..7dbf18179 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -555,6 +555,9 @@ test('the caption names an image’s own licence after its author, and leaves it caption({ creator: 'Fixture Mapper', license: 'CC BY-SA 4.0' }), 'Image by Fixture Mapper · CC BY-SA 4.0', ); - assert.equal(caption({ license: 'Licence Ouverte 2.0' }), 'Licence Ouverte 2.0'); + assert.equal( + caption({ license: 'Licence Ouverte 2.0' }), + 'Licence Ouverte 2.0', + ); assert.equal(caption({ creator: 'Someone' }), 'Image by Someone'); }); From 3ae367d0dd8dba30cb71935ab33404d205dda5a6 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:38:53 +0100 Subject: [PATCH 11/17] =?UTF-8?q?fix(street-level):=20Panoramax=20IC8=20ro?= =?UTF-8?q?und=202=20=E2=80=94=20shared=20point=20budget,=20selected=20lin?= =?UTF-8?q?e,=20cone=20cache,=20DP=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the round-2 verification (approve with follow-ups), all follow-ups: - The point budget is shared across the tiles in view once loading settles (water-filling): a sparse tile keeps what it has, a dense city centre takes the rest instead of drawing thinner than its suburbs. The selected sequence is always drawn, and selecting one a cap had left out draws it. - The cones' picture-list cache keeps only finished lists: a re-centre while the cones loaded inherited the aborted load's tiles and drew none in them. - Douglas–Peucker measures to the segment, so dead ends and U-turns keep their far end. - Switching Panoramax off lets its decoded tiles and lists go. - Picture downloads are counted while they stream (a host sending no length cannot fill memory before the cap). - Comments on the via caption and the simplification tolerance match the code. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 11 ++- src/layers/streetLevel/lineCoverage.js | 99 ++++++++++++++++--- src/layers/streetLevel/lineCoverage.test.mjs | 93 ++++++++++++++++- src/layers/streetLevel/photoViewer.js | 32 +++++- src/layers/streetLevel/photoViewer.test.mjs | 14 +++ .../streetLevel/providers/panoramax/decode.js | 18 ++-- .../providers/panoramax/decode.test.mjs | 7 ++ .../streetLevel/providers/panoramax/index.js | 12 ++- .../streetLevel/providers/panoramax/policy.js | 8 +- .../providers/panoramax/provider.test.mjs | 80 ++++++++++++++- src/layers/streetLevel/tileCache.js | 2 + 11 files changed, 343 insertions(+), 33 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9fc1f5fb..15a80c743 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3256,7 +3256,16 @@ IGN, MapComplete…; IGN's redirect to its OVH object storage). Its chip vector tiles (`/api/map/{z}/{x}/{y}.mvt`, an hour's HTTP cache): sequence lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km, z11 to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is -~2.1 MB and 8,600 sequences; each tile draws its 1,600 longest), and above +~2.1 MB and 8,600 sequences, 750,000 points). `decode.js` simplifies each +line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a +pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in +view draw their longest lines within 90,000 points (`maxPoints`, +`keepOrder`), shared out once loading settles so a sparse tile keeps all +it has and a dense one (a city centre) takes the rest; the selected +sequence is always drawn. Every point becomes ~8 ground-line vertices: +unsimplified, the lines hung the tab. Decoded tiles are kept while the +provider is on (16, ten minutes, `tileCache.js`, shared with KartaView), +and above 60 km the `grid` layer's 0.1° density cells, binned to at most 96×96 points a tile (a z0 tile holds ~70,000 cells) and not counted as sequences: the panel says to zoom in. Both diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 2eb634c9c..84204ae83 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -155,19 +155,85 @@ export function createLineCoverage({ function drawnSequences(entry) { const drawn = []; const current = filter(); + const selectedId = state.sequence.selectedId ?? null; + const cap = entry.pointCap ?? perTilePointCap; let points = 0; + let demand = 0; for (const sequence of entry.sequenceList) { - if (drawn.length >= perTileSequenceCap) break; if (!passes(sequence, current)) continue; - // A line past the point budget is skipped; a shorter one may still fit. const size = pointsOf(sequence); - if (points + size > perTilePointCap) continue; + demand += size; + // The selected sequence is always drawn: its highlight is the answer + // to a click, whatever the caps leave out. + if (sequence.id === selectedId) { + points += size; + drawn.push(sequence); + continue; + } + if (drawn.length >= perTileSequenceCap) continue; + // A line past the point budget is skipped; a shorter one may still fit. + if (points + size > cap) continue; points += size; drawn.push(sequence); } + // What every passing line would take: the budget is shared by it. + entry.pointDemand = demand; + entry.pointsDrawn = points; return drawn; } + /** + * Share the point budget across the tiles in view (water-filling): a + * sparse tile keeps what it needs and the rest goes to the dense ones, + * so a city centre is not drawn thinner than its suburbs. Sets each + * tile's cap; returns the tiles whose cap changes what they draw. + */ + function shareCaps() { + const changed = []; + if (!tuning.maxPoints) return changed; + const entries = [...state.coverage.tiles.values()].filter( + (entry) => entry.kind === 'sequence', + ); + const byDemand = [...entries].sort( + (a, b) => (a.pointDemand || 0) - (b.pointDemand || 0), + ); + let left = tuning.maxPoints; + byDemand.forEach((entry, i) => { + const share = left / (byDemand.length - i); + const cap = Math.floor(Math.min(entry.pointDemand || 0, share)); + left -= cap; + const before = entry.pointCap ?? perTilePointCap; + entry.pointCap = cap; + // Only a change that alters what is drawn: more room for a tile that + // had lines left out, or less than it draws now. + if ( + (cap > before && entry.pointsDrawn < entry.pointDemand) || + cap < entry.pointsDrawn + ) + changed.push(entry); + }); + return changed; + } + + /** Once loading settles: redraw the tiles the shared budget changes. */ + function shareBudget() { + const changed = shareCaps(); + for (const entry of changed) redrawEntry(entry); + if (changed.length) { + requestRender(); + notify(); + } + } + + /** Draw one tile again from its decoded lines (caps or selection changed). */ + function redrawEntry(entry) { + cancelCast(entry); + entry.castRequested = false; + entry.meshParts = null; + detachPrimitive(entry); + attachPrimitive(entry); + } + function pointsOf(sequence) { let count = 0; for (const part of sequence.parts) count += part.length; @@ -616,7 +682,10 @@ export function createLineCoverage({ // 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(); + if (!state.coverage.pending.size) { + purgeStale(); + shareBudget(); + } } notify(); } @@ -809,13 +878,12 @@ export function createLineCoverage({ /** 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); - } + // What each tile's lines need under the new filter, then the shares, + // then one redraw each. + for (const entry of state.coverage.tiles.values()) + if (entry.kind === 'sequence') drawnSequences(entry); + shareCaps(); + for (const entry of state.coverage.tiles.values()) redrawEntry(entry); requestRender(); notify(); } @@ -860,6 +928,15 @@ export function createLineCoverage({ /** Recolour one sequence, every part of it, in place (selection highlight). */ function recolorSequence(id, selected) { + // Selected but left out by a cap: draw it (it is always admitted). + if (selected && (tuning.maxPoints || 0) > 0) + for (const entry of state.coverage.tiles.values()) + if ( + entry.kind === 'sequence' && + !entry.sequences.has(id) && + entry.sequenceList.some((sequence) => sequence.id === id) + ) + redrawEntry(entry); for (const entry of drawnEntries()) { const sequence = entry.sequences.get(id); if (!sequence) continue; diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 51ac366c3..e7b8b693e 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -76,7 +76,7 @@ function setup( fetchTile: async (tile) => { asked.push(tile); if (fail) throw fail; - if (decoded) return decoded(centre); + if (decoded) return decoded(centre, tile); return { sequences: [ { @@ -247,3 +247,94 @@ test('a point budget skips lines past it, and a shorter one later may still fit' const [tile] = state.coverage.tiles.values(); assert.deepEqual([...tile.sequences.keys()], ['three', 'two']); }); + +/** A line of `n` points, its id unique to the tile. */ +function pointsLine(id, n, tile, centre) { + return { + id, + parts: [ + Array.from({ length: n }, (_, i) => [ + centre.lon - 0.001 + i * 0.00001, + centre.lat, + ]), + ], + capturedAt: 0, + isPano: false, + }; +} + +test('the point budget is shared: a sparse tile keeps its few, a dense one gets the rest', async (t) => { + const tuning = { + ...TUNING, + maxTiles: 9, + maxSequences: 900, + maxPoints: 90, + keepOrder: true, + }; + const { state, coverage } = setup(t, { + // Street zoom far enough in that several tiles are in view. + zoomFor: () => ({ zoom: 18, kind: 'sequence' }), + tuning, + decoded: (centre, tile) => { + const dense = tile.x % 2 === 0 && tile.y % 2 === 0; + const lines = dense + ? Array.from({ length: 30 }, (_, i) => + pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre), + ) + : [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)]; + return { sequences: lines, overview: [] }; + }, + }); + coverage.refresh(); + assert.ok( + await until( + () => state.coverage.tiles.size > 1 && !state.coverage.pending.size, + ), + ); + const entries = [...state.coverage.tiles.values()]; + const drawn = (entry) => entry.pointsDrawn; + const total = entries.reduce((sum, entry) => sum + drawn(entry), 0); + assert.ok(total <= tuning.maxPoints, `within the budget: ${total}`); + const sparse = entries.filter((entry) => entry.pointDemand === 2); + const dense = entries.filter((entry) => entry.pointDemand === 150); + assert.ok(sparse.length && dense.length, 'both kinds in view'); + for (const entry of sparse) + assert.equal(drawn(entry), 2, 'a sparse tile keeps all'); + const fair = Math.floor(tuning.maxPoints / tuning.maxTiles); + const left = tuning.maxPoints - sparse.length * 2; + for (const entry of dense) + assert.ok( + drawn(entry) > fair || dense.length * fair >= left, + `a dense tile gets more than the even share: ${drawn(entry)}`, + ); + assert.ok( + total > tuning.maxPoints - 5 * dense.length, + 'little budget unused', + ); +}); + +test('a selected sequence the budget left out is drawn, highlighted', async (t) => { + const { state, coverage } = setup(t, { + tuning: { + ...TUNING, + maxTiles: 1, + maxSequences: 10, + maxPoints: 5, + keepOrder: true, + }, + decoded: (centre, tile) => ({ + sequences: [ + pointsLine('long', 5, tile, centre), + pointsLine('short', 3, tile, centre), + ], + overview: [], + }), + }); + coverage.refresh(); + assert.ok(await until(() => state.coverage.tiles.size === 1)); + const [tile] = state.coverage.tiles.values(); + assert.deepEqual([...tile.sequences.keys()], ['long']); + state.sequence.selectedId = 'short'; + coverage.recolorSequence('short', true); + assert.deepEqual([...tile.sequences.keys()].sort(), ['long', 'short']); +}); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index b4fb67398..0457e9f76 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -79,8 +79,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { // photo's size before it is decoded. const length = Number(response.headers?.get?.('content-length')); if (length > MAX_IMAGE_BYTES) throw new Error(failure); - const blob = await response.blob(); - if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure); + const blob = await boundedBlob(response, failure); const bitmap = await (resize ? createImageBitmap(blob, { resizeWidth: resize[0], @@ -88,6 +87,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { resizeQuality: 'high', }) : createImageBitmap(blob)); + // (A resized decode reports its new size: this guards unresized ones.) if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) { bitmap.close?.(); throw new Error(failure); @@ -101,6 +101,34 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { } } +/** + * The body as a Blob, read with a running count so a host that sends no + * length (or a false one) cannot fill memory before the cap is checked. + */ +async function boundedBlob(response, failure) { + const reader = response.body?.getReader?.(); + if (!reader) { + const blob = await response.blob(); + if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure); + return blob; + } + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_IMAGE_BYTES) { + reader.cancel().catch(() => {}); + throw new Error(failure); + } + chunks.push(value); + } + return new Blob(chunks, { + type: response.headers?.get?.('content-type') || 'image/jpeg', + }); +} + /** 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); diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 78397a632..05391a3cc 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -303,6 +303,20 @@ test('a picture file or bitmap past any real photo’s size is refused', async ( respond({ width: MAX_IMAGE_SIDE + 1 }); await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); assert.equal(closed, 1, 'the oversize bitmap is released'); + // No length given, a body that keeps coming: cut off while it streams. + let pulled = 0; + globalThis.fetch = async () => ({ + ok: true, + headers: new Map(), + body: new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(new Uint8Array(8 * 1024 * 1024)); + }, + }), + }); + await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + assert.ok(pulled * 8 * 1024 * 1024 <= MAX_IMAGE_BYTES + 16 * 1024 * 1024); respond({}); assert.equal((await loadImage('https://x/a.jpg')).width, 100); }); diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index c75a01768..0a354d06b 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -9,7 +9,8 @@ import { /** * Douglas–Peucker in tile units: the points of `line` ({x, y}) that keep - * it within `tolerance` of the original, first and last always kept. + * it within `tolerance` of the original (measured to each segment, so + * dead ends and U-turns stay), first and last always kept. * Iterative, so a long track cannot overflow the stack. */ export function simplifyLine(line, tolerance) { @@ -24,15 +25,20 @@ export function simplifyLine(line, tolerance) { const b = line[to]; const dx = b.x - a.x; const dy = b.y - a.y; - const span = Math.hypot(dx, dy); + const span2 = dx * dx + dy * dy; let far = -1; let farthest = tolerance; for (let i = from + 1; i < to; i++) { const p = line[i]; - // Distance to the chord, or to `a` where the chord has no length. - const d = span - ? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / span - : Math.hypot(p.x - a.x, p.y - a.y); + // Distance to the segment, not the infinite line: a dead end or a + // U-turn (a point beyond the chord's ends) is kept. + const t = span2 + ? Math.max( + 0, + Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / span2), + ) + : 0; + const d = Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); if (d > farthest) { farthest = d; far = i; diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index 104471a1e..c98ff3461 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -216,6 +216,13 @@ test('lines are simplified within a few tile units, ends and corners kept', () = ]; assert.equal(simplifyLine(loop, 4).length, 3); assert.equal(simplifyLine(zigzag, 0).length, 1000, 'no tolerance, no change'); + // A dead end: out to 100 and back to 50 keeps its far end. + const deadEnd = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 50, y: 0 }, + ]; + assert.deepEqual(simplifyLine(deadEnd, 4), deadEnd); }); test('a decoded GPS track keeps a fraction of its points', () => { diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index 9eb13cdaf..b6cfa93c8 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -286,11 +286,15 @@ export function createPanoramaxProvider({ }); /** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */ const pictureLists = createLru(PICTURE_LIST_CACHE_SIZE); - function picturesIn(tile, sequenceId, signal) { + /** + * Only finished lists are kept: a load in flight belongs to its own + * selection, whose abort (a re-centre) must not empty the next one's. + */ + async function picturesIn(tile, sequenceId, signal) { const key = `${tileKey(tile)}:${sequenceId}`; const kept = pictureLists.get(key); if (kept) return kept; - const list = readable(async () => + const list = await readable(async () => decodePictures( await source.getTile(tile, { signal, lane: 'pictures' }), tile, @@ -298,7 +302,6 @@ export function createPanoramaxProvider({ ), ); pictureLists.set(key, list); - list.catch(() => pictureLists.delete(key)); return list; } @@ -418,6 +421,9 @@ export function createPanoramaxProvider({ cones.setVisible(false); drawn = null; preferred = null; + // Off: decoded tiles and lists go (several MB each over a city). + coverageTiles.clear(); + pictureLists.clear(); }, destroy(cesiumViewer) { diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index 59fc95b3b..7e8ccca0d 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -62,8 +62,8 @@ export const COVERAGE_TUNING = Object.freeze({ }); /** * Lines are simplified (Douglas–Peucker) to this many tile units of a - * 4,096 extent: under a pixel at every height lines are drawn from, and - * 6–10% of the points GPS tracks carry. + * 4,096 extent: about a pixel where each zoom is drawn (1.6 m at z14, + * within GPS noise), and 6–10% of the points GPS tracks carry. */ export const SIMPLIFY_TOLERANCE_UNITS = 4; /** @@ -280,8 +280,8 @@ export function normalizeItem(item) { isPano: isEquirectangular(item), capturedAt: panoramaxTimeMs(props.datetime), creator: creatorOf(item), - // Licences ask for the source to be named: the instance holding it is - // named beside an author, or alone where the author is hidden. + // Licences ask for the source to be named: where no author can be + // named, the caption names the instance holding the picture. via: instanceOf(item), license: licenseLabel(props.license), width: Number.isFinite(width) && width > 0 ? width : null, diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 8cf3911d9..031f376d7 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -927,19 +927,89 @@ test('lines keep the decoded (longest-first) order under a budget of points', as assert.ok(COVERAGE_TUNING.maxPoints > 0); }); -test('a coverage tile decoded a moment ago is not fetched or decoded again', async (t) => { +test('a coverage tile decoded a moment ago is not fetched again; switched off, the tiles are let go', async (t) => { const env = coverageSetup(t, { sequences: [line('a')] }); - let asked = 0; + const asked = []; const getTile = env.source.getTile; env.source.getTile = (tile, options) => { - asked++; + asked.push(`${tile.z}/${tile.x}/${tile.y}`); return getTile(tile, options); }; env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - const first = asked; + const z14 = asked.length; + // Up a zoom band and back down: the z14 tile comes from memory. + env.viewer.view.height = 3_000; + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().zoom === 13)); + assert.ok(await until(() => !env.instance.coverageStats().loading)); + const withZ13 = asked.length; + env.viewer.view.height = 900; + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().zoom === 14)); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(asked.length, withZ13, 'nothing fetched again'); + assert.ok(z14 > 0); + // Off and on: the memory went back, so the tile is fetched again. env.instance.deactivate(); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - assert.equal(asked, first); + assert.ok(asked.length > withZ13); +}); + +test('a re-centre while the cones still load does not inherit the aborted load’s tiles', async (t) => { + const items = sequence('long', 400); + const tiles = pictureTiles(items); + const env = await mounted({ items, tiles }); + const cones = await withCones(t, env); + let hold = null; + env.source.getTile = async (tile, { signal } = {}) => { + env.source.calls.tiles.push(`${tile.z}/${tile.x}/${tile.y}`); + if (hold) await hold.promise; + signal?.throwIfAborted(); + return tiles(tile); + }; + await env.instance.viewer.open('long-0'); + const tileOf = (item) => { + const [lon, lat] = item.geometry.coordinates; + return pictureTilesAround({ lon, lat })[0]; + }; + const first = new Set( + pictureTilesAround({ + lon: items[0].geometry.coordinates[0], + lat: items[0].geometry.coordinates[1], + }).map((tile) => `${tile.x}/${tile.y}`), + ); + const out = items.findIndex((item) => { + const tile = tileOf(item); + return !first.has(`${tile.x}/${tile.y}`); + }); + hold = {}; + hold.promise = new Promise((resolve) => (hold.release = resolve)); + const loading = env.instance.selectSequence('long'); + await new Promise((resolve) => setTimeout(resolve, 5)); + // The far picture opens while the first load is held: the cones re-centre. + await env.instance.viewer.open(`long-${out}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + const release = hold.release; + hold = null; + release(); + await loading; + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + // Every picture in the tiles the second load covers has its cone. + const [lon, lat] = items[out].geometry.coordinates; + const second = new Set( + pictureTilesAround({ lon, lat }).map((tile) => `${tile.x}/${tile.y}`), + ); + const ids = new Set(cones.ids()); + const covered = items.filter((item) => { + const tile = tileOf(item); + return second.has(`${tile.x}/${tile.y}`); + }); + assert.ok(covered.length > 0); + assert.equal( + covered.filter((item) => ids.has(item.id)).length, + covered.length, + 'every picture in the re-centred tiles has its cone (the shared ones too)', + ); }); diff --git a/src/layers/streetLevel/tileCache.js b/src/layers/streetLevel/tileCache.js index 55d997cdb..7fc9bb889 100644 --- a/src/layers/streetLevel/tileCache.js +++ b/src/layers/streetLevel/tileCache.js @@ -44,5 +44,7 @@ export function createTileCache({ kept.set(key, { at: now(), tile: loaded }); return loaded; }, + /** Forget every tile (the layer is off: its memory goes back). */ + clear: () => kept.clear(), }; } From ce3f66a8377d34d7c99285721706b82767e31ebf Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:54:21 +0100 Subject: [PATCH 12/17] =?UTF-8?q?fix(street-level):=20IC8=20round=203=20?= =?UTF-8?q?=E2=80=94=20no=20blink=20when=20the=20point=20budget=20is=20re-?= =?UTF-8?q?shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-3 verification found the shared point budget rebuilding a dense tile by removing its lines before the new ones were built, so central Paris vanished for 1–2 s after every pan: - Tiles redrawn for a new share, or for a selected line the cap had left out, go through the swap path: the old lines stay until the new ones are ready. - A share must move by 10% to redraw a tile (a 1.6% change rebuilt it). - The selected line, admitted past the cap, is not counted against it, so its tile no longer redraws on every settle. - A selection the imagery filter hides is not redrawn for. - Shares are recomputed when a refresh ends loading without a tile finishing. Co-Authored-By: Claude Opus 5.5 --- src/layers/streetLevel/lineCoverage.js | 52 +++++++---- src/layers/streetLevel/lineCoverage.test.mjs | 95 ++++++++++++++++++++ 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 50a81cfe2..78c9b36da 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -12,6 +12,8 @@ import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js'; import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js'; import { tileBounds, tilesForBbox } from './tileMath.js'; +/** A tile's shared point cap must move by this much to redraw it. */ +const SHARE_HYSTERESIS = 0.1; /** 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. */ @@ -164,9 +166,9 @@ export function createLineCoverage({ const size = pointsOf(sequence); demand += size; // The selected sequence is always drawn: its highlight is the answer - // to a click, whatever the caps leave out. + // to a click, whatever the caps leave out. It is outside the cap, so + // it is not counted against it. if (sequence.id === selectedId) { - points += size; drawn.push(sequence); continue; } @@ -204,32 +206,45 @@ export function createLineCoverage({ left -= cap; const before = entry.pointCap ?? perTilePointCap; entry.pointCap = cap; - // Only a change that alters what is drawn: more room for a tile that - // had lines left out, or less than it draws now. - if ( - (cap > before && entry.pointsDrawn < entry.pointDemand) || - cap < entry.pointsDrawn - ) - changed.push(entry); + // Only a change that alters what is drawn, by enough to be worth a + // rebuild: more room for a tile that had lines left out, or well + // under what it draws now (a small overshoot stays until the next + // natural redraw). + const grows = + cap > before * (1 + SHARE_HYSTERESIS) && + entry.pointsDrawn < entry.pointDemand; + const shrinks = cap < entry.pointsDrawn * (1 - SHARE_HYSTERESIS); + if (grows || shrinks) changed.push(entry); }); return changed; } - /** Once loading settles: redraw the tiles the shared budget changes. */ + /** + * Once loading settles: redraw the tiles the shared budget changes, + * keeping their old lines until the new ones are ready (no blink). + */ function shareBudget() { const changed = shareCaps(); - for (const entry of changed) redrawEntry(entry); + for (const entry of changed) redrawEntry(entry, { swap: true }); if (changed.length) { requestRender(); notify(); } } - /** Draw one tile again from its decoded lines (caps or selection changed). */ - function redrawEntry(entry) { + /** + * Draw one tile again from its decoded lines (filter, caps or selection + * changed). With `swap`, its old lines stay until the new ones are ready. + */ + function redrawEntry(entry, { swap = false } = {}) { cancelCast(entry); entry.castRequested = false; entry.meshParts = null; + if (swap && entry.kind === 'sequence' && entry.primitives?.length) { + finishSwap(entry); + redrawTile(entry); + return; + } detachPrimitive(entry); attachPrimitive(entry); } @@ -825,7 +840,10 @@ export function createLineCoverage({ state.coverage.pending.delete(key); } for (const tile of tiles) loadTile(tile, kind); - if (!state.coverage.pending.size) purgeStale(); + if (!state.coverage.pending.size) { + purgeStale(); + shareBudget(); + } // 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()) @@ -943,9 +961,11 @@ export function createLineCoverage({ if ( entry.kind === 'sequence' && !entry.sequences.has(id) && - entry.sequenceList.some((sequence) => sequence.id === id) + entry.sequenceList.some( + (sequence) => sequence.id === id && passes(sequence, filter()), + ) ) - redrawEntry(entry); + redrawEntry(entry, { swap: true }); for (const entry of drawnEntries()) { const sequence = entry.sequences.get(id); if (!sequence) continue; diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index e7b8b693e..db89e8143 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -338,3 +338,98 @@ test('a selected sequence the budget left out is drawn, highlighted', async (t) coverage.recolorSequence('short', true); assert.deepEqual([...tile.sequences.keys()].sort(), ['long', 'short']); }); + +/** Count the line primitives a coverage adds to the scene. */ +function countAdds(viewer) { + const counts = { added: 0 }; + for (const collection of [ + viewer.scene.groundPrimitives, + viewer.scene.primitives, + ]) { + const add = collection.add.bind(collection); + collection.add = (primitive) => { + counts.added++; + return add(primitive); + }; + } + return counts; +} + +test('a tile redrawn for a new share keeps its old lines until the new ones are ready', async (t) => { + const tuning = { + ...TUNING, + maxTiles: 9, + maxSequences: 900, + maxPoints: 90, + keepOrder: true, + }; + const { state, coverage, viewer } = setup(t, { + zoomFor: () => ({ zoom: 18, kind: 'sequence' }), + tuning, + decoded: (centre, tile) => { + const dense = tile.x % 2 === 0 && tile.y % 2 === 0; + return { + sequences: dense + ? Array.from({ length: 30 }, (_, i) => + pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre), + ) + : [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)], + overview: [], + }; + }, + }); + coverage.refresh(); + assert.ok( + await until( + () => state.coverage.tiles.size > 1 && !state.coverage.pending.size, + ), + ); + const redrawn = [...state.coverage.tiles.values()].filter( + (entry) => entry.swap, + ); + assert.ok(redrawn.length > 0, 'a dense tile took a bigger share'); + for (const entry of redrawn) + for (const { primitive } of entry.swap.old) + assert.ok( + viewer.scene.groundPrimitives.items.has(primitive) || + viewer.scene.primitives.items.has(primitive), + 'the old lines are still on the globe while the new ones build', + ); +}); + +test('a selected line outside the cap does not make its tile redraw on every settle, nor does one the filter hides', async (t) => { + let pano = 'all'; + const { state, coverage, viewer } = setup(t, { + tuning: { + ...TUNING, + maxTiles: 1, + maxSequences: 10, + maxPoints: 5, + keepOrder: true, + }, + decoded: (centre, tile) => ({ + sequences: [ + pointsLine('long', 5, tile, centre), + { ...pointsLine('short', 3, tile, centre), isPano: true }, + ], + overview: [], + }), + }); + state.context.getFilter = () => ({ pano, sinceMs: null }); + coverage.refresh(); + assert.ok(await until(() => state.coverage.tiles.size === 1)); + state.sequence.selectedId = 'short'; + coverage.recolorSequence('short', true); + const counts = countAdds(viewer); + for (let i = 0; i < 3; i++) { + coverage.refresh(); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal(counts.added, 0, 'settles redraw nothing'); + // A selection the filter hides is not drawn, so it is not redrawn for. + pano = 'flat'; + coverage.rebuild(); + const after = countAdds(viewer); + for (let i = 0; i < 3; i++) coverage.recolorSequence('short', true); + assert.equal(after.added, 0); +}); From e3bc431f9a641952b2ea14772d38d50566881647 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 15 Oct 2026 16:08:18 +0100 Subject: [PATCH 13/17] =?UTF-8?q?fix(street-level):=20review=20follow-ups?= =?UTF-8?q?=20=E2=80=94=20shares=20judged=20against=20the=20last=20draw;?= =?UTF-8?q?=20refused=20pictures=20stay=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the maintainer review of #1134 (approve with follow-ups): - lineCoverage: a share is judged against the cap the tile was last drawn with, not against later shares that redrew nothing, so small increases add up (a retained tile stayed at 100 points after its share grew to 116). A rebuild's measuring pass no longer counts as a draw. - photoViewer: a picture refused for its size (bytes, streamed bytes or decoded side) is marked `tooLarge`, and the viewer never loads that file again by any path; the flat fallback had retried an oversized SD file through an , which has no size limit. - Regressions for both: cumulative share growth below the threshold, and a refused SD panorama that is not fetched again as a flat photo. Co-Authored-By: Claude Opus 5.5 --- src/layers/streetLevel/lineCoverage.js | 16 +++++--- src/layers/streetLevel/lineCoverage.test.mjs | 40 +++++++++++++++++++ src/layers/streetLevel/photoViewer.js | 34 ++++++++++++---- src/layers/streetLevel/photoViewer.test.mjs | 19 +++++++-- .../providers/panoramax/viewer.test.mjs | 36 +++++++++++++++++ 5 files changed, 129 insertions(+), 16 deletions(-) diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 78c9b36da..ab9b996db 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -152,9 +152,10 @@ export function createLineCoverage({ /** * A tile's sequences, filtered then capped: capping first could leave a - * dense tile of newer flat captures with no 360° lines at all. + * dense tile of newer flat captures with no 360° lines at all. `measure` + * only records what the lines need, for a draw that follows. */ - function drawnSequences(entry) { + function drawnSequences(entry, { measure = false } = {}) { const drawn = []; const current = filter(); const selectedId = state.sequence.selectedId ?? null; @@ -180,7 +181,12 @@ export function createLineCoverage({ } // What every passing line would take: the budget is shared by it. entry.pointDemand = demand; - entry.pointsDrawn = points; + if (!measure) { + entry.pointsDrawn = points; + // The cap this draw used: a share is judged against it, not against + // later shares that redrew nothing (small steps add up). + entry.drawnCap = cap; + } return drawn; } @@ -204,7 +210,7 @@ export function createLineCoverage({ const share = left / (byDemand.length - i); const cap = Math.floor(Math.min(entry.pointDemand || 0, share)); left -= cap; - const before = entry.pointCap ?? perTilePointCap; + const before = entry.drawnCap ?? perTilePointCap; entry.pointCap = cap; // Only a change that alters what is drawn, by enough to be worth a // rebuild: more room for a tile that had lines left out, or well @@ -899,7 +905,7 @@ export function createLineCoverage({ // What each tile's lines need under the new filter, then the shares, // then one redraw each. for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') drawnSequences(entry); + if (entry.kind === 'sequence') drawnSequences(entry, { measure: true }); shareCaps(); for (const entry of state.coverage.tiles.values()) redrawEntry(entry); requestRender(); diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index db89e8143..b0b5f7e94 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -433,3 +433,43 @@ test('a selected line outside the cap does not make its tile redraw on every set for (let i = 0; i < 3; i++) coverage.recolorSequence('short', true); assert.equal(after.added, 0); }); + +test('small share increases add up: a retained tile redraws once its share has grown past the threshold since its last draw', async (t) => { + const tuning = { + ...TUNING, + maxTiles: 1, + maxSequences: 900, + maxPoints: 100, + keepOrder: true, + }; + const { state, coverage } = setup(t, { + tuning, + decoded: (centre, tile) => ({ + sequences: Array.from({ length: 30 }, (_, i) => + pointsLine(`l${i}`, 4, tile, centre), + ), + overview: [], + }), + }); + coverage.refresh(); + assert.ok( + await until( + () => state.coverage.tiles.size === 1 && !state.coverage.pending.size, + ), + ); + const [tile] = state.coverage.tiles.values(); + assert.equal(tile.pointsDrawn, 100); + const settle = async () => { + coverage.refresh(); + await until(() => !state.coverage.pending.size); + await new Promise((resolve) => setTimeout(resolve, 10)); + }; + // +8%: under the threshold, nothing is redrawn. + tuning.maxPoints = 108; + await settle(); + assert.equal(tile.pointsDrawn, 100); + // +8% again: +16% since the tile was last drawn, so it is redrawn. + tuning.maxPoints = 116; + await settle(); + assert.equal(tile.pointsDrawn, 116, 'the grown share is drawn'); +}); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index a7e86cb42..3de2c1af7 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -78,7 +78,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { // Picture hosts are not ours: refuse a file or an image past any real // photo's size before it is decoded. const length = Number(response.headers?.get?.('content-length')); - if (length > MAX_IMAGE_BYTES) throw new Error(failure); + if (length > MAX_IMAGE_BYTES) throw oversized(failure); const blob = await boundedBlob(response, failure); const bitmap = await (resize ? createImageBitmap(blob, { @@ -90,10 +90,12 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { // (A resized decode reports its new size: this guards unresized ones.) if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) { bitmap.close?.(); - throw new Error(failure); + throw oversized(failure); } return bitmap; - } catch { + } catch (error) { + // A size refusal stays one: the viewer must not fetch it another way. + if (error?.tooLarge) throw error; throw new Error(failure); } finally { clearTimeout(timer); @@ -101,6 +103,14 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { } } +/** + * A picture refused for its size (`tooLarge`): the viewer will not load + * that file another way, an having no size limit at all. + */ +export function oversized(failure) { + return Object.assign(new Error(failure), { tooLarge: true }); +} + /** * The body as a Blob, read with a running count so a host that sends no * length (or a false one) cannot fill memory before the cap is checked. @@ -109,7 +119,7 @@ async function boundedBlob(response, failure) { const reader = response.body?.getReader?.(); if (!reader) { const blob = await response.blob(); - if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure); + if (blob.size > MAX_IMAGE_BYTES) throw oversized(failure); return blob; } const chunks = []; @@ -120,7 +130,7 @@ async function boundedBlob(response, failure) { total += value.byteLength; if (total > MAX_IMAGE_BYTES) { reader.cancel().catch(() => {}); - throw new Error(failure); + throw oversized(failure); } chunks.push(value); } @@ -226,8 +236,17 @@ export function createPhotoViewer({ /** ‹ › presses run one after another, each from where the last one went. */ let steps = Promise.resolve(); - const loadAs = (url, options) => - load(url, { failure: LOAD_FAILED, ...options }); + /** Files refused for their size: never loaded again, by any path. */ + const refused = new Set(); + async function loadAs(url, options) { + if (refused.has(url)) throw oversized(LOAD_FAILED); + try { + return await load(url, { failure: LOAD_FAILED, ...options }); + } catch (error) { + if (error?.tooLarge) refused.add(url); + throw error; + } + } function requestRender() { render?.governorRequestRender?.(`${providerId}-viewer`); @@ -796,6 +815,7 @@ export function createPhotoViewer({ unmount() { openSeq++; dropSharpPanorama(); + refused.clear(); openingId = null; keepViewFor = null; flatSharpUrl = null; diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 05391a3cc..758a1e25b 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -296,12 +296,17 @@ test('a picture file or bitmap past any real photo’s size is refused', async ( headers: new Map([['content-length', String(length ?? '')]]), blob: async () => ({ size: size ?? 10, bitmap: bitmap(width, height) }), })); + const refusedForSize = (error) => { + assert.match(error.message, /could not be loaded/); + assert.equal(error.tooLarge, true, 'marked as a size refusal'); + return true; + }; respond({ length: MAX_IMAGE_BYTES + 1 }); - await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize); respond({ size: MAX_IMAGE_BYTES + 1 }); - await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize); respond({ width: MAX_IMAGE_SIDE + 1 }); - await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize); assert.equal(closed, 1, 'the oversize bitmap is released'); // No length given, a body that keeps coming: cut off while it streams. let pulled = 0; @@ -315,8 +320,14 @@ test('a picture file or bitmap past any real photo’s size is refused', async ( }, }), }); - await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize); assert.ok(pulled * 8 * 1024 * 1024 <= MAX_IMAGE_BYTES + 16 * 1024 * 1024); + // Other failures are not size refusals. + globalThis.fetch = async () => ({ ok: false, headers: new Map() }); + await assert.rejects( + loadImage('https://x/a.jpg'), + (error) => !error.tooLarge, + ); respond({}); assert.equal((await loadImage('https://x/a.jpg')).width, 100); }); diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs index 78c39f316..614a50e13 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -295,3 +295,39 @@ test('a photo with no author to name is captioned with the instance holding it', const named = { ...photo, creator: 'Robot8A' }; assert.equal(PANORAMAX_SOURCES.describe(named).title, null); }); + +test('an SD panorama refused for its size is not fetched again as a flat photo', async () => { + const loads = []; + const viewer = createPanoramaxViewer({ + getPhoto: async () => PHOTOS.pano, + step: async () => null, + onStep() {}, + load: async (url, options = {}) => { + loads.push({ url, crossOrigin: options.crossOrigin }); + // The picture host's SD file is past the viewer's size limits. + if (url === PHOTOS.pano.urls.sd) + throw Object.assign(new Error('too big'), { tooLarge: true }); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView() {}, + getView: () => ({ yaw: 0, pitch: 0, fov: 80 }), + resize() {}, + clear() {}, + destroy() {}, + }), + }); + const host = fakeHost(); + await viewer.mount(host); + await viewer.open('pano'); + const sdLoads = loads.filter((l) => l.url === PHOTOS.pano.urls.sd); + assert.equal(sdLoads.length, 1, 'tried once, as a bitmap, and refused'); + assert.equal(sdLoads[0].crossOrigin, true); + const { picture } = parts(host); + assert.equal(picture.hidden, false, 'the flat fallback shows'); + assert.notEqual(picture.src, PHOTOS.pano.urls.sd); + // Opened again later: the refused file is still not asked for. + await viewer.open('pano'); + assert.equal(loads.filter((l) => l.url === PHOTOS.pano.urls.sd).length, 1); +}); From b645a767c828e4c3dd8c50d23b9e1e2a5c5bccd8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 15 Oct 2026 16:55:59 +0100 Subject: [PATCH 14/17] Street Level: Panoramax review follow-ups (gekh, #1134) Image loading (photoViewer.js) - One AbortController per open: a new open, close or unmount stops the previous photo's first and sharper downloads (an by src = ''). A collapsed viewer never falls back to the original; Panoramax's flat fallback is the thumbnail. A superseded open resolves quietly. - Flat HD tracked as "loading" and "shown" apart: a discarded response no longer blocks later upgrades, and resizes while it loads ask once. - No upgrade of the photo still shown while another opens. - Size limits before decoding: imageSize.js reads JPEG/PNG/WebP/GIF headers; more than 64 MP or 16,384 px a side is refused undecoded, an that decodes larger is refused and let go, flat HD needs a known width of at most 8,192 px, and panoramas always decode with a resize. Panoramax provider - Retry-After parsed as seconds or an HTTP date; the browser cannot read it here, so busy answers back off 5 s doubling to 300 s, reset once answered. - Headers timed, then the body by an idle timeout (15 s of silence), so a slow link still gets its megabyte tiles. - Cones centre only on the picture shown (onShowing), never on a late lookup; lookups nobody waits for are aborted (shared, refcounted). - onReopen: a GPU reset redraws in the viewer, not through the core. - Cones draw the home z15 tile at once and add neighbours as they arrive; a neighbour that failed is not counted as drawn. - Coverage tiles decode in ~8 ms slices (a 2 MB z10 tile over Paris was one ~85-200 ms task); sorted by measured length without a Map. - Box search asks for 20, not 100 (the API returns them nearest first). - looksLikeEmail checks every '@'; creatorOf de-duplicates with a Set. lineCoverage.js - A redraw while a swap still builds keeps what is on screen (the swap's old lines and its ready new ones) and drops only the unready ones. Tests for each (mutation-checked), two vacuous tests made real, the cones' pictures lane and readable()'s busy/timeout paths covered; the fixture's box search is nearest first. Docs: README filters and share links, CHANGELOG HD wording, CURRENT-STATE. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +- README.md | 4 +- docs/CURRENT-STATE.md | 39 ++- scripts/fixtures/street-level/panoramax.mjs | 9 +- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 3 + src/app/layers/streetLevel.test.mjs | 5 + src/layers/streetLevel/imageSize.js | 64 +++++ src/layers/streetLevel/imageSize.test.mjs | 82 ++++++ src/layers/streetLevel/lineCoverage.js | 18 +- src/layers/streetLevel/lineCoverage.test.mjs | 57 ++++ src/layers/streetLevel/photoViewer.js | 128 ++++++--- src/layers/streetLevel/photoViewer.test.mjs | 207 ++++++++++++++- .../streetLevel/providers/kartaview/viewer.js | 1 + .../providers/kartaview/viewer.test.mjs | 9 +- .../streetLevel/providers/panoramax/decode.js | 64 ++++- .../providers/panoramax/decode.test.mjs | 84 +++++- .../streetLevel/providers/panoramax/index.js | 136 ++++++++-- .../streetLevel/providers/panoramax/policy.js | 37 ++- .../providers/panoramax/policy.test.mjs | 17 ++ .../providers/panoramax/provider.test.mjs | 243 +++++++++++++++++- .../streetLevel/providers/panoramax/source.js | 81 ++++-- .../providers/panoramax/source.test.mjs | 155 ++++++++++- .../streetLevel/providers/panoramax/viewer.js | 23 +- .../providers/panoramax/viewer.test.mjs | 16 +- 25 files changed, 1362 insertions(+), 135 deletions(-) create mode 100644 src/layers/streetLevel/imageSize.js create mode 100644 src/layers/streetLevel/imageSize.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index df6497222..a7db1c6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,13 +153,18 @@ ← → following the picture's own links. The 360° and since filters apply, in the lines and in the lookup. The caption names each picture's author and licence (CC BY-SA 4.0, Licence Ouverte 2.0…) and links to it in the - Panoramax viewer; HD originals load only for an expanded viewer, once. - Share links carry it as `0.m.0_0.r.panoramax-1`. KartaView's photo viewer + Panoramax viewer. HD originals load once, only when the viewer is wider + than the 2,048 px version (a panorama: expanded), and every download stops + when the photo changes; an image whose header declares more than 64 MP is + refused before it decodes. Share links carry it as + `0.m.0_0.r.google-0*kartaview-0*panoramax-1` (`0.m.0_0.r.panoramax-1` works + too). KartaView's photo viewer is now shared (`photoViewer.js`, `panorama.js`); in it, for KartaView too, Esc clears the selected drive before it collapses the panel. Lines are simplified and drawn within a point budget (a tile's unsimplified lines - over Paris hung the tab), decoded tiles are kept, and the cones' tiles - have their own connection. + over Paris hung the tab), decoded in slices so the globe keeps moving, + and kept; the cones' tiles have their own connection, and the open + picture's tile draws its cones before its neighbours arrive. - Flights and Military Flights highlight emergency transponder broadcasts in fleet billboards, 3D models and Cockpit contacts. Colors update when a status diff --git a/README.md b/README.md index ee19953ab..554f9b57f 100644 --- a/README.md +++ b/README.md @@ -354,9 +354,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**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. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -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, and `0.m.0_0.r.panoramax-1` lights Panoramax; 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`. +Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. ## 🎖️ Field Missions diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8e5bff676..28c59191a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3403,7 +3403,13 @@ provider is on (16, ten minutes, `tileCache.js`, shared with KartaView), and above 60 km the `grid` layer's 0.1° density cells, binned to at most 96×96 points a tile (a z0 tile holds -~70,000 cells) and not counted as sequences: the panel says to zoom in. Both +~70,000 cells) and not counted as sequences: the panel says to zoom in. +A tile decodes in slices of about 8 ms, the page getting a turn between +them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the +tiles back for its Retry-After, which the browser cannot read here (the API +exposes no headers), so the wait doubles from 5 s to 5 minutes until it +answers; an answer may take as long as it keeps arriving (silent for 15 s +is given up), so a slow link still gets its megabyte tiles. Both filters apply: lines carry their day and camera type, and a cell stays while it holds pictures of the kind asked for (cells are undated). A click on a line asks the core (`actions.openNearest`) for that sequence's picture @@ -3412,8 +3418,8 @@ a click lands well off a line), without framing, then any sequence's within 50 m. Each lookup is two searches side by side, ranked by distance in the browser: `place_position` (nearest first, but even at `place_fov_tolerance=180` it leaves out pictures looking away from the -point) and a bounding box (those too, but in no order); the filters go to -both (`filter=field_of_view=360`, `datetime=/..`). A line is dated +point) and a bounding box (those too, roughly nearest first, so 20 are +asked for: ~60 kB, against ~600 kB for 100); the filters go to both (`filter=field_of_view=360`, `datetime=/..`). A line is dated by its sequence's first day, so a sequence still growing can hide under SINCE; a line with no camera type or day stays drawn. Lines keep the decoded longest-first order under the per-tile cap (`keepOrder`). Once the picture is on screen @@ -3421,20 +3427,31 @@ its sequence is highlighted and drawn as cones from the z15 tiles around it (their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC item), plus the neighbours within 150 m of an edge, on their own connection (coverage tiles take three, searches two); each tile's list for the sequence -is kept, and the cones re-centre when a picture opens outside the tiles -they were drawn from, judged from the moment their load starts. ‹ › follow the picture's own `prev`/`next` +is kept. The open picture's own tile draws at once and the neighbours join +as they arrive; one that fails is not counted, so a picture opening in it +re-centres the cones there. The cones re-centre when a picture opens outside the tiles +they were drawn from, judged from the moment their load starts, and centre +only on a picture actually shown (never on a lookup that came back after +the user moved on); a lookup nobody waits for any more is stopped. ‹ › follow the picture's own `prev`/`next` links, so stepping costs one small search per picture. The viewer shows the -2,048 px SD derivative (equirectangular for a 360° picture) and swaps in the -HD original only when the viewer is wider than SD (flat) or, for a -panorama, expanded; once, shrunk while it decodes, and aborted if another -photo opens; originals wider than 8,192 px are never fetched, and any -picture file over 48 MB or 16,384 px is refused. The caption names every +2,048 px SD derivative (equirectangular for a 360° picture, decoded at +2,048 × 1,024) and swaps in the HD original only when the viewer is wider +than SD (flat) or, for a panorama, expanded; once, shrunk while it decodes. +When SD fails a flat picture falls back to the thumbnail, and to the +original only in the expanded viewer. Every download of a photo, first or +sharper, stops when another opens or the viewer closes. Originals wider than +8,192 px are never fetched; a file's header (JPEG, PNG, WebP, GIF) is read +before it decodes, and one declaring more than 64 MP or 16,384 px a side +is refused undecoded, as is an `` that decodes larger and any file +over 48 MB. The caption names every producer (an uploading app and its photographer; never an account named by an email address), or else the instance holding the picture ("via panoramax.ign.fr"), and the picture's licence (CC BY-SA 4.0, Licence Ouverte 2.0…; StreetPose `license`), and links to the picture in the Panoramax viewer. Esc anywhere in the docked viewer clears the selected -sequence before the panel can take it (KartaView too). The MCP globe panel allows the meta-catalogue and the +sequence before the panel can take it (KartaView too). After a GPU reset +the viewer draws the same picture again itself (`onReopen`), leaving the +core's lookups alone. The MCP globe panel allows the meta-catalogue and the three biggest instances; a picture on another instance shows only in the app. diff --git a/scripts/fixtures/street-level/panoramax.mjs b/scripts/fixtures/street-level/panoramax.mjs index 7506eb806..acca30e0f 100644 --- a/scripts/fixtures/street-level/panoramax.mjs +++ b/scripts/fixtures/street-level/panoramax.mjs @@ -249,9 +249,11 @@ export function answerPanoramaxRequest({ method, url }) { : true, ) .filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since)); - // A box: every picture in it, in no particular order (id order here). + // A box: every picture in it, nearest its centre first, as the live API + // returns them (roughly: the browser ranks them again). if (q.has('bbox')) { const [west, south, east, north] = q.get('bbox').split(',').map(Number); + const centre = { lon: (west + east) / 2, lat: (south + north) / 2 }; return json({ type: 'FeatureCollection', features: kept @@ -259,9 +261,10 @@ export function answerPanoramaxRequest({ method, url }) { (p) => p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north, ) - .sort((a, b) => a.id.localeCompare(b.id)) + .map((p) => ({ p, metres: metresApart(centre, p) })) + .sort((a, b) => a.metres - b.metres || a.p.id.localeCompare(b.p.id)) .slice(0, Number(q.get('limit')) || 10) - .map((p) => item(p, pictures)), + .map(({ p }) => item(p, pictures)), }); } const [lon, lat] = (q.get('place_position') || '').split(',').map(Number); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1eb4f7014..f8c76fea4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -943,6 +943,7 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/imageSize.js", "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/providers/panoramax/policy.test.mjs", "src/layers/streetLevel/providers/panoramax/decode.test.mjs", @@ -952,6 +953,7 @@ "src/testSupport/panoramaxFixtures.mjs", "src/layers/streetLevel/photoViewer.test.mjs", "src/layers/streetLevel/limiter.test.mjs", + "src/layers/streetLevel/imageSize.test.mjs", "src/layers/streetLevel/lineCoverage.test.mjs", "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 98e8d17ec..e7b494fba 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1244,6 +1244,7 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/imageSize.js", "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", @@ -2225,6 +2226,7 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/imageSize.js", "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", @@ -2777,6 +2779,7 @@ "src/layers/streetLevel/providers/panoramax/viewer.js", "src/layers/streetLevel/photoViewer.js", "src/layers/streetLevel/limiter.js", + "src/layers/streetLevel/imageSize.js", "src/layers/streetLevel/tileCache.js", "src/layers/streetLevel/panorama.js", "src/layers/streetLevel/lineCoverage.js", diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index 16e2cc4b5..e82e762e5 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -64,6 +64,11 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and assert.equal(layer.getParams().panoramax, true); assert.equal(layer.getParams().kartaview, false); assert.equal(layer.getParams().mapillary, false); + // The form the app writes (the README and CHANGELOG quote it). + assert.equal( + layer.getParams().providerSwitches, + 'google-0*kartaview-0*panoramax-1', + ); // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); diff --git a/src/layers/streetLevel/imageSize.js b/src/layers/streetLevel/imageSize.js new file mode 100644 index 000000000..dc1131495 --- /dev/null +++ b/src/layers/streetLevel/imageSize.js @@ -0,0 +1,64 @@ +/** + * An image's pixel size read from its header, before anything decodes it: + * a picture host can serve a small file that declares enormous dimensions, + * and a decode of it alone can take the tab (or the GPU process) down. + * JPEG (SOFn), PNG (IHDR), WebP (VP8, VP8L, VP8X) and GIF; null for any + * other format, or a header this does not reach. + * @param {Uint8Array} bytes The start of the file (a JPEG's EXIF can push its SOF past 64 kB). + * @returns {{width: number, height: number}|null} + */ +export function imageSize(bytes) { + if (!bytes || bytes.length < 12) return null; + const b = bytes; + const be16 = (i) => (b[i] << 8) | b[i + 1]; + const le16 = (i) => b[i] | (b[i + 1] << 8); + const le24 = (i) => b[i] | (b[i + 1] << 8) | (b[i + 2] << 16); + const ascii = (i, text) => + [...text].every((char, k) => b[i + k] === char.charCodeAt(0)); + // JPEG: walk the markers to the first start-of-frame. + if (b[0] === 0xff && b[1] === 0xd8) { + let i = 2; + while (i + 9 < b.length) { + if (b[i] !== 0xff) return null; + const marker = b[i + 1]; + // Fill bytes, and markers with no length. + if (marker === 0xff) { + i++; + continue; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + i += 2; + continue; + } + const isFrame = + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc; + if (isFrame) return { width: be16(i + 7), height: be16(i + 5) }; + i += 2 + be16(i + 2); + } + return null; + } + // PNG: IHDR is the first chunk. + if (b[0] === 0x89 && ascii(1, 'PNG') && ascii(12, 'IHDR')) { + const u32 = (i) => + ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0; + return { width: u32(16), height: u32(20) }; + } + if (ascii(0, 'RIFF') && ascii(8, 'WEBP') && b.length >= 30) { + if (ascii(12, 'VP8 ')) + return { width: le16(26) & 0x3fff, height: le16(28) & 0x3fff }; + if (ascii(12, 'VP8L')) + return { + width: 1 + (((b[22] & 0x3f) << 8) | b[21]), + height: + 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)), + }; + if (ascii(12, 'VP8X')) return { width: 1 + le24(24), height: 1 + le24(27) }; + return null; + } + if (ascii(0, 'GIF8')) return { width: le16(6), height: le16(8) }; + return null; +} diff --git a/src/layers/streetLevel/imageSize.test.mjs b/src/layers/streetLevel/imageSize.test.mjs new file mode 100644 index 000000000..e83978596 --- /dev/null +++ b/src/layers/streetLevel/imageSize.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { imageSize } from './imageSize.js'; + +const bytes = (...parts) => + Uint8Array.from( + parts.flat().map((v) => (typeof v === 'string' ? v.charCodeAt(0) : v)), + ); +const ascii = (text) => [...text]; + +test('a JPEG’s size is read from its first frame, past an EXIF segment', () => { + const exif = [0xff, 0xe1, 0x00, 0x10, ...new Array(14).fill(0)]; + const sof = [0xff, 0xc0, 0x00, 0x11, 0x08, 0x0b, 0xc0, 0x0f, 0xc0]; + const jpeg = bytes([0xff, 0xd8], exif, sof, new Array(20).fill(0)); + assert.deepEqual(imageSize(jpeg), { width: 0x0fc0, height: 0x0bc0 }); + // Progressive (SOF2) too; a DHT (C4) is not a frame. + const dht = [0xff, 0xc4, 0x00, 0x04, 0, 0]; + const sof2 = [0xff, 0xc2, 0x00, 0x11, 0x08, 0x00, 0x10, 0x00, 0x20]; + assert.deepEqual( + imageSize(bytes([0xff, 0xd8], dht, sof2, new Array(10).fill(0))), + { + width: 32, + height: 16, + }, + ); +}); + +test('PNG, WebP (lossy, lossless, extended) and GIF sizes', () => { + const png = bytes( + [0x89], + ascii('PNG'), + [0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13], + ascii('IHDR'), + [0, 0, 0x10, 0, 0, 0, 0x08, 0], + ); + assert.deepEqual(imageSize(png), { width: 4096, height: 2048 }); + const riff = (chunk, rest) => + bytes(ascii('RIFF'), [0, 0, 0, 0], ascii('WEBP'), ascii(chunk), rest); + const vp8 = riff('VP8 ', [...new Array(10).fill(0), 0x00, 0x08, 0x00, 0x04]); + assert.deepEqual(imageSize(vp8), { width: 2048, height: 1024 }); + // VP8L: 14-bit width-1 and height-1 packed after the 0x2f signature. + const w = 99; + const h = 49; + const packed = w | (h << 14); + const vp8l = riff('VP8L', [ + 0, + 0, + 0, + 0, + 0x2f, + packed & 0xff, + (packed >> 8) & 0xff, + (packed >> 16) & 0xff, + (packed >> 24) & 0xff, + 0, + 0, + 0, + 0, + 0, + 0, + ]); + assert.deepEqual(imageSize(vp8l), { width: 100, height: 50 }); + const vp8x = riff( + 'VP8X', + [0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0x0f, 0x00, 0xff, 0x07, 0x00], + ); + assert.deepEqual(imageSize(vp8x), { width: 4096, height: 2048 }); + const gif = bytes(ascii('GIF89a'), [0x40, 0x01, 0xf0, 0x00], [0, 0]); + assert.deepEqual(imageSize(gif), { width: 320, height: 240 }); +}); + +test('anything else, or a header cut short, has no size', () => { + assert.equal(imageSize(null), null); + assert.equal(imageSize(new Uint8Array(4)), null); + assert.equal(imageSize(bytes(ascii(''))), null); + assert.equal( + imageSize( + bytes([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff], new Array(8).fill(0)), + ), + null, + ); +}); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index ab9b996db..d57a98bd0 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -247,7 +247,6 @@ export function createLineCoverage({ entry.castRequested = false; entry.meshParts = null; if (swap && entry.kind === 'sequence' && entry.primitives?.length) { - finishSwap(entry); redrawTile(entry); return; } @@ -373,7 +372,22 @@ export function createLineCoverage({ /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ function redrawTile(entry) { - const previous = entry.primitives; + let previous = entry.primitives; + if (entry.swap) { + // A swap still building: what is on screen is its old lines and + // whichever new ones are ready. Those stay; the rest never showed. + const shown = [...entry.swap.old]; + const building = []; + for (const record of previous) + (record.primitive.ready && !record.primitive.isDestroyed?.() + ? shown + : building + ).push(record); + entry.swap.stop(); + entry.swap = null; + removePrimitives(building); + previous = shown; + } entry.primitives = []; attachPrimitive(entry); removeWhenReady(entry, previous); diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index b0b5f7e94..7fdc19773 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -397,6 +397,63 @@ test('a tile redrawn for a new share keeps its old lines until the new ones are ); }); +test('a second redraw while a swap still builds keeps the lines on screen', async (t) => { + const { state, coverage, viewer } = setup(t, { + zoomFor: () => ({ zoom: 18, kind: 'sequence' }), + tuning: { + ...TUNING, + maxTiles: 9, + maxSequences: 900, + maxPoints: 90, + keepOrder: true, + }, + decoded: (centre, tile) => { + const dense = tile.x % 2 === 0 && tile.y % 2 === 0; + return { + sequences: dense + ? Array.from({ length: 30 }, (_, i) => + pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre), + ) + : [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)], + overview: [], + }; + }, + }); + coverage.refresh(); + assert.ok( + await until( + () => state.coverage.tiles.size > 1 && !state.coverage.pending.size, + ), + ); + // A tile mid-swap (its new lines never become ready here) with a line + // the cap left out: selecting that line redraws the tile again. + const entry = [...state.coverage.tiles.values()].find( + (tile) => + tile.swap && + tile.sequenceList.some((sequence) => !tile.sequences.has(sequence.id)), + ); + assert.ok(entry, 'a swapping tile with a line left out'); + const onScreen = [...entry.swap.old]; + const building = entry.primitives.map(({ primitive }) => primitive); + const left = entry.sequenceList.find( + (sequence) => !entry.sequences.has(sequence.id), + ); + state.sequence.selectedId = left.id; + coverage.recolorSequence(left.id, true); + assert.ok(entry.sequences.has(left.id), 'the selected line is drawn'); + const onGlobe = (primitive) => + viewer.scene.groundPrimitives.items.has(primitive) || + viewer.scene.primitives.items.has(primitive); + for (const { primitive } of onScreen) + assert.ok( + onGlobe(primitive), + 'the lines on screen stay until the new ones are ready', + ); + for (const primitive of building) + assert.ok(!onGlobe(primitive), 'the first swap’s unready lines are gone'); + assert.ok(entry.swap, 'still swapping'); +}); + test('a selected line outside the cap does not make its tile redraw on every settle, nor does one the filter hides', async (t) => { let pano = 'all'; const { state, coverage, viewer } = setup(t, { diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index 3de2c1af7..079cf82c8 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -3,6 +3,7 @@ import { PANO_FOV_DEFAULT_DEG, wrapDegrees, } from './panorama.js'; +import { imageSize } from './imageSize.js'; /** A photo download (tens of kilobytes to a few megabytes). */ export const IMAGE_TIMEOUT_MS = 15_000; @@ -10,6 +11,19 @@ export const IMAGE_TIMEOUT_MS = 15_000; /** A picture file or decoded side past these is refused (a photo is a few MB). */ export const MAX_IMAGE_BYTES = 48 * 1024 * 1024; export const MAX_IMAGE_SIDE = 16_384; +/** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */ +export const MAX_IMAGE_PIXELS = 8_192 * 8_192; +/** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */ +const HEADER_BYTES = 256 * 1024; + +/** Whether a picture this size is past what the viewer decodes. */ +export function tooManyPixels(width, height) { + return ( + width > MAX_IMAGE_SIDE || + height > MAX_IMAGE_SIDE || + width * height > MAX_IMAGE_PIXELS + ); +} /** Panorama texture widths: enough for a docked viewer, and for an expanded one. */ export const PANO_MID_SIZE = Object.freeze([2048, 1024]); @@ -43,19 +57,36 @@ export function loadImage( return loadBitmap(url, timeoutMs, resize, failure, signal); return new Promise((resolve, reject) => { if (!url) return reject(new Error(failure)); + if (signal?.aborted) return reject(new Error(failure)); const image = new Image(); if (crossOrigin) image.crossOrigin = 'anonymous'; image.decoding = 'async'; - const timer = setTimeout(() => { + // Stop the download: another photo opened, or the time ran out. + const stop = () => { + clearTimeout(timer); + signal?.removeEventListener?.('abort', stop); + image.onload = image.onerror = null; image.src = ''; reject(new Error(failure)); - }, timeoutMs); - image.onload = () => { + }; + const timer = setTimeout(stop, timeoutMs); + signal?.addEventListener?.('abort', stop, { once: true }); + const settle = () => { clearTimeout(timer); + signal?.removeEventListener?.('abort', stop); + }; + image.onload = () => { + settle(); + // An reads no header first: refuse what it decoded too large. + if (tooManyPixels(image.naturalWidth || 0, image.naturalHeight || 0)) { + image.src = ''; + reject(oversized(failure)); + return; + } resolve(image); }; image.onerror = () => { - clearTimeout(timer); + settle(); reject(new Error(failure)); }; image.src = url; @@ -80,6 +111,12 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { const length = Number(response.headers?.get?.('content-length')); if (length > MAX_IMAGE_BYTES) throw oversized(failure); const blob = await boundedBlob(response, failure); + // Its declared size, before anything decodes it. + const size = imageSize( + new Uint8Array(await blob.slice(0, HEADER_BYTES).arrayBuffer()), + ); + if (size && tooManyPixels(size.width, size.height)) + throw oversized(failure); const bitmap = await (resize ? createImageBitmap(blob, { resizeWidth: resize[0], @@ -88,7 +125,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { }) : createImageBitmap(blob)); // (A resized decode reports its new size: this guards unresized ones.) - if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) { + if (tooManyPixels(bitmap.width, bitmap.height)) { bitmap.close?.(); throw oversized(failure); } @@ -152,7 +189,7 @@ export function panoSizeFor(canvasPixels, fovDeg) { * The panorama shown first, its texture width (0 when it is only a preview), and a size to shrink it to. * @property {(photo: object, size: [number, number], view: {expanded: boolean}) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp * A sharper panorama for a canvas that needs `size`, or null; `expanded` while the viewer is. - * @property {(photo: object) => Array} flatFirst Flat versions to try in turn. + * @property {(photo: object, view: {expanded: boolean}) => Array} flatFirst Flat versions to try in turn (`expanded` while the viewer is). * @property {(photo: object, pixels: number) => string|null} flatSharp * A sharper flat version for a picture this many device pixels wide, or null. * @property {(photo: object) => {title?: string|null, creator?: string|null, license?: string|null, externalUrl: string, capturedAtPrecision?: 'day'|'month'}} describe @@ -176,6 +213,7 @@ export function panoSizeFor(canvasPixels, fovDeg) { * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). * @param {(photo: object) => void} [options.onShown] A photo is on screen. + * @param {(photo: object) => void} [options.onShowing] The photo this open shows, just before its pose. * @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`). * @param {() => boolean} [options.onEscape] Esc in the docked viewer: true when it was used (e.g. a selection cleared). * @param {object} [options.render] Render governor. @@ -191,6 +229,7 @@ export function createPhotoViewer({ step, onStep, onShown = () => {}, + onShowing = () => {}, onReopen = onStep, onEscape = () => false, render, @@ -227,8 +266,10 @@ export function createPhotoViewer({ let panoComing = null; /** A resize came while it loaded: look again once it lands. */ let lookAgain = false; - /** The sharper flat version on screen or on its way (one download each). */ - let flatSharpUrl = null; + /** The sharper flat version on screen, and the one on its way. */ + let flatSharp = { shown: null, loading: null }; + /** The open in progress: another open, a close or an unmount stops its downloads. */ + let openAbort = null; /** 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. */ @@ -626,6 +667,9 @@ export function createPhotoViewer({ async function upgrade(target, seq, kind) { // A 360° photo shown flat (no WebGL) keeps its front view. if (kind === 'flat' && target.isPano) return; + // Another photo is opening: the one still shown is not worth more bytes. + if (openingId !== null && openingId !== target.id) return; + const signal = openAbort?.signal; if (kind === 'pano') { const size = panoSizeNow(); if (size[0] <= panoWidth) return; @@ -636,7 +680,7 @@ export function createPhotoViewer({ return; } const sharp = sources.panoSharp(target, size, { - expanded: Boolean(root?.closest?.('.sl-viewer-wrap-expanded')), + expanded: isExpanded(), }); if (!sharp?.url) return; const coming = { seq, width: size[0], controller: new AbortController() }; @@ -674,19 +718,28 @@ export function createPhotoViewer({ return; } const url = sources.flatSharp(target, viewerPixels()); - if (!url || url === flatSharpUrl) return; - flatSharpUrl = url; + if (!url || url === flatSharp.shown || url === flatSharp.loading) return; + const sharp = flatSharp; + sharp.loading = url; try { - const image = await loadAs(url, { crossOrigin: false }); + const image = await loadAs(url, { crossOrigin: false, signal }); if (seq !== openSeq || photo !== target || showing !== 'flat') return; picture.src = image.src; + sharp.shown = url; requestRender(); } catch { - // The first version stays; a later resize may try again. - if (flatSharpUrl === url && photo === target) flatSharpUrl = null; + /* the first version stays; a later resize may try again */ + } finally { + // Discarded or failed: nothing is on its way, so it may be asked again. + if (sharp.loading === url) sharp.loading = null; } } + /** The viewer is the expanded dialog, not docked in the panel. */ + function isExpanded() { + return Boolean(root?.closest?.('.sl-viewer-wrap-expanded')); + } + /** Another photo, or none: a sharper panorama still on its way is not needed. */ function dropSharpPanorama() { panoComing?.controller?.abort(); @@ -701,14 +754,16 @@ export function createPhotoViewer({ return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG); } - async function showPano(target, seq, keepView) { + async function showPano(target, seq, keepView, signal) { // If the panorama fails, the caller shows the front view rather than // waiting on it twice. const first = sources.panoFirst(target); if (!first?.url) throw new Error(LOAD_FAILED); const image = await loadAs(first.url, { crossOrigin: true, - resize: first.resize ?? null, + signal, + // Never decoded at more than the texture it becomes. + resize: first.resize ?? PANO_MID_SIZE, }); if (seq !== openSeq) { image.close?.(); @@ -730,17 +785,20 @@ export function createPhotoViewer({ show('pano'); renderer.setImage(image); panoWidth = first.width || 0; - panoComing = null; + // A sharper version of the photo before was dropped by `open`; one of + // this photo, asked by a resize meanwhile, is still worth having. return true; } - async function showFlat(target, seq) { - const urls = sources.flatFirst(target).filter(Boolean); + async function showFlat(target, seq, signal) { + const urls = sources + .flatFirst(target, { expanded: isExpanded() }) + .filter(Boolean); let image; let failure = null; for (const url of urls) { try { - image = await loadAs(url, { crossOrigin: false }); + image = await loadAs(url, { crossOrigin: false, signal }); break; } catch (error) { failure = error; @@ -750,26 +808,27 @@ export function createPhotoViewer({ if (!image) throw failure || new Error(LOAD_FAILED); if (seq !== openSeq) return false; picture.src = image.src; - flatSharpUrl = null; + flatSharp = { shown: null, loading: null }; photo = target; show('flat'); return true; } - async function openPhoto(id, seq, keepView) { - const target = await getPhoto(id); + async function openPhoto(id, seq, keepView, signal) { + const target = await getPhoto(id, { signal }); if (seq !== openSeq) return; let shown = false; if (sources.hasPano(target) && ensureRenderer()) { try { - shown = await showPano(target, seq, keepView); + shown = await showPano(target, seq, keepView, signal); } catch { // The panorama failed: its front view is better than nothing. if (seq !== openSeq) return; - shown = await showFlat(target, seq); + shown = await showFlat(target, seq, signal); } - } else shown = await showFlat(target, seq); + } else shown = await showFlat(target, seq, signal); if (!shown) return; + onShowing(target); emit(); onShown(target); upgrade(target, seq, showing); @@ -790,11 +849,18 @@ export function createPhotoViewer({ const id = String(imageId); const seq = ++openSeq; dropSharpPanorama(); + // The previous open's downloads stop: only this photo is wanted now. + openAbort?.abort(); + const opening = (openAbort = new AbortController()); const keepView = keepViewFor === id; keepViewFor = null; openingId = id; try { - await openPhoto(id, seq, keepView); + await openPhoto(id, seq, keepView, opening.signal); + } catch (error) { + // A later open, or a close, stopped this one: that is not a failure. + if (seq !== openSeq) return; + throw error; } finally { if (openingId === id && seq === openSeq) openingId = null; } @@ -803,10 +869,12 @@ export function createPhotoViewer({ close() { openSeq++; dropSharpPanorama(); + openAbort?.abort(); + openAbort = null; openingId = null; keepViewFor = null; photo = null; - flatSharpUrl = null; + flatSharp = { shown: null, loading: null }; show(null); if (picture) picture.removeAttribute('src'); renderer?.clear?.(); @@ -815,10 +883,12 @@ export function createPhotoViewer({ unmount() { openSeq++; dropSharpPanorama(); + openAbort?.abort(); + openAbort = null; refused.clear(); openingId = null; keepViewFor = null; - flatSharpUrl = null; + flatSharp = { shown: null, loading: null }; teardown(); }, diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 758a1e25b..09df91c2e 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -7,6 +7,7 @@ const PHOTOS = { a: { id: 'a', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true }, b: { id: 'b', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true }, f: { id: 'f', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false }, + g: { id: 'g', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false }, }; /** Sources whose panoramas and sharper flat versions are named after the photo. */ @@ -294,7 +295,11 @@ test('a picture file or bitmap past any real photo’s size is refused', async ( (globalThis.fetch = async () => ({ ok: true, headers: new Map([['content-length', String(length ?? '')]]), - blob: async () => ({ size: size ?? 10, bitmap: bitmap(width, height) }), + blob: async () => ({ + size: size ?? 10, + bitmap: bitmap(width, height), + slice: () => ({ arrayBuffer: async () => new ArrayBuffer(0) }), + }), })); const refusedForSize = (error) => { assert.match(error.message, /could not be loaded/); @@ -331,3 +336,203 @@ test('a picture file or bitmap past any real photo’s size is refused', async ( respond({}); assert.equal((await loadImage('https://x/a.jpg')).width, 100); }); + +/** A viewer whose loads are held, recording each load's signal by url. */ +function heldViewer() { + const pending = new Map(); + const signals = new Map(); + const loads = []; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + load: (url, options = {}) => + new Promise((resolve, reject) => { + loads.push(url); + signals.set(url, options.signal); + options.signal?.addEventListener('abort', () => + reject(new Error('aborted')), + ); + pending.set(url, { resolve, reject }); + }), + }); + const release = (url) => { + pending.get(url).resolve({ src: url }); + pending.delete(url); + }; + return { viewer, pending, signals, loads, release, host: fakeHost() }; +} + +test('a new open, a close or an unmount stops the downloads of the open before it', async () => { + const env = heldViewer(); + await env.viewer.mount(env.host); + const first = env.viewer.open('f'); + assert.ok(await until(() => env.signals.has('f-first'))); + const second = env.viewer.open('g'); + assert.equal(env.signals.get('f-first').aborted, true, 'f is not wanted now'); + await first.catch(() => {}); + assert.ok(await until(() => env.signals.has('g-first'))); + env.viewer.close(); + assert.equal(env.signals.get('g-first').aborted, true, 'closed'); + await second.catch(() => {}); + const third = env.viewer.open('f'); + assert.ok(await until(() => env.pending.has('f-first'))); + env.viewer.unmount(); + assert.equal(env.signals.get('f-first').aborted, true, 'unmounted'); + await third.catch(() => {}); +}); + +test('a sharper flat version on its way stops when another photo opens, and is asked again later', async (t) => { + t.after(() => delete globalThis.devicePixelRatio); + globalThis.devicePixelRatio = 1; + const env = heldViewer(); + await env.viewer.mount(env.host); + parts(env.host).picture.clientWidth = 1200; + const opening = env.viewer.open('f'); + assert.ok(await until(() => env.pending.has('f-first'))); + env.release('f-first'); + await opening; + assert.ok( + await until(() => env.pending.has('f-sharp')), + 'the 1,200 px viewer asks for more', + ); + // Resizes while it loads (the panel animating) do not ask again. + env.viewer.resize(); + env.viewer.resize(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(env.loads.filter((url) => url === 'f-sharp').length, 1); + // g opens (and fails): f's sharp download stops, f stays on screen. + const failing = env.viewer.open('g'); + assert.equal(env.signals.get('f-sharp').aborted, true); + assert.ok(await until(() => env.pending.has('g-first'))); + env.pending.get('g-first').reject(new Error('gone')); + env.pending.delete('g-first'); + await failing.catch(() => {}); + // The discarded response does not block f's sharp version for good. + env.signals.delete('f-sharp'); + env.viewer.resize(); + assert.ok(await until(() => env.signals.has('f-sharp')), 'asked again'); +}); + +test('a resize while another photo opens does not upgrade the photo still shown', async (t) => { + t.after(() => delete globalThis.devicePixelRatio); + globalThis.devicePixelRatio = 1; + const env = heldViewer(); + await env.viewer.mount(env.host); + const opening = env.viewer.open('f'); + assert.ok(await until(() => env.pending.has('f-first'))); + env.release('f-first'); + await opening; + // g is opening; EXPAND resizes the viewer meanwhile. + const next = env.viewer.open('g'); + assert.ok(await until(() => env.pending.has('g-first'))); + parts(env.host).picture.clientWidth = 1200; + env.viewer.resize(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(env.pending.has('f-sharp'), false, 'f is on its way out'); + env.release('g-first'); + await next; + assert.ok(await until(() => env.pending.has('g-sharp')), 'g gets it'); +}); + +test('a panorama is always decoded at a bounded size', async () => { + const env = heldViewer(); + const options = []; + const viewer = createPhotoViewer({ + providerId: 'test', + name: 'Test', + sources: SOURCES, + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + load: async (url, opts) => { + options.push(opts); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView() {}, + getView: () => ({ yaw: 0, pitch: 0, fov: 80 }), + resize() {}, + clear() {}, + destroy() {}, + }), + }); + await viewer.mount(env.host); + await viewer.open('a'); + // SOURCES' panoFirst names no size: the viewer still bounds it. + assert.deepEqual(options[0].resize, [2048, 1024]); +}); + +test('a header that declares a huge image is refused before anything decodes it', async (t) => { + const { loadImage } = await import('./photoViewer.js'); + const saved = { + fetch: globalThis.fetch, + createImageBitmap: globalThis.createImageBitmap, + }; + t.after(() => Object.assign(globalThis, saved)); + let decoded = 0; + globalThis.createImageBitmap = async () => { + decoded++; + return { width: 10, height: 10 }; + }; + // A 1 kB PNG that says it is 16,384 × 16,384. + const png = new Uint8Array(1024); + png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); + png.set( + [...'IHDR'].map((c) => c.charCodeAt(0)), + 12, + ); + png.set([0, 0, 0x40, 0, 0, 0, 0x40, 0], 16); + globalThis.fetch = async () => new Response(png); + await assert.rejects( + loadImage('https://x/bomb.png'), + (error) => error.tooLarge === true, + ); + assert.equal(decoded, 0, 'never decoded'); +}); + +test('an that turns out too large is refused and let go', async (t) => { + const { loadImage, MAX_IMAGE_SIDE } = await import('./photoViewer.js'); + const saved = globalThis.Image; + t.after(() => (globalThis.Image = saved)); + let size = [MAX_IMAGE_SIDE + 1, 100]; + const images = []; + globalThis.Image = class { + constructor() { + images.push(this); + } + set src(value) { + this._src = value; + if (value) + queueMicrotask(() => { + [this.naturalWidth, this.naturalHeight] = size; + this.onload?.(); + }); + } + get src() { + return this._src; + } + }; + await assert.rejects( + loadImage('https://x/a.jpg', { crossOrigin: false }), + (error) => error.tooLarge === true, + ); + assert.equal(images.at(-1).src, '', 'its pixels let go'); + size = [4000, 3000]; + const ok = await loadImage('https://x/b.jpg', { crossOrigin: false }); + assert.equal(ok.naturalWidth, 4000); + // An aborted load stops and rejects. + size = [10, 10]; + const controller = new AbortController(); + const aborted = loadImage('https://x/c.jpg', { + crossOrigin: false, + signal: controller.signal, + }); + controller.abort(); + await assert.rejects(aborted); + assert.equal(images.at(-1).src, '', 'its download stops'); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index d33adef97..9b1d0884b 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -37,6 +37,7 @@ const KARTAVIEW_SOURCES = Object.freeze({ panoFirst: (photo) => ({ url: resizedImageUrl(photo.urls.pano, PANO_PREVIEW_SIZE), width: 0, + resize: PANO_PREVIEW_SIZE, }), panoSharp: (photo, size) => ({ url: resizedImageUrl(photo.urls.pano, size), diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 1391b727c..9ca5174c1 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -883,6 +883,11 @@ test('the panorama is as sharp as the viewer needs: 2,048 docked, 4,096 once exp }); test('a CORS image is decoded off the main thread into an ImageBitmap where the browser can', async (t) => { + // A blob whose header names no size (the viewer then reads none). + const fakeBlob = { + size: 4, + slice: () => ({ arrayBuffer: async () => new ArrayBuffer(0) }), + }; const saved = { fetch: globalThis.fetch, bitmap: globalThis.createImageBitmap, @@ -896,7 +901,7 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the fetched.push({ url, mode: init.mode }); return url.includes('gone') ? { ok: false, status: 409 } - : { ok: true, blob: async () => 'blob' }; + : { ok: true, blob: async () => fakeBlob }; }; globalThis.createImageBitmap = async (blob) => ({ blob, @@ -906,7 +911,7 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the const bitmap = await loadImage('https://cdn.kartaview.org/a', { crossOrigin: true, }); - assert.deepEqual(bitmap, { blob: 'blob', width: 4096, height: 2048 }); + assert.deepEqual(bitmap, { blob: fakeBlob, width: 4096, height: 2048 }); assert.deepEqual(fetched, [ { url: 'https://cdn.kartaview.org/a', mode: 'cors' }, ]); diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index 0a354d06b..b8cbfeacc 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -2,6 +2,7 @@ import { PbfReader } from 'pbf'; import { VectorTile } from '@mapbox/vector-tile'; import { tileLocalToLonLat } from '../../tileMath.js'; import { + DECODE_SLICE_MS, OVERVIEW_BINS_PER_AXIS, SIMPLIFY_TOLERANCE_UNITS, panoramaxTimeMs, @@ -66,11 +67,62 @@ function dayMs(value) { * most OVERVIEW_BINS_PER_AXIS² a tile (a z0 tile holds ~70,000 cells). A * cell has 360° and flat counts but no date. * - `pictures` (z15) is left for `decodePictures`. + * + * In one go; `decodeCoverageTileSliced` gives the page a turn between + * slices. * @param {Uint8Array} bytes * @param {{x: number, y: number, z: number}} address * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array<{id: string, lon: number, lat: number, capturedAt: null, isPano: boolean, hasPano: boolean, hasFlat: boolean, weight: number}>}} */ export function decodeCoverageTile(bytes, address) { + const steps = coverageSteps(bytes, address); + let step = steps.next(); + while (!step.done) step = steps.next(); + return step.value; +} + +/** Give the page a turn: input and rendering run before the next slice. */ +function nextTask() { + if (typeof globalThis.scheduler?.yield === 'function') + return globalThis.scheduler.yield(); + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * `decodeCoverageTile` in slices of about `sliceMs`, the page getting a + * turn between them: a 2 MB z10 tile over Paris is ~200 ms of decoding on + * a fast laptop, more on a phone, and would freeze the globe while the + * camera approaches. Stops at the next slice once `signal` aborts. + * @param {Uint8Array} bytes + * @param {{x: number, y: number, z: number}} address + * @param {{signal?: AbortSignal, sliceMs?: number, pause?: () => Promise, now?: () => number}} [options] + */ +export async function decodeCoverageTileSliced( + bytes, + address, + { + signal, + sliceMs = DECODE_SLICE_MS, + pause = nextTask, + now = () => performance.now(), + } = {}, +) { + const steps = coverageSteps(bytes, address); + let started = now(); + let step = steps.next(); + while (!step.done) { + if (now() - started >= sliceMs) { + await pause(); + signal?.throwIfAborted(); + started = now(); + } + step = steps.next(); + } + return step.value; +} + +/** The decode, a step a feature (see `decodeCoverageTile`). */ +function* coverageSteps(bytes, address) { const result = { sequences: [], overview: [] }; if (!bytes || !bytes.length) return result; const tile = new VectorTile(new PbfReader(bytes)); @@ -78,7 +130,7 @@ export function decodeCoverageTile(bytes, address) { const sequences = tile.layers.sequences; if (sequences) { - const lengths = new Map(); + const measured = []; for (let i = 0; i < sequences.length; i++) { const feature = sequences.feature(i); const props = feature.properties || {}; @@ -113,10 +165,13 @@ export function decodeCoverageTile(bytes, address) { ? false : null, }; - lengths.set(sequence, length); - result.sequences.push(sequence); + measured.push({ sequence, length }); + yield; } - result.sequences.sort((a, b) => lengths.get(b) - lengths.get(a)); + // A turn before the sort: over a city it is thousands of lines. + yield; + measured.sort((a, b) => b.length - a.length); + result.sequences = measured.map(({ sequence }) => sequence); } const grid = tile.layers.grid; @@ -127,6 +182,7 @@ export function decodeCoverageTile(bytes, address) { const feature = grid.feature(i); const props = feature.properties || {}; const point = feature.loadGeometry()?.[0]?.[0]; + if (i % 64 === 63) yield; if (!point) continue; const pano = Number(props.nb_360_pictures) || 0; const flat = Number(props.nb_flat_pictures) || 0; diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index c98ff3461..7caa25a63 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { decodeCoverageTile, decodePictures, simplifyLine } from './decode.js'; +import { + decodeCoverageTile, + decodeCoverageTileSliced, + decodePictures, + simplifyLine, +} from './decode.js'; import { OVERVIEW_BINS_PER_AXIS } from './policy.js'; import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs'; import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs'; @@ -69,10 +74,11 @@ test('sequence lines keep their parts apart, with their day and camera type', () test('density cells bin to a bounded number of points that keep the counts and kinds', () => { const tile = { x: 0, y: 0, z: 0 }; - // Two cells in one bin, one far away, one empty. + // Two cells in one bin (3.75° wide at z0) far enough apart to tell + // which it sits on, one far away, one empty. const grid = [ - { lon: 2.35, lat: 48.85, pano: 0, flat: 5 }, - { lon: 2.36, lat: 48.85, pano: 30, flat: 0 }, + { lon: 0.3, lat: 48.85, pano: 0, flat: 5 }, + { lon: 3.4, lat: 48.85, pano: 30, flat: 0 }, { lon: -73.9, lat: 40.7, pano: 1, flat: 0 }, { lon: 10, lat: 10, pano: 0, flat: 0 }, ]; @@ -87,7 +93,10 @@ test('density cells bin to a bounded number of points that keep the counts and k assert.equal(paris.hasFlat, true); assert.equal(paris.isPano, true, 'mostly 360°'); assert.equal(paris.capturedAt, null, 'cells are undated'); - assert.ok(near(paris.lon, 2.36, 0.2), 'sits on the busiest cell'); + assert.ok( + near(paris.lon, 3.4, 0.2), + `sits on the busiest cell: ${paris.lon}`, + ); const nyc = overview.find((p) => p.lon < 0); assert.deepEqual([nyc.hasPano, nyc.hasFlat, nyc.weight], [true, false, 1]); }); @@ -134,6 +143,71 @@ test('a dense grid tile never draws more than the bins allow', () => { ); }); +/** A tile with many lines and a dense grid, for the sliced decode. */ +function busyTile() { + const { tile, lon, lat } = tileCentre(2.35, 48.85); + const sequences = Array.from({ length: 200 }, (_, i) => ({ + id: `s${i}`, + parts: [ + [ + [lon, lat + i / 1e6], + [lon + (i + 1) / 73_000, lat + i / 1e6], + ], + ], + })); + const grid = Array.from({ length: 500 }, (_, i) => ({ + lon: lon + (i % 25) / 1e4, + lat: lat + Math.floor(i / 25) / 1e4, + })); + return { tile, bytes: encodePanoramaxTile(tile, { sequences, grid }) }; +} + +test('a sliced decode gives the page turns and decodes the same as one in one go', async () => { + const { tile, bytes } = busyTile(); + let clock = 0; + let pauses = 0; + const sliced = await decodeCoverageTileSliced(bytes, tile, { + sliceMs: 8, + // Each step "takes" a millisecond: a turn every eight features. + now: () => clock++, + pause: async () => { + pauses++; + }, + }); + assert.ok(pauses > 20, `${pauses} turns`); + assert.deepEqual(sliced, decodeCoverageTile(bytes, tile)); +}); + +test('a sliced decode stops at the next turn once aborted', async () => { + const { tile, bytes } = busyTile(); + const controller = new AbortController(); + let clock = 0; + let pauses = 0; + await assert.rejects( + decodeCoverageTileSliced(bytes, tile, { + signal: controller.signal, + now: () => (clock += 10), + pause: async () => { + if (++pauses === 2) controller.abort(); + }, + }), + { name: 'AbortError' }, + ); + assert.equal(pauses, 2, 'no work after the abort'); +}); + +test('a fast sliced decode takes no turns', async () => { + const { tile, bytes } = busyTile(); + let pauses = 0; + await decodeCoverageTileSliced(bytes, tile, { + now: () => 0, + pause: async () => { + pauses++; + }, + }); + assert.equal(pauses, 0); +}); + test('an empty tile decodes to nothing', () => { for (const bytes of [new Uint8Array(0), null, undefined]) assert.deepEqual(decodeCoverageTile(bytes, { x: 0, y: 0, z: 0 }), { diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index b6cfa93c8..3ce1f5d21 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -9,7 +9,7 @@ import { } from '../../tileMath.js'; import { metresBetween } from '../../view.js'; import { createLru, createTileCache } from '../../tileCache.js'; -import { decodeCoverageTile, decodePictures } from './decode.js'; +import { decodeCoverageTileSliced, decodePictures } from './decode.js'; import { createPanoramaxSource } from './source.js'; import { createPanoramaxViewer } from './viewer.js'; import { @@ -230,16 +230,23 @@ export function createPanoramaxProvider({ return photo; } - /** Lookups in flight, shared: ‹ › and the viewer ask for the same picture. */ + /** + * Lookups in flight, shared: ‹ › and the viewer ask for the same + * picture. A lookup stops once every caller waiting on it has given up + * (its search would hold one of the two API connections for nothing). + */ const inflight = new Map(); - function getPhoto(photoId) { + function getPhoto(photoId, { signal } = {}) { const id = String(photoId); const cached = photos.get(id); if (cached) return Promise.resolve(cached); + if (signal?.aborted) return Promise.reject(signal.reason); let lookup = inflight.get(id); if (!lookup) { - lookup = source - .getItem(id) + const controller = new AbortController(); + lookup = { controller, waiting: 0 }; + lookup.promise = source + .getItem(id, { signal: controller.signal }) .catch((error) => { // Ids are UUIDs: the API answers 400 to anything else. if (error?.status === 400) return null; @@ -250,10 +257,39 @@ export function createPanoramaxProvider({ if (!photo) throw new Error(GONE_MESSAGE); return photo; }) - .finally(() => inflight.delete(id)); + .finally(() => { + if (inflight.get(id) === lookup) inflight.delete(id); + }); + lookup.promise.catch(() => {}); inflight.set(id, lookup); } - return lookup; + const shared = lookup; + shared.waiting++; + if (!signal) shared.waiting = Infinity; + return new Promise((resolve, reject) => { + const leave = () => { + reject(signal.reason); + shared.waiting--; + // A caller in the same tick (the same photo opened again) may + // still join: stop only if nobody has. + queueMicrotask(() => { + if (shared.waiting > 0) return; + shared.controller.abort(signal.reason); + if (inflight.get(id) === shared) inflight.delete(id); + }); + }; + signal?.addEventListener('abort', leave, { once: true }); + shared.promise.then( + (photo) => { + signal?.removeEventListener('abort', leave); + resolve(photo); + }, + (error) => { + signal?.removeEventListener('abort', leave); + reject(error); + }, + ); + }); } /** @@ -281,7 +317,11 @@ export function createPanoramaxProvider({ size: COVERAGE_TILE_CACHE_SIZE, load: (tile, { signal }) => readable(async () => - decodeCoverageTile(await source.getTile(tile, { signal }), tile), + decodeCoverageTileSliced( + await source.getTile(tile, { signal }), + tile, + { signal }, + ), ), }); /** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */ @@ -326,21 +366,40 @@ export function createPanoramaxProvider({ const tiles = pictureTilesAround(anchor); // Known from the start: a picture opening while these load is // judged against them (re-centred if outside, left alone inside). - drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) }; - // The home tile must load; a neighbour that fails only thins the cones. - const lists = await Promise.allSettled( - tiles.map((tile) => picturesIn(tile, sequenceId, signal)), - ); + const covering = { sequenceId, tiles: new Set(tiles.map(tileKey)) }; + drawn = covering; + const conesOf = (lists) => + thinImages( + [ + ...new Map( + lists.flat().map((image) => [image.id, image]), + ).values(), + ].sort((a, b) => a.capturedAt - b.capturedAt), + ); + const keyOf = (tile) => `${tileKey(tile)}:${sequenceId}`; + // The home tile is needed now (it holds the open picture). + const home = await picturesIn(tiles[0], sequenceId, signal); signal?.throwIfAborted(); - if (lists[0].status === 'rejected') throw lists[0].reason; - const byId = new Map( - lists - .flatMap((list) => list.value ?? []) - .map((image) => [image.id, image]), - ); - return thinImages( - [...byId.values()].sort((a, b) => a.capturedAt - b.capturedAt), + // Neighbours already read draw with it; the rest (each up to + // 1.4 MB in central Paris) join the cones as they arrive. + const neighbours = tiles.slice(1); + const kept = neighbours + .map((tile) => pictureLists.get(keyOf(tile))) + .filter(Boolean); + const missing = neighbours.filter( + (tile) => !pictureLists.get(keyOf(tile)), ); + if (missing.length) + Promise.allSettled( + missing.map((tile) => picturesIn(tile, sequenceId, signal)), + ).then((lists) => { + if (signal?.aborted || drawn !== covering) return; + dropFailed(covering, missing, lists); + // Only a tile that arrived now redraws (no loop on failures). + if (lists.some((list) => list.status === 'fulfilled')) + cones.select(sequenceId, { force: true }); + }); + return conesOf([home, ...kept]); }, colors: COLORS, pickPrefix: PICK_PREFIX.image, @@ -350,6 +409,17 @@ export function createPanoramaxProvider({ coverage.recolorSequence(sequenceId, selected), }); + /** + * A tile that failed is not covered: a picture opening in it later + * re-centres the cones there (and asks for the tile again). + */ + function dropFailed(covering, tiles, lists) { + lists.forEach((list, i) => { + if (list.status === 'rejected') + covering.tiles.delete(tileKey(tiles[i])); + }); + } + /** * The cones show the z15 tiles around one picture: once a picture of * the selected sequence opens outside them (‹ ›, a cone), re-centre. @@ -363,11 +433,23 @@ export function createPanoramaxProvider({ } const viewer = createPanoramaxViewer({ - getPhoto: async (photoId) => { - const photo = await getPhoto(photoId); + getPhoto, + // The cones centre on the picture actually shown, never on a + // lookup that came back after the user moved on. + onShowing: (photo) => { if (photo.sequenceId) anchors.set(photo.sequenceId, photo); - return photo; }, + // After a GPU reset the same picture is drawn again by the viewer + // itself: through the core it would cancel a lookup the user + // started. A failure still shows. + onReopen: (photoId) => + viewer + .open(photoId) + .catch((error) => + context.actions.reportError( + error?.message || 'The Panoramax photo could not be loaded', + ), + ), step, onStep: (photoId) => context.actions.openImage(photoId, { frame: false }), @@ -449,9 +531,9 @@ export function createPanoramaxProvider({ coverageStats() { const { kind } = state.coverage; const lines = kind === 'sequence'; - const drawn = coverage.sequenceCount(); + const shown = coverage.sequenceCount(); // Density dots are not sequences: they are not counted as such. - const count = lines ? drawn : 0; + const count = lines ? shown : 0; const loading = state.coverage.pending.size > 0; const settled = !loading && !state.coverage.lastError; let fallback = ''; @@ -461,7 +543,7 @@ export function createPanoramaxProvider({ ); if (kind === 'overview') fallback = - drawn > 0 || !settled + shown > 0 || !settled ? OVERVIEW_HINT : filteredOut ? FILTERED_HINT diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index 7e8ccca0d..198b8bb8b 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -66,6 +66,13 @@ export const COVERAGE_TUNING = Object.freeze({ * within GPS noise), and 6–10% of the points GPS tracks carry. */ export const SIMPLIFY_TOLERANCE_UNITS = 4; + +/** + * Coverage tiles decode in slices of this many ms, the page getting a turn + * between them (decode.js `decodeCoverageTileSliced`): under a frame. + */ +export const DECODE_SLICE_MS = 8; + /** * Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells * (1.9 MB), drawn as this many binned points. @@ -89,6 +96,14 @@ export const PICTURE_TILE_ZOOM = 15; export const PICTURE_TILE_EDGE_M = 150; export const TILE_TIMEOUT_MS = 20_000; +/** + * Once an answer has started, it may take as long as it keeps coming: a + * 2 MB tile on a slow link outlasts any whole-request timeout, so only a + * stream silent this long is given up. + */ +export const STREAM_IDLE_TIMEOUT_MS = 15_000; +/** Busy (429/503) without a Retry-After the browser can read: back off from this. */ +export const BUSY_BACKOFF_BASE_SEC = 5; export const API_TIMEOUT_MS = 12_000; /** Real tiles are under 6 MB (z6 over France); refuse anything absurd. */ export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; @@ -111,8 +126,12 @@ export const NEAREST_LIMIT = 10; * beside it has those). */ export const NEAREST_FOV_TOLERANCE_DEG = 180; -/** Pictures in the bounding box around a click, at most. */ -export const NEAREST_BOX_LIMIT = 100; +/** + * Pictures in the bounding box around a click, at most. The API returns + * them roughly nearest first (the first ten of 100 are the first ten of + * 10), and the browser ranks them exactly: 20 is ~60 kB, 100 ~600 kB. + */ +export const NEAREST_BOX_LIMIT = 20; /** * A line clicked from high up may be tens of metres off the click: its * sequence is searched within the camera height / 100 (50–400 m). @@ -194,10 +213,12 @@ function linkedId(item, rel) { } } -/** An email address anywhere in a name (linear: names come from any instance). */ +/** + * An email address anywhere in a name, at any of its '@'s. Linear: names + * come from any instance, and each run between '@'s is scanned once. + */ function looksLikeEmail(name) { - const at = name.indexOf('@'); - return at > 0 && name.indexOf('.', at + 2) !== -1; + return /[^\s@]@[^\s@]+\.[^\s@]/.test(name); } /** @@ -217,12 +238,14 @@ function creatorOf(item) { // Every producer is credited (an uploading app and its photographer), // each name once, none that is an email address. const credited = []; + const seen = new Set(); for (const value of names) { if (typeof value !== 'string') continue; const name = value.trim().slice(0, 80); if (!name || looksLikeEmail(value)) continue; - if (credited.some((other) => other.toLowerCase() === name.toLowerCase())) - continue; + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); credited.push(name); } return credited.length ? credited.join(', ').slice(0, 120) : null; diff --git a/src/layers/streetLevel/providers/panoramax/policy.test.mjs b/src/layers/streetLevel/providers/panoramax/policy.test.mjs index 770f52a26..1bb1ea578 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/policy.test.mjs @@ -224,6 +224,23 @@ test('hostile item data: a malformed link escape, null coordinates, a huge produ 'Carto@Cité', 'an @ without a domain is a name', ); + // The address need not follow the first '@'. + assert.equal(named('@team someone@example.org'), null); + assert.equal(named('Carto@Cité (mapper@example.org)'), null); + assert.equal(named(`${'@'.repeat(100_000)}x`), `${'@'.repeat(80)}`); +}); + +test('thousands of producers are credited in linear time', () => { + const item = stacItem({ id: 'a', lon: 1, lat: 2 }); + item.providers = Array.from({ length: 50_000 }, (_, i) => ({ + name: `producer-${i}`, + roles: ['producer'], + })); + const started = performance.now(); + const { creator } = normalizeItem(item); + assert.ok(performance.now() - started < 1000); + assert.ok(creator.startsWith('tester, producer-0, producer-1')); + assert.ok(creator.length <= 120); }); test('the instance holding a picture is named (its via host), never as its author', () => { diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 031f376d7..4bee380ae 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -726,12 +726,16 @@ test('from high up the density grid draws points, not counted as sequences, and test('switching off clears the cones and forgets a pending line click', async (t) => { const items = sequence('s', 3); const env = await mounted({ items, tiles: pictureTiles(items) }); - await withCones(t, env); + const cones = await withCones(t, env); + await env.instance.viewer.open('s-0'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => cones.count() === 3), 'cones to clear'); env.instance.handlePick(`${PICK_PREFIX.sequence}s`, { groundPoint: () => east(0), }); env.instance.deactivate(); assert.equal(env.instance.sequenceStats().selectedId, null); + assert.equal(cones.count(), 0, 'the cones are gone'); // The click's preference is gone: a later lookup asks any sequence. await env.instance.nearestImage(east(0)); assert.equal(env.source.calls.nearest.at(-1).collections, null); @@ -1001,15 +1005,242 @@ test('a re-centre while the cones still load does not inherit the aborted load const second = new Set( pictureTilesAround({ lon, lat }).map((tile) => `${tile.x}/${tile.y}`), ); - const ids = new Set(cones.ids()); const covered = items.filter((item) => { const tile = tileOf(item); return second.has(`${tile.x}/${tile.y}`); }); assert.ok(covered.length > 0); - assert.equal( - covered.filter((item) => ids.has(item.id)).length, - covered.length, - 'every picture in the re-centred tiles has its cone (the shared ones too)', + // The neighbours join as they arrive: wait for them, then every picture + // in the re-centred tiles has its cone (the shared ones too). + const coneCount = () => { + const ids = new Set(cones.ids()); + return covered.filter((item) => ids.has(item.id)).length; + }; + assert.ok( + await until(() => coneCount() === covered.length), + `${coneCount()} of ${covered.length}`, ); }); + +test('after a GPU reset the viewer draws the same picture itself, leaving the core’s lookups alone; a failure is reported', async () => { + const items = sequence('p', 2, { pano: true }); + let failLoads = false; + const view = { yaw: 0, pitch: 0, fov: 90 }; + const definition = createPanoramaxProvider({ + source: fakeSource({ items }), + viewerOptions: { + load: async (url) => { + if (failLoads) + throw new Error('The Panoramax photo could not be loaded'); + return { src: url, width: 2048, height: 1024 }; + }, + createRenderer: () => ({ + setImage() {}, + setView: (next) => Object.assign(view, next), + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }), + }, + }); + const { context, actions } = fakeContext(); + const instance = definition.create(context); + actions.instance = instance; + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + await instance.viewer.open('p-0'); + const canvas = () => host.children[0].children[1]; + const kind = () => host.children[0].dataset.kind; + assert.equal(kind(), 'pano'); + canvas().fire('webglcontextlost'); + assert.ok(await until(() => kind() === 'pano'), 'drawn again'); + assert.deepEqual(poses, ['p-0', 'p-0']); + assert.deepEqual(actions.opened, [], 'not through the core'); + failLoads = true; + canvas().fire('webglcontextlost'); + assert.ok(await until(() => actions.errors.some(Boolean))); + assert.match(actions.errors.filter(Boolean)[0], /could not be loaded/); +}); + +/** A source whose lookup of `heldId` waits for `release()` and ignores aborts. */ +function holdLookup(source, heldId) { + const held = { release: null, signals: [] }; + const getItem = source.getItem; + source.getItem = async (id, options = {}) => { + held.signals.push({ id, signal: options.signal }); + if (id === heldId) await new Promise((go) => (held.release = go)); + return getItem(id); + }; + return held; +} + +test('a lookup that comes back after the user moved on does not move the cones', async (t) => { + const items = sequence('long', 400); + const env = await mounted({ items, tiles: pictureTiles(items) }); + await withCones(t, env); + const held = holdLookup(env.source, 'long-0'); + const late = env.instance.viewer.open('long-0'); + assert.ok(await until(() => held.release !== null)); + await env.instance.viewer.open('long-399'); + await late; + // The first lookup answers anyway (a source that ignores the abort). + held.release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await env.instance.selectSequence('long'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + const [lon, lat] = items[399].geometry.coordinates; + const [far] = pictureTilesAround({ lon, lat }); + const [lon0, lat0] = items[0].geometry.coordinates; + const [near] = pictureTilesAround({ lon: lon0, lat: lat0 }); + assert.equal(env.source.calls.tiles[0], `15/${far.x}/${far.y}`); + assert.ok( + !env.source.calls.tiles.includes(`15/${near.x}/${near.y}`), + 'nothing around the picture the user left', + ); +}); + +test('a lookup nobody waits for any more is stopped; one still wanted is not', async () => { + const items = sequence('s', 3); + const env = await mounted({ items }); + const held = holdLookup(env.source, 's-0'); + const first = env.instance.viewer.open('s-0'); + assert.ok(await until(() => held.release !== null)); + const [{ signal }] = held.signals; + assert.equal(signal.aborted, false); + await env.instance.viewer.open('s-1'); + await first; + await Promise.resolve(); + assert.equal(signal.aborted, true, 'the user moved on: its search stops'); + // The same picture asked again at once shares the lookup, which goes on. + held.release = null; + held.signals.length = 0; + const again = env.instance.viewer.open('s-2'); + const twice = env.instance.viewer.open('s-2'); + await Promise.all([again, twice]); + assert.equal(held.signals.length, 1); + assert.equal(held.signals[0].signal.aborted, false); + assert.equal(env.poses.at(-1), 's-2'); +}); + +/** + * Two pictures of sequence `e` either side of a z15 tile's west edge, + * half-way down it: the cones around the first read its tile and the + * west neighbour. + */ +function acrossWestEdge() { + const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15); + const box = tileBounds(home.tile.x, home.tile.y, 15); + const lat = (box.north + box.south) / 2; + const items = [ + stacItem({ + id: 'e-0', + sequenceId: 'e', + lon: box.west + 0.00005, + lat, + nextId: 'e-1', + }), + stacItem({ + id: 'e-1', + sequenceId: 'e', + lon: box.west - 0.00005, + lat, + prevId: 'e-0', + }), + ]; + const west = `15/${home.tile.x - 1}/${home.tile.y}`; + return { items, west }; +} + +test( + 'the open picture’s own tile draws its cones at once; a neighbour’s join as it arrives', + { timeout: 10_000 }, + async (t) => { + const { items, west } = acrossWestEdge(); + const tiles = pictureTiles(items); + let release = null; + const env = await mounted({ + items, + tiles: async (tile) => { + if (`${tile.z}/${tile.x}/${tile.y}` === west) + await new Promise((go) => (release = go)); + return tiles(tile); + }, + }); + const cones = await withCones(t, env); + await env.instance.viewer.open('e-0'); + const [lon, lat] = items[0].geometry.coordinates; + assert.equal( + pictureTilesAround({ lon, lat }).length, + 2, + 'the west neighbour is read too', + ); + // Not awaited: a load that waited for the neighbour would hang here. + env.instance.selectSequence('e'); + assert.ok(await until(() => release !== null), 'the neighbour is held'); + assert.ok( + await until(() => cones.count() === 1), + 'the home tile’s cone shows', + ); + assert.deepEqual(cones.ids(), ['e-0']); + release(); + assert.ok(await until(() => cones.count() === 2), 'the neighbour’s joins'); + }, +); + +test('a neighbour tile that failed is not counted as shown: › into it re-centres the cones there', async (t) => { + const { items, west } = acrossWestEdge(); + const tiles = pictureTiles(items); + let failing = true; + const env = await mounted({ + items, + tiles: (tile) => { + if (failing && `${tile.z}/${tile.x}/${tile.y}` === west) + throw Object.assign(new Error('Panoramax is busy'), { status: 503 }); + return tiles(tile); + }, + }); + const cones = await withCones(t, env); + await env.instance.viewer.open('e-0'); + await env.instance.selectSequence('e'); + assert.ok(await until(() => cones.count() === 1)); + // Let the neighbour's failure land. + assert.ok(await until(() => env.source.calls.tiles.includes(west))); + await new Promise((resolve) => setTimeout(resolve, 10)); + failing = false; + const asked = env.source.calls.tiles.filter((key) => key === west).length; + env.next.fire('click'); + assert.ok(await until(() => env.poses.at(-1) === 'e-1')); + assert.ok( + await until( + () => env.source.calls.tiles.filter((key) => key === west).length > asked, + ), + 'the failed tile is asked again', + ); + assert.ok(await until(() => cones.count() === 2), 'e-1 has its cone'); +}); + +test('a busy or timed-out tile keeps its own message; only a body that is not a tile reads as unreadable', async (t) => { + const items = sequence('s', 3); + const failures = [ + Object.assign(new Error('Panoramax is busy'), { status: 429 }), + Object.assign(new Error('Panoramax did not answer in time — try again'), { + status: 0, + }), + ]; + for (const failure of failures) { + const env = await mounted({ + items, + tiles: () => { + throw failure; + }, + }); + await withCones(t, env); + await env.instance.viewer.open('s-0'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.actions.errors.some(Boolean))); + assert.equal(env.actions.errors.filter(Boolean).at(-1), failure.message); + } +}); diff --git a/src/layers/streetLevel/providers/panoramax/source.js b/src/layers/streetLevel/providers/panoramax/source.js index 1217b19bf..9678233ad 100644 --- a/src/layers/streetLevel/providers/panoramax/source.js +++ b/src/layers/streetLevel/providers/panoramax/source.js @@ -1,10 +1,8 @@ import { createLimiter } from '../../limiter.js'; - -/** Longest wait a Retry-After is honoured for. */ -const RETRY_AFTER_MAX_SEC = 300; import { API_CONCURRENCY, API_TIMEOUT_MS, + BUSY_BACKOFF_BASE_SEC, MAX_RESPONSE_BYTES, NEAREST_BOX_LIMIT, NEAREST_FOV_TOLERANCE_DEG, @@ -12,10 +10,14 @@ import { NEAREST_RADIUS_M, PANORAMAX_API, PICTURE_TILE_CONCURRENCY, + STREAM_IDLE_TIMEOUT_MS, TILE_CONCURRENCY, TILE_TIMEOUT_MS, } from './policy.js'; +/** Longest wait a Retry-After (or the backoff) asks for. */ +const RETRY_AFTER_MAX_SEC = 300; + /** An HTTP or API failure; `retryAfterSec` is set while Panoramax is busy. */ export class PanoramaxSourceError extends Error { constructor(message, { status = 0, retryAfterSec = null } = {}) { @@ -29,17 +31,30 @@ export class PanoramaxSourceError extends Error { const NO_ANSWER = 'Panoramax did not answer in time — try again'; const UNREACHABLE = 'Panoramax could not be reached — check the connection'; -/** Seconds from a Retry-After header, or null. */ -function retryAfterSeconds(response) { +/** + * Seconds from a Retry-After header (delay seconds or an HTTP date), or + * null. The meta-catalogue exposes no headers to the browser, so in the + * app this is usually null and the caller backs off on its own. + */ +export function retryAfterSeconds(response, now = Date.now()) { const header = response.headers?.get?.('retry-after'); - const value = header == null || header === '' ? NaN : Number(header); - if (!Number.isFinite(value) || value < 0) return null; + if (header == null || header === '') return null; + let value = Number(header); + if (!Number.isFinite(value)) { + const at = Date.parse(header); + if (!Number.isFinite(at)) return null; + value = Math.ceil((at - now) / 1000); + } + if (value < 0) return null; // A maintenance page's hour must not freeze coverage for an hour. return Math.min(RETRY_AFTER_MAX_SEC, Math.max(1, value)); } -/** The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the headers say. */ -async function readBounded(response) { +/** + * The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the + * headers say. `onChunk` hears each piece arrive (the idle timeout). + */ +async function readBounded(response, onChunk = () => {}) { const tooMuch = () => new PanoramaxSourceError('Panoramax sent too much data', { status: response.status, @@ -57,6 +72,7 @@ async function readBounded(response) { for (;;) { const { done, value } = await reader.read(); if (done) break; + onChunk(); total += value.byteLength; if (total > MAX_RESPONSE_BYTES) { reader.cancel().catch(() => {}); @@ -92,30 +108,55 @@ export function createPanoramaxSource({ lifo: true, }); - /** GET within `timeoutMs`; the caller's abort wins over the timeout. */ + /** Busy answers in a row with no readable Retry-After (the backoff grows). */ + let busyStreak = 0; + + /** + * GET: its headers within `timeoutMs`, then a body that may take as long + * as it keeps arriving (silent for STREAM_IDLE_TIMEOUT_MS is given up). + * The caller's abort wins over both. + */ async function get(url, { signal, timeoutMs, empty = [] }) { 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 PanoramaxSourceError(NO_ANSWER)), - timeoutMs, - ); + const giveUp = () => controller.abort(new PanoramaxSourceError(NO_ANSWER)); + let timer = setTimeout(giveUp, timeoutMs); try { const response = await fetchImpl(url, { signal: controller.signal }); - if (empty.includes(response.status)) return null; - if (response.status === 429 || response.status === 503) + if (empty.includes(response.status)) { + busyStreak = 0; + return null; + } + if (response.status === 429 || response.status === 503) { + const told = retryAfterSeconds(response); + // Not told how long: back off, longer each time (5, 10, 20… 300 s). + const wait = + told ?? + Math.min( + RETRY_AFTER_MAX_SEC, + BUSY_BACKOFF_BASE_SEC * 2 ** Math.min(busyStreak, 8), + ); + busyStreak++; throw new PanoramaxSourceError('Panoramax is busy', { status: response.status, - retryAfterSec: retryAfterSeconds(response) ?? 5, + retryAfterSec: wait, }); + } if (!response.ok) throw new PanoramaxSourceError( `Panoramax answered HTTP ${response.status}`, { status: response.status }, ); - return await readBounded(response); + busyStreak = 0; + // The answer has begun: from here only silence is timed. + clearTimeout(timer); + timer = setTimeout(giveUp, STREAM_IDLE_TIMEOUT_MS); + return await readBounded(response, () => { + clearTimeout(timer); + timer = setTimeout(giveUp, STREAM_IDLE_TIMEOUT_MS); + }); } catch (error) { if (signal?.aborted) throw signal.reason; if (controller.signal.aborted) throw controller.signal.reason; @@ -180,8 +221,8 @@ export function createPanoramaxSource({ * Pictures within `radius` metres of a point, unranked: the caller ranks * them by distance. Two searches side by side, since neither finds them * all: `place_position` (nearest first) leaves out pictures looking - * away from the point, whatever the tolerance; a bounding box has them - * but in no order, so in a dense spot its page may miss the nearest. + * away from the point, whatever the tolerance; a bounding box has them, + * roughly nearest first (not exactly: the caller ranks the union). * `collections` keeps one sequence's, `pano` and `sinceMs` filter as * the imagery filter does. */ diff --git a/src/layers/streetLevel/providers/panoramax/source.test.mjs b/src/layers/streetLevel/providers/panoramax/source.test.mjs index 63eb6ff9e..db2b1160d 100644 --- a/src/layers/streetLevel/providers/panoramax/source.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/source.test.mjs @@ -1,7 +1,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { PanoramaxSourceError, createPanoramaxSource } from './source.js'; -import { MAX_RESPONSE_BYTES } from './policy.js'; +import { + PanoramaxSourceError, + createPanoramaxSource, + retryAfterSeconds, +} from './source.js'; +import { + MAX_RESPONSE_BYTES, + NEAREST_BOX_LIMIT, + STREAM_IDLE_TIMEOUT_MS, + TILE_TIMEOUT_MS, +} from './policy.js'; /** A fetch answering from `respond(url)`, recording every URL asked. */ function fakeFetch(respond) { @@ -348,6 +357,148 @@ test('a Retry-After is honoured up to five minutes', async () => { assert.equal(await wait('12'), 12); }); +test('a Retry-After may be an HTTP date', () => { + const now = Date.parse('2026-10-15T12:00:00Z'); + const response = (value) => + new Response(null, { status: 429, headers: { 'retry-after': value } }); + assert.equal( + retryAfterSeconds(response('Thu, 15 Oct 2026 12:01:00 GMT'), now), + 60, + ); + assert.equal( + retryAfterSeconds(response('Thu, 15 Oct 2026 14:00:00 GMT'), now), + 300, + ); + assert.equal( + retryAfterSeconds(response('Thu, 15 Oct 2026 11:00:00 GMT'), now), + null, + 'a date already past', + ); + assert.equal(retryAfterSeconds(response('soon'), now), null); + assert.equal( + retryAfterSeconds(new Response(null, { status: 429 }), now), + null, + ); +}); + +test('busy without a readable Retry-After (the browser hides it): the wait doubles, and resets once answered', async () => { + let busy = true; + const source = createPanoramaxSource({ + fetchImpl: fakeFetch(() => + busy ? { status: 429 } : { body: new Uint8Array([1]) }, + ).fetchImpl, + }); + const wait = () => + source.getTile({ x: 0, y: 0, z: 0 }).then( + () => 'ok', + (error) => error.retryAfterSec, + ); + const waits = []; + for (let i = 0; i < 9; i++) waits.push(await wait()); + assert.deepEqual(waits, [5, 10, 20, 40, 80, 160, 300, 300, 300]); + busy = false; + assert.equal(await wait(), 'ok'); + busy = true; + assert.equal(await wait(), 5, 'back to the first wait'); +}); + +test( + 'a body that keeps arriving is not cut off by the request timeout; one that stalls is', + { timeout: 10_000 }, + async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const settle = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); + }; + // Headers at once, then a chunk every 10 s for twice the tile timeout. + const chunks = Math.ceil((2 * TILE_TIMEOUT_MS) / 10_000); + let push; + const slowBody = (signal) => + new Response( + new ReadableStream({ + start(controller) { + push = (last) => { + controller.enqueue(new Uint8Array([7])); + if (last) controller.close(); + }; + signal.addEventListener('abort', () => + controller.error(signal.reason), + ); + }, + }), + { status: 200 }, + ); + const source = createPanoramaxSource({ + fetchImpl: async (url, { signal }) => slowBody(signal), + }); + const slow = source.getTile({ x: 0, y: 0, z: 0 }); + await settle(); + for (let i = 1; i <= chunks; i++) { + t.mock.timers.tick(10_000); + push(i === chunks); + await settle(); + } + assert.equal((await slow).length, chunks, 'every chunk read'); + const stalled = source.getTile({ x: 0, y: 0, z: 0 }); + await settle(); + push(false); + await settle(); + t.mock.timers.tick(STREAM_IDLE_TIMEOUT_MS); + await assert.rejects(stalled, /did not answer in time/); + }, +); + +test('the box search asks for a few pictures: the API returns them nearest first', async () => { + const { urls, fetchImpl } = fakeFetch(() => json({ features: [] })); + const source = createPanoramaxSource({ fetchImpl }); + await source.nearestItems({ lon: 2.35, lat: 48.85 }); + const box = urls + .map((url) => new URL(url)) + .find((url) => url.searchParams.has('bbox')); + assert.ok(box, 'a box search'); + assert.equal(box.searchParams.get('limit'), String(NEAREST_BOX_LIMIT)); + assert.ok(NEAREST_BOX_LIMIT <= 20); +}); + +test('the cones’ tiles have their own connection: never behind coverage tiles, the newest first', async () => { + const { PICTURE_TILE_CONCURRENCY, TILE_CONCURRENCY } = + await import('./policy.js'); + assert.equal(PICTURE_TILE_CONCURRENCY, 1); + const held = new Map(); + const asked = []; + const source = createPanoramaxSource({ + fetchImpl: (url) => { + asked.push(url.replace(/^.*\/map\//, '')); + return new Promise((resolve) => + held.set(url.replace(/^.*\/map\//, ''), () => + resolve(new Response(null, { status: 204 })), + ), + ); + }, + }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + // Coverage fills its lane and queues. + const coverage = Array.from({ length: TILE_CONCURRENCY + 2 }, (_, i) => + source.getTile({ x: i, y: 0, z: 10 }), + ); + await settle(); + assert.equal(asked.length, TILE_CONCURRENCY); + // A cone tile goes at once; two more wait, the newer first. + const pictures = [1, 2, 3].map((x) => + source.getTile({ x, y: 9, z: 15 }, { lane: 'pictures' }), + ); + await settle(); + assert.deepEqual(asked.slice(TILE_CONCURRENCY), ['15/1/9.mvt']); + held.get('15/1/9.mvt')(); + await settle(); + assert.deepEqual(asked.slice(TILE_CONCURRENCY), ['15/1/9.mvt', '15/3/9.mvt']); + for (let round = 0; round < 6; round++) { + for (const go of held.values()) go(); + await settle(); + } + await Promise.all([...coverage, ...pictures]); +}); + test('a lookup near the antimeridian keeps its box on the map', async () => { const { urls, fetchImpl } = fakeFetch(() => json({ features: [] })); const source = createPanoramaxSource({ fetchImpl }); diff --git a/src/layers/streetLevel/providers/panoramax/viewer.js b/src/layers/streetLevel/providers/panoramax/viewer.js index 3e26e4d56..902e95156 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.js +++ b/src/layers/streetLevel/providers/panoramax/viewer.js @@ -31,7 +31,13 @@ export const PANORAMAX_SOURCES = Object.freeze({ Boolean(photo.isPano && (photo.urls.sd || hdDecodable(photo))), // Without an SD derivative the original goes straight to the texture size. panoFirst: (photo) => { - if (photo.urls.sd) return { url: photo.urls.sd, width: SD_WIDTH }; + // Decoded at SD's own size, whatever the host actually serves. + if (photo.urls.sd) + return { + url: photo.urls.sd, + width: SD_WIDTH, + resize: [SD_WIDTH, SD_WIDTH / 2], + }; if (!hdDecodable(photo)) return null; const width = Math.min(photo.width, PANO_FULL_SIZE[0]); return { url: photo.urls.hd, width, resize: [width, width / 2] }; @@ -51,15 +57,20 @@ export const PANORAMAX_SOURCES = Object.freeze({ timeoutMs: HD_TIMEOUT_MS, }; }, - // A 360° picture shown flat (no WebGL) is its whole equirectangular image; - // an original too big to decode is skipped for the thumbnail. - flatFirst: (photo) => [ + // A 360° picture shown flat (no WebGL) is its whole equirectangular image. + // When SD fails the thumbnail stands in; the original (megabytes) only + // for the expanded viewer, and only one of a size it may decode. + flatFirst: (photo, { expanded = false } = {}) => [ photo.urls.sd, - !photo.isPano || hdDecodable(photo) ? photo.urls.hd : null, + expanded && hdDecodable(photo) ? photo.urls.hd : null, photo.urls.thumb, ], + // The original once the viewer is wider than SD, if its width is known + // and decodable (a 200 MP phone photo is 16,000 px across). flatSharp: (photo, pixels) => - photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null, + photo.urls.sd && hdDecodable(photo) && pixels > SD_WIDTH + ? photo.urls.hd + : null, describe: (photo) => ({ // Where no author can be named, the instance holding the photo is. title: !photo.creator && photo.via ? `via ${photo.via}` : null, diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs index 614a50e13..3f8ae4789 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -140,12 +140,14 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09 assert.equal(canvas.hidden, false); assert.equal(picture.hidden, true); assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit'); - assert.deepEqual(loads[0], { + const { signal, ...first } = loads[0]; + assert.deepEqual(first, { url: PHOTOS.pano.urls.sd, crossOrigin: true, - resize: null, + resize: [2048, 1024], failure: 'The Panoramax photo could not be loaded', }); + assert.equal(signal.aborted, false, 'the open can stop it'); assert.equal(poses.at(-1).isPano, true); assert.equal(poses.at(-1).bearing, 100); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -258,7 +260,7 @@ test('without WebGL a 360° picture shows its whole equirectangular image flat', assert.equal(picture.src, PHOTOS.pano.urls.sd); }); -test('a picture whose SD fails falls back to HD, then the thumbnail', async () => { +test('a picture whose SD fails falls back to the thumbnail; the original only for the expanded viewer', async () => { const tried = []; const viewer = createPanoramaxViewer({ getPhoto: async () => PHOTOS.flat, @@ -273,12 +275,18 @@ test('a picture whose SD fails falls back to HD, then the thumbnail', async () = const host = fakeHost(); await viewer.mount(host); await viewer.open('flat'); + assert.deepEqual(tried, [PHOTOS.flat.urls.sd, PHOTOS.flat.urls.thumb]); + assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb); + // Expanded, the original is worth its megabytes. + tried.length = 0; + parts(host).root.closest = (selector) => + selector.includes('sl-viewer-wrap-expanded') ? {} : null; + await viewer.open('flat'); assert.deepEqual(tried, [ PHOTOS.flat.urls.sd, PHOTOS.flat.urls.hd, PHOTOS.flat.urls.thumb, ]); - assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb); }); test('a photo with no author to name is captioned with the instance holding it', () => { From c42b072e99e3a27dc6613ad1c52fa4931c00ba5a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 10:48:38 -0700 Subject: [PATCH 15/17] fix(overpass): tag caches by the sorted upstream set and cover installations Uses a full SHA-256 of the sorted instance list, so reordering the same instances for failover keeps the cache. Adds the installation-cache repoint test (tagged, reordered, repointed, untagged, unconfigured) and the changelog and current-state notes. --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 4 ++++ server/providers/overpass/constants.js | 8 ++++---- src/data/installationProxy.test.mjs | 28 ++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95eff69e9..a56c13575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Overpass and military-installation disk caches no longer serve answers from a + previously configured `OVERPASS_UPSTREAMS` instance after it is repointed. + - A Radio Browser mirror answering 204, 205 or 304, or a status outside 200–599, no longer crashes the local dev/preview server. Null-body statuses get an empty body; anything else that cannot become a response fails that diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b9e3e845..60f7bfbbf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4132,6 +4132,10 @@ for, resolved lazily after env loading. It replaces the empty default list; there is no built-in fallback. Local/private instances are allowed. The stable `gods-eye-view/…` User-Agent is unchanged. URLs never appear in response headers or upstream errors, including old cached endpoint metadata. +Disk cache entries for both routes carry a SHA-256 tag of the configured +instance set (order does not matter) and are served only while it matches, so +repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's +answers; untagged entries stop serving once an instance is configured. The shared transport guards `/api/overpass` and `/api/military-installations`. With no configured upstream, each route serves existing cache data (including diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index 2a0d4a3ef..6cfd7257a 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -40,10 +40,10 @@ let upstreamMemo = { raw: null, endpoints: [], tag: 'none' }; */ function upstreamTag(endpoints) { if (!endpoints.length) return 'none'; - return createHash('sha1') - .update(endpoints.join('\n')) - .digest('hex') - .slice(0, 12); + // Sorted, so reordering the same instances for failover keeps the cache. + return createHash('sha256') + .update([...endpoints].sort().join('\n')) + .digest('hex'); } /** Resolve after environment loading. Public Overpass instances are not used by default. */ diff --git a/src/data/installationProxy.test.mjs b/src/data/installationProxy.test.mjs index 08b14de99..308ca9dba 100644 --- a/src/data/installationProxy.test.mjs +++ b/src/data/installationProxy.test.mjs @@ -26,6 +26,7 @@ import { validMilitaryInstallationBox, writeMilitaryInstallationDisk, } from '../../vite.config.js'; +import { resolveOverpassUpstreamTag } from '../../server/providers/overpass/constants.js'; const DAY_MS = 86_400_000; test('installation failure reasons disclose no raw upstream error and do not guess overload', () => { @@ -274,3 +275,30 @@ test('an unwritable cache directory degrades to a miss instead of throwing', asy await fsp.rm(dir, { recursive: true, force: true }); } }); + +test('an installation entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => { + const prior = process.env.OVERPASS_UPSTREAMS; + t.after(() => { + if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS; + else process.env.OVERPASS_UPSTREAMS = prior; + }); + const dir = tempCacheDir(); + const key = '30.250,-97.750,30.300,-97.700'; + try { + process.env.OVERPASS_UPSTREAMS = 'https://a.example/api/interpreter,https://b.example/api/interpreter'; + const tag = resolveOverpassUpstreamTag(); + await writeMilitaryInstallationDisk(key, { ...entry(Date.now()), upstreams: tag }, dir); + assert.ok(await readMilitaryInstallationDisk(key, TTL_MS, dir), 'its own entry is a hit'); + process.env.OVERPASS_UPSTREAMS = 'https://b.example/api/interpreter,https://a.example/api/interpreter'; + assert.equal(resolveOverpassUpstreamTag(), tag, 'reordering the same instances keeps the tag'); + process.env.OVERPASS_UPSTREAMS = 'https://c.example/api/interpreter'; + assert.equal(await readMilitaryInstallationDisk(key, TTL_MS, dir), null, 'fresh read refused'); + assert.equal(await readMilitaryInstallationDisk(key, Infinity, dir), null, 'stale read refused'); + await writeMilitaryInstallationDisk('legacy', entry(Date.now()), dir); + assert.equal(await readMilitaryInstallationDisk('legacy', Infinity, dir), null, 'untagged entry refused'); + delete process.env.OVERPASS_UPSTREAMS; + assert.ok(await readMilitaryInstallationDisk(key, Infinity, dir), 'nothing configured accepts any entry'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 86b7dc8d3c922c562751f82245e6f7f999496250 Mon Sep 17 00:00:00 2001 From: NotProniss <71785359+NotProniss@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:12:27 +0000 Subject: [PATCH 16/17] feat(weather): add satellite cloud filter strength slider Blend the existing infrared brightness key from Full (0%) to Clouds only (100%), preserving Soft/Vivid as independent opacity presets. Commit filter changes on slider release and retain legacy share-link mode codes. --- CHANGELOG.md | 6 ++ docs/CURRENT-STATE.md | 12 ++- src/data/layerState.js | 23 +++++- src/data/layerState.test.mjs | 64 +++++++++++++-- src/layers/weather/index.js | 45 ++++++++--- src/layers/weather/infraredAlpha.js | 17 +++- src/layers/weather/infraredAlpha.test.mjs | 15 ++++ src/layers/weather/infraredImage.test.mjs | 9 +++ src/layers/weather/weather.test.mjs | 70 +++++++++++++++-- src/ui/railCardBlocks.js | 94 ++++++++++++++++++++++- src/ui/railCards.test.mjs | 75 ++++++++++++++++++ src/ui/styles/weather.css | 29 +++++++ 12 files changed, 421 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dee202852..56f5c65a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,12 @@ ## [Unreleased] +- Satellite clouds keep Clouds only / Full as quick endpoints and add a continuous + cloud-filter slider: 0% is Full, 100% is the existing brightness key, and + intermediate values blend the keyed alpha. The slider commits on release to + avoid restaging imagery for every drag event. Filter percentages round-trip in + share links; legacy mode codes retain their old endpoint meanings. + - Flights and Military Flights highlight emergency transponder broadcasts in fleet billboards, 3D models and Cockpit contacts. Colors update when a status starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 60f7bfbbf..629d1416f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -256,8 +256,13 @@ Clouds only applies a soft brightness ramp to decoded pixels once, using Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40 to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are preserved in Full image mode; both modes use the chosen layer opacity. This -is a display filter, not a cloud mask. Satellite share links retain the display -mode; observation history remains transient and links open latest. +is a display filter, not a cloud mask. The cloud-filter slider is a continuous +0–100% strength: 0% is Full, 100% is the existing Clouds only key, and +intermediate values blend their alpha. The value commits on slider release to +avoid restaging for every pointer event. Satellite share links carry the filter +strength; legacy `f`/`a` values keep their Clouds only / Full meanings. Soft and +Vivid remain layer-opacity presets (40%/70%); history remains transient and links +open latest. Global infrared fetches one capped 4 MiB, 2048×1024 mosaic per frame and decodes and processes it before staging. Globe hosts crop that canvas into 256 px tiles to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent, @@ -302,7 +307,8 @@ release commits immediately. Latest selects the newest frame per product, and history labels the actual displayed frame as synced or nearest. Missing-frame messages use each product's eligibility gap (30 minutes, or three hours for the global mosaic). Wind shows forecast valid and issue times and does not follow -history. Satellite clouds uses Clouds only / Full image configuration labels. +history. Satellite clouds uses Clouds only / Full controls, a continuous cloud +filter slider and Soft/Vivid opacity presets in its right-rail settings. Left weather rows retain only the toggle and one status line with a source tooltip. Configuration and the cyclone Storms list live in the cards, as do the diff --git a/src/data/layerState.js b/src/data/layerState.js index 1db10494b..d7175fd67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -296,6 +296,24 @@ function imageryPinOption(key, token) { }); } +function satelliteFilterOption() { + const percentage = boundedIntegerOption('infrared', 'i', 100, { + min: 0, + max: 100, + }); + return Object.freeze({ + ...percentage, + normalize: (value) => + value === 'filtered' + ? 100 + : value === 'full' + ? 0 + : percentage.normalize(value), + decode: (value) => + value === 'f' ? 100 : value === 'a' ? 0 : percentage.decode(value), + }); +} + const OPTION_GROUPS = Object.freeze({ traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { @@ -317,10 +335,7 @@ const OPTION_GROUPS = Object.freeze({ }), ]), 'weather-satellite': Object.freeze([ - enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], { - filtered: 'f', - full: 'a', - }), + satelliteFilterOption(), enumOption('opacity', 'o', 'strong', ['light', 'strong'], { light: 'l', strong: 's', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 30f251b7b..631e5dfec 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2422,23 +2422,74 @@ test('wind appearance shares round trip while old links retain weather defaults' assert.deepEqual(invalid.options.wind, defaults); }); -test('observed weather round trips product and opacity without persisting historical playback', () => { +test('observed weather round trips product and presets without persisting historical playback', () => { const state = normalizeLayerState({ enabledLayerIds: ['weather-radar', 'weather-satellite'], options: { 'weather-radar': { opacity: 'light', play: true }, - 'weather-satellite': { product: 'clouds', opacity: 'light', step: -1 }, + 'weather-satellite': { + product: 'clouds', + opacity: 'light', + infrared: 'filtered', + step: -1, + }, }, }); const params = new URLSearchParams(encode(state)); const decoded = decodeLayerStateParams(params); assert.deepEqual(decoded, state); assert.equal(state.options['weather-satellite'].product, 'clouds'); + assert.equal(state.options['weather-satellite'].opacity, 'light'); + assert.equal(state.options['weather-satellite'].infrared, 100); assert.equal(Object.hasOwn(state.options['weather-radar'], 'play'), false); }); -test('satellite infrared display mode round trips and invalid or absent values use filtered', () => { - for (const infrared of ['full', 'filtered', undefined, 'invalid']) { +test('satellite filter strength percentages round-trip and legacy mode codes remain readable', () => { + const state = normalizeLayerState({ + enabledLayerIds: ['weather-satellite'], + options: { 'weather-satellite': { infrared: 53, opacity: 'light' } }, + }); + const params = encodeLayerStateParams( + new URLSearchParams([['v', '2']]), + state, + ); + assert.deepEqual(decodeLayerStateParams(params), state); + assert.equal(state.options['weather-satellite'].infrared, 53); + assert.equal(state.options['weather-satellite'].opacity, 'light'); + for (const [legacy, expected] of [ + ['f', 100], + ['a', 0], + ]) { + const decoded = decodeLayerStateParams( + new URLSearchParams(`v=2&l=o&lo=o.i.${legacy}`), + ); + assert.equal(decoded.options['weather-satellite'].infrared, expected); + } + for (const [legacy, expected] of [ + ['filtered', 100], + ['full', 0], + ]) { + const normalized = normalizeLayerState({ + enabledLayerIds: ['weather-satellite'], + options: { 'weather-satellite': { infrared: legacy } }, + }); + assert.equal(normalized.options['weather-satellite'].infrared, expected); + } + const invalid = normalizeLayerState({ + enabledLayerIds: ['weather-satellite'], + options: { 'weather-satellite': { infrared: 101 } }, + }); + assert.equal(invalid.options['weather-satellite'].infrared, 100); +}); + +test('satellite cloud filter round trips and invalid or absent values default to Clouds only', () => { + for (const [infrared, expected] of [ + ['full', 0], + ['filtered', 100], + [53, 53], + [undefined, 100], + ['invalid', 100], + ]) { const state = normalizeLayerState({ enabledLayerIds: ['weather-satellite'], options: { @@ -2454,10 +2505,7 @@ test('satellite infrared display mode round trips and invalid or absent values u decodeLayerStateParams(new URLSearchParams(encode(state))), state, ); - assert.equal( - state.options['weather-satellite'].infrared, - infrared === 'full' ? 'full' : 'filtered', - ); + assert.equal(state.options['weather-satellite'].infrared, expected); assert.equal( Object.hasOwn(state.options['weather-satellite'], 'step'), false, diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 8d6d331eb..beada1ef6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -50,7 +50,7 @@ export function createWeatherLayer({ const satellite = !radar && !lightning; let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional'; let opacity = 'strong'; - let infrared = 'filtered'; + let infrared = 100; let viewer = null, rendering = null, manifest = null, @@ -382,11 +382,20 @@ export function createWeatherLayer({ } }, setParams(params = {}) { + const nextInfrared = satellite + ? params.infrared === 'full' + ? 0 + : params.infrared === 'filtered' + ? 100 + : params.infrared + : null; const infraredChanged = satellite && - ['filtered', 'full'].includes(params.infrared) && - params.infrared !== infrared; - if (infraredChanged) infrared = params.infrared; + Number.isInteger(nextInfrared) && + nextInfrared >= 0 && + nextInfrared <= 100 && + nextInfrared !== infrared; + if (infraredChanged) infrared = nextInfrared; if (['light', 'strong'].includes(params.opacity)) { opacity = params.opacity; rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8); @@ -594,17 +603,17 @@ export function createWeatherLayer({ { id: 'filtered', label: 'Clouds only', - active: infrared === 'filtered', - params: { infrared: 'filtered' }, + active: infrared === 100, + params: { infrared: 100 }, title: - 'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask', + 'Apply the full cloud brightness key; 100% filter strength', }, { id: 'full', label: 'Full', - active: infrared === 'full', - params: { infrared: 'full' }, - title: 'The complete infrared image at the chosen opacity', + active: infrared === 0, + params: { infrared: 0 }, + title: 'Show the complete infrared image; 0% filter strength', }, ] : []), @@ -656,7 +665,21 @@ export function createWeatherLayer({ { id: 'image', label: 'IMAGE', - chips: controls.chips.filter(({ params }) => params.infrared), + chips: controls.chips.filter( + ({ params }) => params.infrared !== undefined, + ), + range: { + id: 'cloud-filter', + param: 'infrared', + min: 0, + max: 100, + step: 1, + value: infrared, + label: 'FILTER', + unit: '%', + ariaLabel: 'Cloud filter strength', + commitOnChange: true, + }, }, ] : []), diff --git a/src/layers/weather/infraredAlpha.js b/src/layers/weather/infraredAlpha.js index 007c7486f..f42bda603 100644 --- a/src/layers/weather/infraredAlpha.js +++ b/src/layers/weather/infraredAlpha.js @@ -1,10 +1,18 @@ export const INFRARED_ALPHA_T0 = 0.4; export const INFRARED_ALPHA_T1 = 0.7; -/** Return display pixels without changing RGB or the source RGBA bytes. */ -export function infraredAlpha(rgba, mode = 'filtered') { +/** Return display pixels without changing RGB or the source RGBA bytes. + * `strength` is 0–100: zero is the full image and 100 is the cloud key. + */ +export function infraredAlpha(rgba, strength = 'filtered') { const pixels = new Uint8ClampedArray(rgba); - if (mode === 'full') return pixels; + const amount = + strength === 'full' + ? 0 + : strength === 'filtered' || !Number.isFinite(strength) + ? 1 + : Math.max(0, Math.min(100, strength)) / 100; + if (amount === 0) return pixels; for (let i = 0; i < pixels.length; i += 4) { // Match Cesium's czm_srgbToLinear conversion in draped imagery. const linearMax = @@ -17,7 +25,8 @@ export function infraredAlpha(rgba, mode = 'filtered') { (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0), ), ); - pixels[i + 3] = rgba[i + 3] * t * t * (3 - 2 * t); + const filtered = t * t * (3 - 2 * t); + pixels[i + 3] = rgba[i + 3] * (1 - amount + filtered * amount); } return pixels; } diff --git a/src/layers/weather/infraredAlpha.test.mjs b/src/layers/weather/infraredAlpha.test.mjs index f0ee95516..950271a9d 100644 --- a/src/layers/weather/infraredAlpha.test.mjs +++ b/src/layers/weather/infraredAlpha.test.mjs @@ -40,6 +40,21 @@ test('filtered infrared preserves RGB and input bytes, scales existing alpha, an ); }); +test('filter strength interpolates between the full infrared image and the cloud key', () => { + const pixels = new Uint8ClampedArray([ + 0, 0, 0, 255, 194, 0, 0, 200, 12, 50, 217, 128, + ]); + assert.deepEqual([...infraredAlpha(pixels, 0)], [...pixels]); + assert.deepEqual( + infraredAlpha(pixels, 100), + infraredAlpha(pixels, 'filtered'), + ); + assert.deepEqual( + [...infraredAlpha(pixels, 50)], + [0, 0, 0, 128, 194, 0, 0, 149, 12, 50, 217, 128], + ); +}); + test('full infrared preserves every channel including partial and zero alpha', () => { const pixels = new Uint8ClampedArray([ 12, 50, 194, 80, 0, 0, 0, 255, 255, 200, 0, 0, diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index f3937abc3..61e532564 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -32,6 +32,15 @@ test('decoded pixels are processed once on a same-sized canvas', () => { assert.equal(output.height, 1); assert.equal(output.image, image); assert.deepEqual([...output.pixels], [194, 0, 0, 98]); + assert.deepEqual( + [...processInfraredImage(image, 50, canvas).pixels], + [194, 0, 0, 149], + 'half-strength filter retains a midpoint haze', + ); + assert.deepEqual( + [...processInfraredImage(image, 0, canvas).pixels], + [194, 0, 0, 200], + ); assert.deepEqual( [...processInfraredImage(image, 'full', canvas).pixels], [194, 0, 0, 200], diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index a80ad6560..1a4ba5177 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -444,11 +444,14 @@ function layerHarness({ let active = null; let clearCount = 0; let hidden = false; + let alpha = null; const rendering = { rehome() { active?.finish(false); }, - setAlpha() {}, + setAlpha(value) { + alpha = value; + }, setHidden(value) { hidden = value; if (value) active?.finish(false); @@ -514,6 +517,7 @@ function layerHarness({ documentRef, moveEnd, clears: () => clearCount, + getAlpha: () => alpha, }; } @@ -1591,8 +1595,8 @@ test('satellite mode chips restage history without changing time or latest-follo await flush(); h.layer.setParams({ infrared: 'full' }); assert.equal(h.stages[2].time, times[1]); - assert.equal(h.stages[2].infrared, 'full'); - assert.equal(h.layer.getParams().infrared, 'full'); + assert.equal(h.stages[2].infrared, 0); + assert.equal(h.layer.getParams().infrared, 0); assert.deepEqual( h.layer .getRowControls() @@ -1605,11 +1609,59 @@ test('satellite mode chips restage history without changing time or latest-follo assert.equal(h.layer.getDiagnostics().followLatest, false); assert.equal(h.layer.getDiagnostics().time, times[1]); h.layer.setParams({ infrared: 'invalid' }); - assert.equal(h.layer.getParams().infrared, 'full'); + assert.equal(h.layer.getParams().infrared, 0); assert.equal(h.stages.length, 3); h.layer.destroy(); }); +test('satellite cloud filter spans Full to Clouds only without changing layer opacity', () => { + const h = layerHarness({ id: 'weather-satellite' }); + assert.equal(h.layer.getParams().infrared, 100); + assert.equal(h.layer.getParams().opacity, 'strong'); + assert.equal(h.getAlpha(), 0.7); + const setting = h.layer + .getRowControls() + .summary.settings.find(({ id }) => id === 'image'); + assert.equal(setting.range.param, 'infrared'); + assert.equal(setting.range.value, 100); + assert.equal(setting.range.min, 0); + assert.equal(setting.range.max, 100); + assert.equal(setting.range.step, 1); + assert.equal(setting.range.commitOnChange, true); + + const full = h.layer.getRowControls().chips.find(({ id }) => id === 'full'); + h.layer.setParams(full.params); + assert.equal(h.layer.getParams().infrared, 0); + assert.equal(h.getAlpha(), 0.7); + assert.equal( + h.layer.getRowControls().chips.find(({ id }) => id === 'full').active, + true, + ); + + h.layer.setParams({ infrared: 53 }); + assert.equal(h.layer.getParams().infrared, 53); + assert.equal(h.getAlpha(), 0.7, 'filter strength is not overall opacity'); + assert.equal( + h.layer.getRowControls().summary.settings.find(({ id }) => id === 'image') + .range.value, + 53, + ); + h.layer.setParams({ opacity: 'light' }); + assert.equal(h.layer.getParams().opacity, 'light'); + assert.equal( + h.getAlpha(), + 0.4, + 'Soft remains the independent opacity preset', + ); + h.layer.setParams({ infrared: 101 }); + assert.equal( + h.layer.getParams().infrared, + 53, + 'invalid filter values are ignored', + ); + h.layer.destroy(); +}); + test('a rapid third selection waits for retirement before installing, and clear cancels that wait', async () => { for (const clear of [false, true]) { const h = renderingHarness(); @@ -1914,8 +1966,14 @@ test('observed descriptors keep configuration only and label satellite clouds by ); assert.equal( controls.chips.find(({ id }) => id === 'filtered').title, - 'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask', + 'Apply the full cloud brightness key; 100% filter strength', ); + const imageSetting = controls.summary.settings.find( + ({ id }) => id === 'image', + ); + assert.equal(imageSetting.range.param, 'infrared'); + assert.equal(imageSetting.range.value, 100); + assert.equal(imageSetting.range.commitOnChange, true); h.layer.setParams({ product: 'clouds' }); assert.equal( h.layer.getRowControls().summary.coverage, @@ -2196,7 +2254,7 @@ test('layer warms the next advertised observation only after a successful playin warmed.map(({ time }) => time), [times[0]], ); - assert.deepEqual(warmed[0].options, { infrared: 'filtered' }); + assert.deepEqual(warmed[0].options, { infrared: 100 }); const next = clock.setTarget(times[0]); await flush(); h.stages.at(-1).finish(); diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index a2132f7ad..b58e1134f 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -30,6 +30,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) { }; const dispose = (block) => { block.removeClick?.(); + block.removeInput?.(); block.children?.destroy(); block.root.remove(); }; @@ -74,6 +75,64 @@ export function createRailCardBlocks({ container, cardId, onParams }) { ); block.chips = make('div', 'rail-card-chips', root); block.chips.setAttribute('role', 'group'); + block.rangeRow = make('div', 'rail-card-setting-range-row', root); + block.rangeLabel = make( + 'span', + 'rail-card-setting-range-label', + block.rangeRow, + ); + block.rangeInput = make( + 'input', + 'rail-card-setting-range', + block.rangeRow, + ); + block.rangeInput.type = 'range'; + block.rangeValue = make( + 'span', + 'rail-card-setting-range-value', + block.rangeRow, + ); + const rangeParams = () => { + const range = block.props.range; + const value = Number(block.rangeInput.value); + if ( + !range?.param || + range.disabled || + !Number.isFinite(value) || + value < Number(range.min) || + value > Number(range.max) + ) + return null; + return { params: { [range.param]: value } }; + }; + const onInput = () => { + const range = block.props.range; + const value = Number(block.rangeInput.value); + const params = rangeParams(); + if (!params) return; + set( + block.rangeValue, + 'textContent', + `${value}${range.unit || ''}`, + ); + attribute( + block.rangeInput, + 'aria-valuetext', + `${value}${range.unit || ''}`, + ); + if (!range.commitOnChange) dispatch(params); + }; + const onChange = () => { + if (!block.props.range?.commitOnChange) return; + const params = rangeParams(); + if (params) dispatch(params); + }; + block.rangeInput.addEventListener('input', onInput); + block.rangeInput.addEventListener('change', onChange); + block.removeInput = () => { + block.rangeInput.removeEventListener('input', onInput); + block.rangeInput.removeEventListener('change', onChange); + }; bind((event) => { const button = event.target?.closest?.('[data-chip-id]'); dispatch( @@ -124,17 +183,48 @@ export function createRailCardBlocks({ container, cardId, onParams }) { blocks.set(props.id, block); } block.props = props; - if (props.type === 'settings') + if (props.type === 'settings') { block.children.update( (props.settings || []).map((setting) => ({ ...setting, type: 'setting', })), ); - else if (props.type === 'setting') { + } else if (props.type === 'setting') { set(block.label, 'textContent', props.label); attribute(block.chips, 'aria-label', props.label); syncChipGroup(block.chips, props.chips); + const range = props.range; + set(block.rangeRow, 'hidden', !range); + if (range) { + const min = Number(range.min); + const max = Number(range.max); + const step = Number(range.step); + const candidate = Number(range.value); + const value = Math.min( + max, + Math.max(min, Number.isFinite(candidate) ? candidate : min), + ); + set(block.rangeLabel, 'textContent', range.label || ''); + set(block.rangeLabel, 'hidden', !range.label); + set(block.rangeInput.dataset, 'rangeId', range.id || props.id); + set(block.rangeInput, 'min', String(min)); + set(block.rangeInput, 'max', String(max)); + set(block.rangeInput, 'step', String(step)); + set(block.rangeInput, 'value', String(value)); + set(block.rangeInput, 'disabled', Boolean(range.disabled)); + attribute( + block.rangeInput, + 'aria-label', + range.ariaLabel || props.label, + ); + attribute( + block.rangeInput, + 'aria-valuetext', + `${value}${range.unit || ''}`, + ); + set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`); + } } else if (props.type === 'list') syncRowList(block.root, props.list); else if (props.type === 'result') { set(block.label, 'textContent', props.label); diff --git a/src/ui/railCards.test.mjs b/src/ui/railCards.test.mjs index e465e3cc7..48fe93964 100644 --- a/src/ui/railCards.test.mjs +++ b/src/ui/railCards.test.mjs @@ -119,6 +119,81 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs button.click(); assert.deepEqual(calls, [2]); }); +test('setting ranges stay below presets and commit expensive filters on release', () => { + const f = railFixture(); + const calls = []; + const view = createRailCards({ + ...f, + onParams: (...args) => calls.push(args), + }); + const model = (value) => ({ + ...card, + blocks: [ + { + id: 'settings', + type: 'settings', + settings: [ + { + id: 'image', + label: 'IMAGE', + chips: [ + { + id: 'clouds-only', + label: 'Clouds only', + params: { infrared: 100 }, + }, + { id: 'full', label: 'Full', params: { infrared: 0 } }, + ], + range: { + id: 'cloud-filter', + param: 'infrared', + min: 0, + max: 100, + step: 1, + value, + label: 'FILTER', + unit: '%', + ariaLabel: 'Cloud filter strength', + commitOnChange: true, + }, + }, + ], + }, + ], + }); + view.update([model(100)]); + const setting = f.find((node) => node.dataset.blockId === 'image'); + assert.equal(setting.children[1].className, 'rail-card-chips'); + assert.equal(setting.children[2].className, 'rail-card-setting-range-row'); + assert.equal(setting.children[2].children[0].textContent, 'FILTER'); + const slider = f.find((node) => node.dataset.rangeId === 'cloud-filter'); + assert.equal(slider.tagName, 'INPUT'); + assert.equal(slider.type, 'range'); + assert.equal(slider.min, '0'); + assert.equal(slider.max, '100'); + assert.equal(slider.step, '1'); + assert.equal(slider.value, '100'); + assert.equal(slider.getAttribute('aria-label'), 'Cloud filter strength'); + slider.focus(); + slider.value = '53'; + slider.dispatchEvent(new Event('input')); + assert.equal(calls.length, 0, 'expensive image work waits until release'); + assert.equal( + f.find((node) => node.className === 'rail-card-setting-range-value') + .textContent, + '53%', + ); + slider.dispatchEvent(new Event('change')); + assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]); + view.update([model(53)]); + assert.equal(slider.value, '53'); + assert.equal(f.document.activeElement, slider); + view.destroy(); + slider.value = '41'; + slider.dispatchEvent(new Event('change')); + assert.equal(calls.length, 1, 'destroy removes the range listener'); +}); + test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => { let writes = 0; const f = railFixture(() => writes++); diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index 3e83561fb..02ee75ea6 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -327,6 +327,35 @@ body.recording-mode .gev-wind-inspection-marker { .rail-card-setting .rail-card-chips { margin-top: 0; } +.rail-card-setting-range-row { + display: flex; + grid-column: 2; + align-items: center; + gap: 7px; + min-width: 0; +} +.rail-card-setting-range-label { + flex: 0 0 6ch; + color: var(--text-dim); + font: 8px var(--font-mono); + white-space: nowrap; +} +.rail-card-setting-range { + width: 100%; + min-width: 0; + accent-color: var(--accent); + cursor: pointer; +} +.rail-card-setting-range:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 3px; +} +.rail-card-setting-range-value { + min-width: 3ch; + color: var(--text-primary); + font: 9px var(--font-mono); + text-align: right; +} .rail-card-result { display: grid; gap: 4px; From 8959b5b71abe5927846c4fb42dac47fad74c0325 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Thu, 15 Oct 2026 19:32:28 +0200 Subject: [PATCH 17/17] fix(weather): preserve filter drag previews across panel refreshes --- CHANGELOG.md | 4 +++- docs/CURRENT-STATE.md | 4 +++- src/ui/railCardBlocks.js | 15 ++++++++++++++- src/ui/railCards.test.mjs | 15 +++++++++++++++ src/ui/styles/weather.css | 3 ++- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f5c65a6..351b768d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,7 +151,9 @@ cloud-filter slider: 0% is Full, 100% is the existing brightness key, and intermediate values blend the keyed alpha. The slider commits on release to avoid restaging imagery for every drag event. Filter percentages round-trip in - share links; legacy mode codes retain their old endpoint meanings. + share links; legacy mode codes retain their old endpoint meanings. Background + panel refreshes preserve the pending drag value until release. Contributed by + [NotProniss](https://github.com/NotProniss) (#1084). - Flights and Military Flights highlight emergency transponder broadcasts in fleet billboards, 3D models and Cockpit contacts. Colors update when a status diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 629d1416f..09b065630 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -259,7 +259,9 @@ preserved in Full image mode; both modes use the chosen layer opacity. This is a display filter, not a cloud mask. The cloud-filter slider is a continuous 0–100% strength: 0% is Full, 100% is the existing Clouds only key, and intermediate values blend their alpha. The value commits on slider release to -avoid restaging for every pointer event. Satellite share links carry the filter +avoid restaging for every pointer event. Status refreshes retain the uncommitted +drag preview; changed model values or disabled controls supersede it. +Satellite share links carry the filter strength; legacy `f`/`a` values keep their Clouds only / Full meanings. Soft and Vivid remain layer-opacity presets (40%/70%); history remains transient and links open latest. diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index b58e1134f..1726860f9 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -110,6 +110,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) { const value = Number(block.rangeInput.value); const params = rangeParams(); if (!params) return; + if (range.commitOnChange) block.rangeDraft = { range, value }; set( block.rangeValue, 'textContent', @@ -125,6 +126,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) { const onChange = () => { if (!block.props.range?.commitOnChange) return; const params = rangeParams(); + block.rangeDraft = null; if (params) dispatch(params); }; block.rangeInput.addEventListener('input', onInput); @@ -196,11 +198,22 @@ export function createRailCardBlocks({ container, cardId, onParams }) { syncChipGroup(block.chips, props.chips); const range = props.range; set(block.rangeRow, 'hidden', !range); + const draft = block.rangeDraft; + if ( + draft && + (!range || + range.disabled || + !range.commitOnChange || + ['id', 'param', 'min', 'max', 'step', 'value'].some( + (key) => range[key] !== draft.range[key], + )) + ) + block.rangeDraft = null; if (range) { const min = Number(range.min); const max = Number(range.max); const step = Number(range.step); - const candidate = Number(range.value); + const candidate = Number(block.rangeDraft?.value ?? range.value); const value = Math.min( max, Math.max(min, Number.isFinite(candidate) ? candidate : min), diff --git a/src/ui/railCards.test.mjs b/src/ui/railCards.test.mjs index 48fe93964..74629bebb 100644 --- a/src/ui/railCards.test.mjs +++ b/src/ui/railCards.test.mjs @@ -183,11 +183,26 @@ test('setting ranges stay below presets and commit expensive filters on release' .textContent, '53%', ); + view.update([model(100)]); + assert.equal(slider.value, '53', 'a status refresh retains the pending drag'); + assert.equal( + slider.getAttribute('aria-valuetext'), + '53%', + 'the accessible preview also survives a refresh', + ); slider.dispatchEvent(new Event('change')); assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]); view.update([model(53)]); assert.equal(slider.value, '53'); assert.equal(f.document.activeElement, slider); + slider.value = '41'; + slider.dispatchEvent(new Event('input')); + view.update([model(70)]); + assert.equal( + slider.value, + '70', + 'an authoritative value supersedes the draft', + ); view.destroy(); slider.value = '41'; slider.dispatchEvent(new Event('change')); diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index 02ee75ea6..0540fac0a 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -351,7 +351,8 @@ body.recording-mode .gev-wind-inspection-marker { outline-offset: 3px; } .rail-card-setting-range-value { - min-width: 3ch; + flex: 0 0 4ch; + white-space: nowrap; color: var(--text-primary); font: 9px var(--font-mono); text-align: right;