mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
test(street-level): KartaView in the hermetic browser gate; docs
The fixture gate answers api.kartaview.org, cdn.kartaview.org and *.openstreetcam.org from fixtures (a drive with a 360° photo, stored on storage2 while the API names storage1) and adds three steps: keyless coverage with its credit and hidden filters, a line click opening the nearest photo with cones and ›, and a 360° photo on the panorama without FIT / FILL. The Street View share-link check reads the switch field instead of one exact entry, since a chip click records every provider's switch. README, DATA_SOURCES, CHANGELOG and CURRENT-STATE describe KartaView. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
cb01014e36
commit
310984ff0b
8 files changed
+583
-18
No files matched your search
@@ -95,6 +95,18 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Street Level: KartaView is the third provider, keyless. Light its chip
|
||||||
|
(purple; off by default, one provider at a time) and zoom in below about
|
||||||
|
6 km: the lines are the streets KartaView has photos of (z13–14 tiles from
|
||||||
|
`api.kartaview.org`). A click on a line opens the photo nearest the click
|
||||||
|
without moving the camera, flat or 360° (a small WebGL panorama: drag to
|
||||||
|
look, wheel to zoom), with cones along its drive and ‹ › / ← → to step
|
||||||
|
through it. The caption links to the full-size photo (KartaView's photo
|
||||||
|
pages fail for signed-out visitors), and the globe credits "© KartaView
|
||||||
|
contributors, CC BY-SA 4.0". Share links carry it as `r.kartaview-1`.
|
||||||
|
Mapillary's line coverage and sequence cones are now shared by both
|
||||||
|
(`lineCoverage.js`, `sequenceCones.js`).
|
||||||
|
|
||||||
- Street Level: Google Street View is the second provider. On the Google 3D
|
- Street Level: Google Street View is the second provider. On the Google 3D
|
||||||
map (Google's terms keep it off other maps), light its chip (off by
|
map (Google's terms keep it off other maps), light its chip (off by
|
||||||
default: every panorama is billed to the Google Maps key, which needs the
|
default: every panorama is billed to the Google Maps key, which needs the
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ How to read this:
|
|||||||
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
||||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
| **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 |
|
| **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 |
|
||||||
| **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 |
|
| **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 |
|
| **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 |
|
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add
|
|||||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
| 🚗 **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 | 🟢 |
|
| 📹 **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) | 🟢 |
|
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary and Google Street View; Panoramax and KartaView planned | Mapillary · Google | 🟡 Mapillary token · 🔴 Google Maps key |
|
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View and KartaView; Panoramax planned | Mapillary · Google · KartaView | 🟡 Mapillary token · 🔴 Google Maps key · KartaView keyless |
|
||||||
| 📻 **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 | 🟢 |
|
| 📻 **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 | 🟢 |
|
| 🚌 **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 | 🟢 |
|
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||||
@@ -340,13 +340,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
|||||||
|
|
||||||
## 📷 Street Level
|
## 📷 Street Level
|
||||||
|
|
||||||
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token) and **Google Street View** (your Google Maps key).
|
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key) and **KartaView** (no key).
|
||||||
|
|
||||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key.
|
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless.
|
||||||
|
|
||||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google").
|
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||||
|
|
||||||
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.r.google-1` Street View on, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.r.google-1` Street View on, `0.r.kartaview-1` KartaView on, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||||
|
|
||||||
## 🎖️ Field Missions
|
## 🎖️ Field Missions
|
||||||
|
|
||||||
|
|||||||
+37
-5
@@ -3083,8 +3083,8 @@ meaning), one viewer host and one on-globe credit per active provider. Each
|
|||||||
provider draws in one colour (`PROVIDER_COLORS` in
|
provider draws in one colour (`PROVIDER_COLORS` in
|
||||||
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
|
`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,
|
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
||||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary and Google
|
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||||
Street View are registered. Without
|
Street View and KartaView are registered. Without
|
||||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
`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
|
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
|
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||||
@@ -3135,9 +3135,12 @@ Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
|||||||
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
||||||
manages each provider's credit and fans the filter out to every provider. To
|
manages each provider's credit and fans the filter out to every provider. To
|
||||||
add a provider: implement the definition, register it in
|
add a provider: implement the definition, register it in
|
||||||
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
|
`src/app/layers/streetLevel.js` and add its modules to
|
||||||
group in `src/data/layerState.js` and its modules to
|
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
|
||||||
`scripts/package-boundaries.json`. With providers on different keys the
|
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
|
||||||
|
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
|
||||||
|
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
|
||||||
|
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
|
||||||
layer's `requiresKeyId` is null and each chip reports its own key.
|
layer's `requiresKeyId` is null and each chip reports its own key.
|
||||||
|
|
||||||
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
||||||
@@ -3184,6 +3187,35 @@ once; a lookup or open Google never answers fails after 10 or 20 s. In CI
|
|||||||
shows off Google 3D, even switched on by a share link; the click and button
|
shows off Google 3D, even switched on by a share link; the click and button
|
||||||
step skips as `no Google key`.
|
step skips as `no Google key`.
|
||||||
|
|
||||||
|
KartaView (`providers/kartaview/`) is keyless and browser-side: CORS is open
|
||||||
|
on `api.kartaview.org` (the documented `api.openstreetcam.org` answers but
|
||||||
|
serves empty tiles), `cdn.kartaview.org` and `storage*.openstreetcam.org`.
|
||||||
|
Its chip starts off. Coverage comes from z14 JSON tiles below 2.5 km above
|
||||||
|
ground and z13 up to 6 km (lower zooms take seconds per tile; z10 does not
|
||||||
|
answer), so higher up the hint says to zoom in. A tile's lines are OSM way
|
||||||
|
segments with photos and carry no sequence, date or projection, so
|
||||||
|
`decode.js` chains segments that meet end to start (about a quarter of the
|
||||||
|
lines), they are drawn unselectable as `kv:line:<z/x/y/n>`, and the provider
|
||||||
|
declares `filters: []` and `coverageLabel` ("N streets in view · click a line
|
||||||
|
for its nearest photo"). A click on a line reaches `handlePick(id,
|
||||||
|
{groundPoint})`, and the provider asks the core (`actions.openNearest`) for
|
||||||
|
its photo nearest that point within 50 m, without framing. The open photo's
|
||||||
|
sequence draws as cones from the 1,000-photo page holding it
|
||||||
|
(`/2.0/sequence/{id}/photos`; a page with missing photos is found by stepping
|
||||||
|
back), and ‹ › step across pages, re-centring the cones. The photo API answers
|
||||||
|
429 to more than about three requests at once, so `source.js` keeps two in
|
||||||
|
flight and retries a 429 with backoff. Photo URLs are built from `storage` and
|
||||||
|
`filepath*`: the API's `fileurl*` always name storage1, where photos stored
|
||||||
|
elsewhere 404. The viewer (`viewer.js`) shows a flat photo's 1280 px rendition,
|
||||||
|
then a 2,560 px one from the image proxy (`pr:sharp/rs:fit:W:H/<base64url>`),
|
||||||
|
and a 360° photo's equirectangular `wrapped_proc` image (13,000 px wide
|
||||||
|
upstream) resized to 1,024 then 4,096 px on `panorama.js`, a single-triangle
|
||||||
|
WebGL renderer (image centre = the camera heading; drag, wheel). Without WebGL
|
||||||
|
a 360° photo shows its front view. FIT / FILL hide for panoramas
|
||||||
|
(`supportsRenderModes`). The caption has no place name (KartaView's is where
|
||||||
|
the drive began) and links to the full-size photo, since kartaview.org's photo
|
||||||
|
pages fail for signed-out visitors.
|
||||||
|
|
||||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
|
||||||
|
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KartaView's API, image proxy and photo storage answered from fixtures, so
|
||||||
|
* the gate's KartaView steps need no network. One drive runs north along
|
||||||
|
* KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while
|
||||||
|
* their `fileurl*` name storage1, as KartaView's API does, so the gate also
|
||||||
|
* proves the provider builds addresses from `storage`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KV_SEQUENCE_ID = '900001';
|
||||||
|
export const KV_LINE = Object.freeze({
|
||||||
|
lon: -121.4931,
|
||||||
|
south: 38.5795,
|
||||||
|
north: 38.5875,
|
||||||
|
});
|
||||||
|
const KV_SPACING_M = 25;
|
||||||
|
const METRES_PER_DEG_LAT = 110_540;
|
||||||
|
const SHOT = '2024-05-02 16:20:00.000';
|
||||||
|
|
||||||
|
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
|
||||||
|
|
||||||
|
/** The drive's photos, south to north; every tenth is 360°. */
|
||||||
|
export function kartaViewPhotos() {
|
||||||
|
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||||
|
const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1;
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: String(77_000_000 + i),
|
||||||
|
index: i,
|
||||||
|
lon: KV_LINE.lon,
|
||||||
|
lat: Number((KV_LINE.south + i * step).toFixed(7)),
|
||||||
|
isPano: i % 10 === 5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(photo) {
|
||||||
|
const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`;
|
||||||
|
const folder = photo.isPano ? 'wrapped_proc' : 'proc';
|
||||||
|
const path = (prefix) => `2024/5/2/${prefix}/${name}`;
|
||||||
|
const storage1 = (prefix) =>
|
||||||
|
`https://storage1.openstreetcam.org/files/photo/${path(prefix)}`;
|
||||||
|
return {
|
||||||
|
id: photo.id,
|
||||||
|
sequenceId: KV_SEQUENCE_ID,
|
||||||
|
sequenceIndex: String(photo.index),
|
||||||
|
lat: String(photo.lat),
|
||||||
|
lng: String(photo.lon),
|
||||||
|
matchLat: String(photo.lat),
|
||||||
|
matchLng: String(photo.lon),
|
||||||
|
heading: '0',
|
||||||
|
fieldOfView: photo.isPano ? '360' : '0',
|
||||||
|
projection: photo.isPano ? 'SPHERE' : 'PLANE',
|
||||||
|
shotDate: SHOT,
|
||||||
|
dateAdded: SHOT,
|
||||||
|
storage: 'storage2',
|
||||||
|
filepath: path('{{sizeprefix}}'),
|
||||||
|
filepathLTh: path('lth'),
|
||||||
|
filepathProc: path(folder),
|
||||||
|
fileurl: storage1('{{sizeprefix}}'),
|
||||||
|
fileurlLTh: storage1('lth'),
|
||||||
|
fileurlProc: storage1(folder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = (result) => ({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: CORS,
|
||||||
|
body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }),
|
||||||
|
});
|
||||||
|
const raw = (status, body) => ({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: CORS,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The drive's track inside tile `z/x/y` (with a small margin), as KartaView's JSON tile. */
|
||||||
|
function tileLines(z, x, y) {
|
||||||
|
const { west, east, south, north } = tileBounds(x, y, z);
|
||||||
|
if (KV_LINE.lon < west || KV_LINE.lon > east) return [];
|
||||||
|
const from = Math.max(KV_LINE.south, south);
|
||||||
|
const to = Math.min(KV_LINE.north, north);
|
||||||
|
if (from >= to) return [];
|
||||||
|
// Two segments meeting end to start, as KartaView splits ways.
|
||||||
|
const mid = (from + to) / 2;
|
||||||
|
const at = (lat) => ({ lat: String(lat), lng: String(KV_LINE.lon) });
|
||||||
|
return [
|
||||||
|
{ track: [at(from), at(mid)], matched: '1' },
|
||||||
|
{ track: [at(mid), at(to)], matched: '1' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A call named by host, path shape and parameter names. */
|
||||||
|
export function describeKartaViewCall(method, url) {
|
||||||
|
const keys = [...url.searchParams.keys()].sort();
|
||||||
|
const path = url.pathname
|
||||||
|
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
|
||||||
|
.replace(/\/photo\/\d+$/, '/photo/{id}')
|
||||||
|
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
|
||||||
|
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
|
||||||
|
.replace(
|
||||||
|
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
|
||||||
|
'/pr:sharp/rs:fit:{w}:{h}/{address}',
|
||||||
|
);
|
||||||
|
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer one KartaView request; `known` is false for a call no fixture
|
||||||
|
* covers, so the gate fails on a new one instead of missing it.
|
||||||
|
*/
|
||||||
|
export function answerKartaViewRequest({ method, url }) {
|
||||||
|
const address = new URL(url);
|
||||||
|
const photos = kartaViewPhotos();
|
||||||
|
if (method === 'OPTIONS')
|
||||||
|
return { status: 204, headers: CORS, body: '', known: true };
|
||||||
|
const image = {
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
headers: CORS,
|
||||||
|
body: photoJpeg(),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
if (address.hostname === 'cdn.kartaview.org')
|
||||||
|
return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname)
|
||||||
|
? image
|
||||||
|
: { ...raw(404, {}), known: false };
|
||||||
|
if (address.hostname === 'storage2.openstreetcam.org')
|
||||||
|
return address.pathname.includes(KV_SEQUENCE_ID)
|
||||||
|
? image
|
||||||
|
: { ...raw(404, {}), known: false };
|
||||||
|
if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname))
|
||||||
|
// The wrong host the API names: KartaView answers 404 there.
|
||||||
|
return { ...raw(404, {}), known: false };
|
||||||
|
if (address.hostname !== 'api.kartaview.org')
|
||||||
|
return { ...raw(404, {}), known: false };
|
||||||
|
const path = address.pathname;
|
||||||
|
const tile = path.match(
|
||||||
|
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/,
|
||||||
|
);
|
||||||
|
if (tile)
|
||||||
|
return {
|
||||||
|
...raw(200, {
|
||||||
|
lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])),
|
||||||
|
}),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
if (path === '/2.0/photo/') {
|
||||||
|
const q = address.searchParams;
|
||||||
|
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||||
|
const near = photos
|
||||||
|
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
|
||||||
|
.filter(({ metres }) => metres < 200)
|
||||||
|
.sort((a, b) => a.metres - b.metres)
|
||||||
|
.slice(0, Number(q.get('itemsPerPage')) || 10)
|
||||||
|
.map(({ photo, metres }) => ({
|
||||||
|
...record(photo),
|
||||||
|
distance: metres.toFixed(2),
|
||||||
|
}));
|
||||||
|
return { ...ok({ data: near, hasMoreData: false }), known: true };
|
||||||
|
}
|
||||||
|
const one = path.match(/^\/2\.0\/photo\/(\d+)$/);
|
||||||
|
if (one) {
|
||||||
|
const photo = photos.find((p) => p.id === one[1]);
|
||||||
|
return photo
|
||||||
|
? { ...ok({ data: record(photo) }), known: true }
|
||||||
|
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
|
||||||
|
}
|
||||||
|
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
|
||||||
|
if (page) {
|
||||||
|
const q = address.searchParams;
|
||||||
|
const size = Number(q.get('itemsPerPage')) || 100;
|
||||||
|
const number = Number(q.get('page')) || 1;
|
||||||
|
const rows = page[1] === KV_SEQUENCE_ID ? photos : [];
|
||||||
|
return {
|
||||||
|
...ok({
|
||||||
|
data: rows.slice((number - 1) * size, number * size).map(record),
|
||||||
|
hasMoreData: number * size < rows.length,
|
||||||
|
}),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...raw(404, {}), known: false };
|
||||||
|
}
|
||||||
@@ -933,6 +933,7 @@
|
|||||||
"src/tooling/streetLevelQa.test.mjs",
|
"src/tooling/streetLevelQa.test.mjs",
|
||||||
"scripts/qa-browserEvidence.mjs",
|
"scripts/qa-browserEvidence.mjs",
|
||||||
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||||
|
"scripts/fixtures/street-level/kartaView.mjs",
|
||||||
"src/layers/streetLevel/filter.test.mjs",
|
"src/layers/streetLevel/filter.test.mjs",
|
||||||
"src/layers/streetLevel/registry.test.mjs",
|
"src/layers/streetLevel/registry.test.mjs",
|
||||||
"src/layers/streetLevel/params.test.mjs",
|
"src/layers/streetLevel/params.test.mjs",
|
||||||
|
|||||||
+283
-8
@@ -3,8 +3,8 @@
|
|||||||
* Browser QA for the Street Level layer against a running server. Run with
|
* Browser QA for the Street Level layer against a running server. Run with
|
||||||
* `npm run qa:street-level -- --url http://localhost:4173`.
|
* `npm run qa:street-level -- --url http://localhost:4173`.
|
||||||
*
|
*
|
||||||
* `--fixtures` (the CI mode) answers every *.mapillary.com request from
|
* `--fixtures` (the CI mode) answers every *.mapillary.com and KartaView
|
||||||
* fixtures, so nothing reaches Mapillary. The server still needs a token
|
* request from fixtures, so nothing reaches Mapillary or KartaView. The server still needs a token
|
||||||
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
|
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
|
||||||
* a rejected key and a keyless second server.
|
* a rejected key and a keyless second server.
|
||||||
*
|
*
|
||||||
@@ -37,6 +37,13 @@ import {
|
|||||||
PHOTO_SEQUENCE_ID,
|
PHOTO_SEQUENCE_ID,
|
||||||
photoImages,
|
photoImages,
|
||||||
} from './fixtures/street-level/mapillaryGraph.mjs';
|
} from './fixtures/street-level/mapillaryGraph.mjs';
|
||||||
|
import {
|
||||||
|
answerKartaViewRequest,
|
||||||
|
describeKartaViewCall,
|
||||||
|
KV_LINE,
|
||||||
|
KV_SEQUENCE_ID,
|
||||||
|
kartaViewPhotos,
|
||||||
|
} from './fixtures/street-level/kartaView.mjs';
|
||||||
|
|
||||||
const PANEL_ID = 'street-level-panel';
|
const PANEL_ID = 'street-level-panel';
|
||||||
|
|
||||||
@@ -49,7 +56,11 @@ export const VIEWPORTS = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/** Provider chips the panel must show, in order. */
|
/** Provider chips the panel must show, in order. */
|
||||||
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']);
|
export const EXPECTED_PROVIDERS = Object.freeze([
|
||||||
|
'mapillary',
|
||||||
|
'google',
|
||||||
|
'kartaview',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
|
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
|
||||||
@@ -229,6 +240,9 @@ export async function serveFixtures(page, fixture) {
|
|||||||
patterns: [
|
patterns: [
|
||||||
{ urlPattern: '*://*.mapillary.com/*' },
|
{ urlPattern: '*://*.mapillary.com/*' },
|
||||||
{ urlPattern: '*/api/mapillary/*' },
|
{ urlPattern: '*/api/mapillary/*' },
|
||||||
|
{ urlPattern: '*://api.kartaview.org/*' },
|
||||||
|
{ urlPattern: '*://cdn.kartaview.org/*' },
|
||||||
|
{ urlPattern: '*://*.openstreetcam.org/*' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const fulfil = (requestId, { status, headers = {}, contentType, body }) =>
|
const fulfil = (requestId, { status, headers = {}, contentType, body }) =>
|
||||||
@@ -259,6 +273,11 @@ export async function serveFixtures(page, fixture) {
|
|||||||
});
|
});
|
||||||
fixture.calls.add(call);
|
fixture.calls.add(call);
|
||||||
if (!answer.known) fixture.unknown.push(call);
|
if (!answer.known) fixture.unknown.push(call);
|
||||||
|
} else if (/(^|\.)(kartaview|openstreetcam)\.org$/.test(address.hostname)) {
|
||||||
|
const call = describeKartaViewCall(method, address);
|
||||||
|
answer = answerKartaViewRequest({ method, url: request.url });
|
||||||
|
fixture.calls.add(call);
|
||||||
|
if (!answer.known) fixture.unknown.push(call);
|
||||||
} else if (
|
} else if (
|
||||||
address.pathname === '/api/mapillary/status' &&
|
address.pathname === '/api/mapillary/status' &&
|
||||||
fixture.configured !== undefined
|
fixture.configured !== undefined
|
||||||
@@ -726,6 +745,253 @@ async function main() {
|
|||||||
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
|
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KartaView: keyless coverage from its JSON tiles, a line click opening
|
||||||
|
* the nearest photo, cones along the drive, › and a 360° photo. Runs on a
|
||||||
|
* draped map so a line is where it is drawn; leaves Mapillary lit.
|
||||||
|
*/
|
||||||
|
async function kartaViewSteps() {
|
||||||
|
const ui = () =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.getUIState(),
|
||||||
|
);
|
||||||
|
const uiUntil = (test, arg = null, options) =>
|
||||||
|
settle(
|
||||||
|
page,
|
||||||
|
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
|
||||||
|
undefined,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const lit = (id) =>
|
||||||
|
uiUntil(
|
||||||
|
(u, want) =>
|
||||||
|
u.providers.find((p) => p.id === want)?.on === true &&
|
||||||
|
u.providers.filter((p) => p.on).length === 1,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
const stack = () =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
window.__godsEyeView.mapStackController.getActiveId(),
|
||||||
|
);
|
||||||
|
const setStack = async (id) => {
|
||||||
|
await page.evaluate(
|
||||||
|
(stackId) =>
|
||||||
|
window.__godsEyeView.mapStackController.setStack(stackId),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
await settle(
|
||||||
|
page,
|
||||||
|
(stackId) =>
|
||||||
|
window.__godsEyeView.mapStackController.getActiveId() === stackId,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const kvChip = '#sl-provider-chips [data-chip-id="kartaview"]';
|
||||||
|
const original = await stack();
|
||||||
|
const photos = kartaViewPhotos();
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.closeViewer(),
|
||||||
|
);
|
||||||
|
await setStack('esri-imagery');
|
||||||
|
// Over the fixture drive, looking down: its line is mid-screen.
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
await page.evaluate((at) => {
|
||||||
|
const v = window.__godsEyeView.viewer;
|
||||||
|
v.camera.cancelFlight?.();
|
||||||
|
const C = v.camera.positionCartographic.constructor;
|
||||||
|
v.camera.setView({
|
||||||
|
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||||
|
C.fromDegrees(at.lon, at.lat, at.height),
|
||||||
|
),
|
||||||
|
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||||||
|
});
|
||||||
|
}, PARK);
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
await step(
|
||||||
|
'KartaView: its chip draws keyless coverage, credits KartaView and hides the filters',
|
||||||
|
async () => {
|
||||||
|
await clickPanelControl(page, kvChip);
|
||||||
|
assert.ok(await lit('kartaview'), 'KartaView alone is lit');
|
||||||
|
await waitForCoverage(page);
|
||||||
|
const u = await ui();
|
||||||
|
assert.equal(u.keyRequired, false);
|
||||||
|
assert.deepEqual(u.filterControls, { pano: false, since: false });
|
||||||
|
assert.equal(
|
||||||
|
await page.$eval(kvChip, (chip) =>
|
||||||
|
chip.style.getPropertyValue('--chip-color'),
|
||||||
|
),
|
||||||
|
'#635bff',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
|
||||||
|
/streets in view · click a line for its nearest photo/,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.body.innerHTML.includes('KartaView</a> contributors'),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
await settle(page, () =>
|
||||||
|
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
||||||
|
.split('_')
|
||||||
|
.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.startsWith('0.') && entry.includes('kartaview-1'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
`the share link lights KartaView: ${await page.evaluate(() => location.hash)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await step(
|
||||||
|
'KartaView: a click on a line opens the nearest photo, cones follow its drive, and › steps along it',
|
||||||
|
async ({ skip }) => {
|
||||||
|
const along = Array.from({ length: 9 }, (_, i) => [
|
||||||
|
KV_LINE.lon,
|
||||||
|
KV_LINE.south + ((i + 2) / 13) * (KV_LINE.north - KV_LINE.south),
|
||||||
|
]);
|
||||||
|
const handle = await page
|
||||||
|
.waitForFunction(
|
||||||
|
findPickPoint,
|
||||||
|
{ timeout: 30_000, polling: 250 },
|
||||||
|
{
|
||||||
|
prefix: 'kv:line:',
|
||||||
|
id: null,
|
||||||
|
points: fixtures ? along : null,
|
||||||
|
billboardIds: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!handle && !fixtures) return skip('no KartaView line in view');
|
||||||
|
assert.ok(handle, 'a KartaView line is pickable');
|
||||||
|
const at = await handle.jsonValue();
|
||||||
|
await page.mouse.click(at.x, at.y);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const s = window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.getUIState().street;
|
||||||
|
return (s.imageId && !s.loading) || s.error;
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
const street = (await ui()).street;
|
||||||
|
if (!fixtures && /No street-level imagery/.test(street.error || ''))
|
||||||
|
return skip('no KartaView photo near the line');
|
||||||
|
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||||
|
assert.equal(street.providerId, 'kartaview');
|
||||||
|
assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo');
|
||||||
|
assert.ok(
|
||||||
|
await settle(page, () => {
|
||||||
|
const img = document.querySelector('.sl-kv-photo');
|
||||||
|
return img && !img.hidden && img.naturalWidth > 0;
|
||||||
|
}),
|
||||||
|
'the photo is on screen',
|
||||||
|
);
|
||||||
|
if (fixtures) {
|
||||||
|
assert.ok(
|
||||||
|
photos.some((photo) => photo.id === street.imageId),
|
||||||
|
`${street.imageId} is a fixture photo`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
await uiUntil(
|
||||||
|
(u, want) =>
|
||||||
|
u.sequence.selectedId === want.id &&
|
||||||
|
!u.sequence.loading &&
|
||||||
|
u.sequence.images === want.count,
|
||||||
|
{ id: KV_SEQUENCE_ID, count: photos.length },
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
),
|
||||||
|
`cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const before = street.imageId;
|
||||||
|
await clickPanelControl(page, '.sl-kv-step-next');
|
||||||
|
assert.ok(
|
||||||
|
await uiUntil(
|
||||||
|
(u, was) =>
|
||||||
|
u.street.imageId && u.street.imageId !== was && !u.street.error,
|
||||||
|
before,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
),
|
||||||
|
'› opened the next photo',
|
||||||
|
);
|
||||||
|
if (fixtures)
|
||||||
|
assert.equal(
|
||||||
|
(await ui()).street.imageId,
|
||||||
|
String(Number(before) + 1),
|
||||||
|
'the next photo along the drive',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (fixtures)
|
||||||
|
await step(
|
||||||
|
'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it',
|
||||||
|
async () => {
|
||||||
|
const pano = photos.find((photo) => photo.isPano);
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
void window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.openImage('kartaview', id);
|
||||||
|
}, pano.id);
|
||||||
|
assert.ok(
|
||||||
|
await uiUntil(
|
||||||
|
(u, want) =>
|
||||||
|
u.street.imageId === want &&
|
||||||
|
!u.street.loading &&
|
||||||
|
u.street.isPano,
|
||||||
|
pano.id,
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
),
|
||||||
|
`the panorama opened: ${JSON.stringify((await ui()).street)}`,
|
||||||
|
);
|
||||||
|
const u = await ui();
|
||||||
|
assert.equal(u.street.error, null);
|
||||||
|
assert.equal(u.street.renderModes, false, 'no frame to fit');
|
||||||
|
assert.equal(
|
||||||
|
await page.$eval('.sl-kv-pano', (canvas) => canvas.hidden),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const box = await page.$eval('.sl-kv-pano', (canvas) => {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||||
|
});
|
||||||
|
const bearing = u.street.bearing;
|
||||||
|
await page.mouse.move(box.x, box.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
for (let i = 1; i <= 8; i++)
|
||||||
|
await page.mouse.move(box.x - i * 15, box.y);
|
||||||
|
await page.mouse.up();
|
||||||
|
assert.ok(
|
||||||
|
await uiUntil((v, was) => v.street.bearing !== was, bearing),
|
||||||
|
'dragging the panorama turned its bearing',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
else
|
||||||
|
await skipStep(
|
||||||
|
'fixtures only',
|
||||||
|
'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it',
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.closeViewer(),
|
||||||
|
);
|
||||||
|
await clickPanelControl(
|
||||||
|
page,
|
||||||
|
'#sl-provider-chips [data-chip-id="mapillary"]',
|
||||||
|
);
|
||||||
|
assert.ok(await lit('mapillary'), 'Mapillary is lit again');
|
||||||
|
await setStack(original);
|
||||||
|
}
|
||||||
|
|
||||||
async function keyedSteps() {
|
async function keyedSteps() {
|
||||||
// A late startup flight only shows itself by moving the camera, so park
|
// A late startup flight only shows itself by moving the camera, so park
|
||||||
// and check it stays put rather than reading a state.
|
// and check it stays put rather than reading a state.
|
||||||
@@ -1803,16 +2069,24 @@ async function main() {
|
|||||||
credit: false,
|
credit: false,
|
||||||
button: false,
|
button: false,
|
||||||
});
|
});
|
||||||
// Street View rides the shared provider-switch field (r).
|
// Street View rides the shared provider-switch field (r), beside
|
||||||
|
// the other providers' switches a chip click recorded.
|
||||||
const option = `${STREET_LEVEL_TOKEN}.r.google-1`;
|
const option = `${STREET_LEVEL_TOKEN}.r.google-1`;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
await settle(
|
await settle(
|
||||||
page,
|
page,
|
||||||
(token) =>
|
(prefix) =>
|
||||||
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
||||||
.split('_')
|
.split('_')
|
||||||
.includes(token),
|
.some(
|
||||||
option,
|
(entry) =>
|
||||||
|
entry.startsWith(prefix) &&
|
||||||
|
entry
|
||||||
|
.slice(prefix.length)
|
||||||
|
.split('*')
|
||||||
|
.includes('google-1'),
|
||||||
|
),
|
||||||
|
`${STREET_LEVEL_TOKEN}.r.`,
|
||||||
// The link is written once the view rests for 500 ms; under a
|
// The link is written once the view rests for 500 ms; under a
|
||||||
// software renderer with a photo open that can take a while.
|
// software renderer with a photo open that can take a while.
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -2026,6 +2300,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await kartaViewSteps();
|
||||||
await step(
|
await step(
|
||||||
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -2108,7 +2383,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
if (fixtures)
|
if (fixtures)
|
||||||
await step(
|
await step(
|
||||||
'every Mapillary request was answered from a fixture',
|
'every Mapillary and KartaView request was answered from a fixture',
|
||||||
() => {
|
() => {
|
||||||
console.log(
|
console.log(
|
||||||
` (answered: ${[...fixture.calls].sort().join(' | ')})`,
|
` (answered: ${[...fixture.calls].sort().join(' | ')})`,
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ import {
|
|||||||
THUMB_HOST,
|
THUMB_HOST,
|
||||||
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
||||||
import { redact } from '../../scripts/qa-browserEvidence.mjs';
|
import { redact } from '../../scripts/qa-browserEvidence.mjs';
|
||||||
|
import {
|
||||||
|
answerKartaViewRequest,
|
||||||
|
describeKartaViewCall,
|
||||||
|
KV_LINE,
|
||||||
|
KV_SEQUENCE_ID,
|
||||||
|
kartaViewPhotos,
|
||||||
|
} from '../../scripts/fixtures/street-level/kartaView.mjs';
|
||||||
|
import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js';
|
||||||
|
import { normalizePhoto } from '../layers/streetLevel/providers/kartaview/policy.js';
|
||||||
|
|
||||||
// The press helpers wait for paint; Node has no frames, so a tick stands in.
|
// The press helpers wait for paint; Node has no frames, so a tick stands in.
|
||||||
globalThis.requestAnimationFrame ??= (callback) =>
|
globalThis.requestAnimationFrame ??= (callback) =>
|
||||||
@@ -639,3 +648,52 @@ test('CI runs the gates hermetically against a production build, strict, with ev
|
|||||||
'a missed header press fails CI now that the rail settles',
|
'a missed header press fails CI now that the rail settles',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('KartaView fixtures draw the drive, answer nearest first, and refuse the storage host the API names', () => {
|
||||||
|
const body = (answer) => JSON.parse(answer.body);
|
||||||
|
const lat = (KV_LINE.south + KV_LINE.north) / 2;
|
||||||
|
const x = lonToTileX(KV_LINE.lon, 14);
|
||||||
|
const y = latToTileY(lat, 14);
|
||||||
|
const tile = answerKartaViewRequest({
|
||||||
|
method: 'GET',
|
||||||
|
url: `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/14.json`,
|
||||||
|
});
|
||||||
|
assert.equal(tile.known, true);
|
||||||
|
assert.ok(body(tile).lines.length >= 1);
|
||||||
|
const empty = answerKartaViewRequest({
|
||||||
|
method: 'GET',
|
||||||
|
url: `https://api.kartaview.org/2.0/sequence/tiles/${x + 3}/${y}/14.json`,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body(empty).lines, []);
|
||||||
|
const near = answerKartaViewRequest({
|
||||||
|
method: 'GET',
|
||||||
|
url: `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`,
|
||||||
|
});
|
||||||
|
const records = body(near).result.data;
|
||||||
|
assert.ok(records.length > 1);
|
||||||
|
assert.ok(Number(records[0].distance) <= Number(records[1].distance));
|
||||||
|
const photo = normalizePhoto(records[0]);
|
||||||
|
assert.match(photo.urls.view, /^https:\/\/storage2\.openstreetcam\.org\//);
|
||||||
|
assert.equal(
|
||||||
|
answerKartaViewRequest({ method: 'GET', url: photo.urls.view }).known,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
answerKartaViewRequest({ method: 'GET', url: records[0].fileurlLTh }).known,
|
||||||
|
false,
|
||||||
|
'the storage1 address the API gives is not where the photo is',
|
||||||
|
);
|
||||||
|
const page = answerKartaViewRequest({
|
||||||
|
method: 'GET',
|
||||||
|
url: `https://api.kartaview.org/2.0/sequence/${KV_SEQUENCE_ID}/photos?page=1&itemsPerPage=1000`,
|
||||||
|
});
|
||||||
|
assert.equal(body(page).result.data.length, kartaViewPhotos().length);
|
||||||
|
assert.ok(kartaViewPhotos().some((p) => p.isPano));
|
||||||
|
assert.equal(
|
||||||
|
describeKartaViewCall(
|
||||||
|
'GET',
|
||||||
|
new URL('https://api.kartaview.org/2.0/photo/77000001'),
|
||||||
|
),
|
||||||
|
'GET api.kartaview.org/2.0/photo/{id}',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user