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/changelog.d/1601-street-level-keyless-default.md b/changelog.d/1601-street-level-keyless-default.md new file mode 100644 index 000000000..04d96b1bd --- /dev/null +++ b/changelog.d/1601-street-level-keyless-default.md @@ -0,0 +1,4 @@ +- Without a Mapillary token, Street Level starts on the first keyless source + (KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on + the globe. A share link that turns a source off is respected + (asturksever, #1601). 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..982c4e581 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, { @@ -528,11 +544,25 @@ async function main() { ); await expandPanel(page); - if (!configured) { + if (!configured && litAlone(await getUI(page), 'mapillary')) { + // A keyed bundle behind a server without the token keeps Mapillary. await step('keyless server: KEY REQUIRED gates the controls', () => assertKeyRequired(page), ); skip('no Mapillary key', 'the keyed steps'); + } else if (!configured) { + 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 { await park(page); await step( @@ -751,6 +781,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 1f7b0f7f0..9c22b95a5 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -126,8 +126,17 @@ export function createStreetLevelLayer({ }); } - // One source at a time: the first provider that defaults on. - const startsOn = defs.find((def) => def.defaultOn !== false); + /** `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; + } + + // One source at a time: the first provider that defaults on. Without its + // key, the first keyless source instead, so a fresh keyless install opens + // on coverage rather than KEY REQUIRED. + const preferred = defs.find((def) => def.defaultOn !== false); + const startsOn = preferred && keylessInstead(preferred); for (const def of defs) { const entry = { def, @@ -585,7 +594,17 @@ export function createStreetLevelLayer({ providerIds: state.providers.keys(), filter: state.filter, }); - const lit = litByParams(decoded.providers); + // 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 && 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 6c79a52ca..0a26df1f1 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1127,3 +1127,59 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( const view = presentStreetLevelPanel(layer.getUIState()); assert.match(view.meta, /Google Street View needs the Google 3D map/); }); + +/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */ +function keylessLayer(t, mapillary = { hasToken: () => false }) { + const layer = createStreetLevelLayer({ + providers: [ + createMapillaryProvider({ + source: fakeMapillarySource(mapillary), + photoViewer: fakePhotoViewer(), + }), + createKartaViewProvider(), + ], + }); + t.after(() => layer.destroy()); + 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(litProviders(layer), ['kartaview']); + // A link that switches KartaView off keeps its word. + layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' }); + 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 928922195..b930405f1 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -74,6 +74,9 @@ export function createMapillaryProvider({ source, photoViewer = null }) { name: MAPILLARY_NAME, label: MAPILLARY_LABEL, requiresKeyId: MAPILLARY_KEY_ID, + /** A browser token, or a Graph proxy that adds one server-side. */ + keyReady: () => + source.hasToken?.() !== false || Boolean(source.graphEndpoint), pickPrefix: PICK_PREFIX.root, colors: COLORS, credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }), diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 5e51929a6..4ba1763ab 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -20,6 +20,8 @@ export function filtersOf(provider) { * @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image. * @property {string} [requiresMapStack] Usable only on this map stack. * @property {boolean} [defaultOn] Default true; the first that defaults on is lit. + * @property {() => boolean} [keyReady] False when its key is missing now; the + * first keyless provider is lit instead of a default that cannot draw. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").