From 8a7402d80c4f1e14d48918e38a75e9306263e089 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 18:14:16 +0100 Subject: [PATCH] fix(street-level): keep explicit provider choices on filter-only updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on top of #1601: - Only redirect a provider the params switch on to a keyless source, so a filter-only setParams (360°, SINCE) no longer moves an explicitly chosen keyless Mapillary to KartaView. - Keyless QA gate: assert the new KartaView default and that the Mapillary chip explains its key instead of switching (it used to expect KEY REQUIRED). - Tests: Graph-proxy keyReady, filter-only updates; shared test helper. - README and CURRENT-STATE describe the keyless default. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/CURRENT-STATE.md | 8 +- scripts/qa-street-level.mjs | 30 +++++- src/layers/streetLevel/index.js | 18 ++-- src/layers/streetLevel/index.test.mjs | 91 +++++++++---------- .../streetLevel/providers/mapillary/index.js | 2 +- 6 files changed, 89 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index c9eb6e52f..32981b337 100644 --- a/README.md +++ b/README.md @@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED > 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** and **Panoramax** need nothing: their public APIs are 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 Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **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. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). 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. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3540c3096..7bfb74470 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4429,8 +4429,10 @@ provider draws in one colour (`PROVIDER_COLORS` in 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, KartaView and Panoramax are registered. Without -`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are -disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 +`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first +keyless provider (KartaView), including a restored "Mapillary on" unless the link +switches the keyless providers off; filter-only updates never change the source. +With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent requests for a tile. @@ -4444,7 +4446,7 @@ the camera, nearest first, at most 9 (the halves of a view across the date line count a shared tile once). Lines are draped `GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain and Google 3D tiles alike; cones and the photo marker are ground-clamped -billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel +billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED, says which key to add and disables the filters; a rejected key reads KEY REJECTED and stops tile requests until the layer goes off; a 429 pauses them for its Retry-After. diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 0dd841acf..4b5a0d958 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -296,6 +296,22 @@ async function expandPanel(page) { ); } +/** A keyless bundle opens on the first keyless source with live controls. */ +async function assertKeylessDefault(page) { + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('street-level', true, { + origin: 'user', + }), + ); + await uiUntil(page, litAlone, 'kartaview'); + const shown = await page.evaluate(() => ({ + status: document.getElementById('sl-status').textContent, + disabled: document.getElementById('sl-controls').disabled, + })); + assert.notEqual(shown.status, 'KEY REQUIRED'); + assert.equal(shown.disabled, false, 'controls stay live'); +} + async function assertKeyRequired(page) { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('street-level', true, { @@ -529,8 +545,16 @@ async function main() { await expandPanel(page); if (!configured) { - await step('keyless server: KEY REQUIRED gates the controls', () => - assertKeyRequired(page), + await step('keyless server: opens on KartaView, not KEY REQUIRED', () => + assertKeylessDefault(page), + ); + await step( + 'keyless server: the MAPILLARY chip explains its key instead of switching', + async () => { + await press(page, '#sl-provider-chips [data-chip-id="mapillary"]'); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(litAlone(await getUI(page), 'kartaview')); + }, ); skip('no Mapillary key', 'the keyed steps'); } else { @@ -751,6 +775,8 @@ async function main() { await park(page); await fixtureStep( + // The fixture bundle carries a token, so this covers a keyless + // server behind a keyed bundle; the keyless default is checked above. 'keyless server (second page): KEY REQUIRED gates the controls', async () => { const keyless = await open( diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 8d332c227..9c22b95a5 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -126,7 +126,7 @@ export function createStreetLevelLayer({ }); } - /** `def`, or the first keyless provider not refused when `def` has no key. */ + /** `def` if its key is ready, else the first keyless provider not refused. */ function keylessInstead(def, refused = () => false) { if (def.keyReady?.() !== false) return def; return defs.find((d) => !d.requiresKeyId && !refused(d.id)) || def; @@ -594,15 +594,17 @@ export function createStreetLevelLayer({ providerIds: state.providers.keys(), filter: state.filter, }); - // Links and stored state omit Mapillary when it is on (the codec - // default), so a keyless install would restore a source it cannot draw. + // A restored "Mapillary on" (the codec default) would light a source a + // keyless install cannot draw. Only redirect a provider the params switch + // on: a filter-only update keeps whatever the user chose. const asked = litByParams(decoded.providers); const lit = - asked && - keylessInstead( - state.providers.get(asked).def, - (id) => decoded.providers.get(id) === false, - ).id; + asked && decoded.providers.get(asked) === true + ? keylessInstead( + state.providers.get(asked).def, + (id) => decoded.providers.get(id) === false, + ).id + : asked; const before = litIds(); for (const [id, entry] of state.providers) if (id !== lit) switchProvider(entry, false); diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 93fc96d23..0a26df1f1 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1128,63 +1128,58 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( assert.match(view.meta, /Google Street View needs the Google 3D map/); }); -test('without a Mapillary key the first keyless source is lit instead', async (t) => { - const kartaViewSource = { - getTile: async () => ({ lines: [] }), - nearestPhotos: async () => [], - getPhoto: async () => { - throw new Error('KartaView has no such photo'); - }, - getSequencePhotos: async () => ({ photos: [], hasMore: false }), - probeImage: async () => true, - }; - const lit = (hasToken) => { - const layer = createStreetLevelLayer({ - providers: [ - createMapillaryProvider({ - source: fakeMapillarySource({ hasToken: () => hasToken }), - photoViewer: fakePhotoViewer(), - }), - createKartaViewProvider({ source: kartaViewSource }), - ], - }); - t.after(() => layer.destroy()); - return layer - .getUIState() - .providers.filter((provider) => provider.on) - .map((provider) => provider.id); - }; - assert.deepEqual(lit(false), ['kartaview']); - assert.deepEqual(lit(true), ['mapillary']); -}); - -test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => { +/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */ +function keylessLayer(t, mapillary = { hasToken: () => false }) { const layer = createStreetLevelLayer({ providers: [ createMapillaryProvider({ - source: fakeMapillarySource({ hasToken: () => false }), + source: fakeMapillarySource(mapillary), photoViewer: fakePhotoViewer(), }), - createKartaViewProvider({ - source: { - getTile: async () => ({ lines: [] }), - nearestPhotos: async () => [], - getPhoto: async () => null, - getSequencePhotos: async () => ({ photos: [], hasMore: false }), - probeImage: async () => true, - }, - }), + createKartaViewProvider(), ], }); t.after(() => layer.destroy()); - const litIds = () => - layer - .getUIState() - .providers.filter((provider) => provider.on) - .map((provider) => provider.id); + return layer; +} +const litProviders = (layer) => + layer + .getUIState() + .providers.filter((provider) => provider.on) + .map((provider) => provider.id); + +test('without a Mapillary key the first keyless source is lit instead', (t) => { + assert.deepEqual(litProviders(keylessLayer(t)), ['kartaview']); + assert.deepEqual(litProviders(keylessLayer(t, {})), ['mapillary']); + // A token-adding Graph proxy counts as a key. + const proxied = keylessLayer(t, { + hasToken: () => false, + graphEndpoint: '/api/mapillary/graph', + }); + assert.deepEqual(litProviders(proxied), ['mapillary']); +}); + +test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => { + const layer = keylessLayer(t); layer.setParams({ mapillary: true, providerSwitches: '' }); - assert.deepEqual(litIds(), ['kartaview']); + assert.deepEqual(litProviders(layer), ['kartaview']); // A link that switches KartaView off keeps its word. layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' }); - assert.deepEqual(litIds(), ['mapillary']); + assert.deepEqual(litProviders(layer), ['mapillary']); +}); + +test('filter-only updates keep the lit source, even a chosen keyless Mapillary', (t) => { + const layer = keylessLayer(t); + layer.setParams({ pano: 'pano' }); + assert.deepEqual(litProviders(layer), ['kartaview']); + // What the MAPILLARY chip sends, and what stored state restores. + layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' }); + for (const params of [{ pano: 'pano' }, { sinceDays: 365 }, {}]) { + layer.setParams(params); + assert.deepEqual( + litProviders(layer), + ['mapillary'], + JSON.stringify(params), + ); + } }); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index c2e9e7850..b930405f1 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -74,7 +74,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) { name: MAPILLARY_NAME, label: MAPILLARY_LABEL, requiresKeyId: MAPILLARY_KEY_ID, - /** A browser token or a token-adding Graph proxy; else KEY REQUIRED. */ + /** A browser token, or a Graph proxy that adds one server-side. */ keyReady: () => source.hasToken?.() !== false || Boolean(source.graphEndpoint), pickPrefix: PICK_PREFIX.root,