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; }