diff --git a/CHANGELOG.md b/CHANGELOG.md index 6157732b7..c9d15d897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,9 +102,11 @@ open Google's own panorama in the panel, with its arrows, the address and month in the caption, a link to Google Maps and FOLLOW. Share links carry it in the provider switches - (`r.google-1`). Its panoramas are all 360°, so the IMAGERY and SINCE - filters and the legend apply to Mapillary only and hide when GOOGLE STREET - VIEW is the only lit provider. + (`r.google-1`). One provider is lit at a time (lighting one switches the + other off), Mapillary by default since it is free. Street View's panoramas + are all 360°, so the IMAGERY and SINCE filters and the legend apply to + Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker + is a Google-blue view cone. - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from diff --git a/README.md b/README.md index 8d410fe39..9ce8d1ff0 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. -**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). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with only 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. 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"). 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`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ad7de6969..a9aeeddbc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3108,11 +3108,11 @@ miss cooldown. Roads on elevated decks are still drawn at ground level. Framing a photo ignores mesh samples far below the bare earth (unloaded tiles). -The header pill is the layer switch. With one provider its chip is a layer -switch too; with several, darkening the last lit chip turns the layer off. A -chip without a usable key explains the key instead of lighting (or of -switching the layer off as the last lit chip); it still darkens beside a lit -one, and the other chips keep switching. When the switched-on providers lack +The header pill is the layer switch. Lighting a chip swaps the source; +clicking the lit chip turns the layer off. A chip without a usable key +explains the key instead of switching, and the other chips keep switching. +Lit off its map stack, a provider leaves the panel saying which map it +needs. When the switched-on providers lack their keys but a switched-off one would draw with a key already set (no Mapillary token, Street View on Google 3D with a Google key), the layer reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage @@ -3158,7 +3158,14 @@ and leaves the camera where it is (`groundClick.js`). Higher up the hint says to zoom in. **OPEN STREET VIEW** beside that hint (`#sl-nearest-btn`) does the same at the view centre, so Street View needs no pointer; it is disabled until the camera is at street zoom (and aimed at the sky, it opens -nothing and says to aim at a street). The chip reads GOOGLE STREET VIEW. Its +nothing and says to aim at a street). One provider is lit at a time: +`setProviderEnabled(id, true)` darkens the others, a chip click sends every +switch in one update so the share link records the swap, and params that +light several keep the one already on, else the first in chip order +(`litByParams`; reapplying them never flips). Mapillary, free, is lit by +default. The chip reads GOOGLE STREET VIEW, and an open panorama's globe +marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place +of GEV's amber pin. Its panoramas are all 360° and it draws no coverage, so it declares `filters: []` (a provider's `filters` default to both `pano` and `since`): lookups ignore the IMAGERY and SINCE filters, the panel hides each filter row no lit provider diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 6f9117fc4..a63836e0b 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1840,8 +1840,14 @@ async function main() { 'the Google credit shows on Google 3D', ); } - // Back off, as the next steps expect. - await setStreetView(false); + // Back to Mapillary (one source at a time), as the next steps expect. + await page.evaluate(() => + window.__godsEyeView.dataManager.setLayerParams( + 'street-level', + { mapillary: true, google: false }, + { origin: 'user' }, + ), + ); await stacks.set(original); await uiUntil( (u) => u.providers.find((p) => p.id === 'google')?.on !== true, diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 05dd50b4b..350e71dea 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -10,12 +10,7 @@ import * as render from '../../renderGovernor.js'; /** The browser Maps key the scene was started with (src/app/scene.js). */ const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null; -/** - * The default providers, once the application supplies its Street Level - * source: Mapillary, and Google Street View, which needs only the browser - * Maps key (off until lit). Without that source there are none, and the - * layer reads as not configured. - */ +/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */ function defaultProviders(sources) { if (sources.mapillary == null) return []; return [ diff --git a/src/data/scenePick.js b/src/data/scenePick.js index 2f3bee1b7..52bab7a21 100644 --- a/src/data/scenePick.js +++ b/src/data/scenePick.js @@ -49,9 +49,8 @@ export function isPickedWorldPosition(position) { } /** - * The world position under a screen point: the rendered surface (terrain, - * Google 3D) where the depth buffer has it, else the ellipsoid. Null over the - * sky or for a pick that names no place. + * The world position under a screen point: the rendered surface where the + * depth buffer has it, else the ellipsoid; null over the sky. * * @param {object} viewer A Cesium viewer. * @param {{x: number, y: number}} screenPosition @@ -84,8 +83,8 @@ export function pickGroundPosition(viewer, screenPosition) { } /** - * Whether a `scene.pick()` result is the map itself: nothing, or Google 3D - * tile content, which picks without an id. + * Whether a `scene.pick()` result is the map itself: nothing, or 3D tile + * content (which has no id). * * @param {object|null|undefined} picked * @returns {boolean} diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index 714c24e39..60910a66c 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -353,9 +353,7 @@ export function keySetupRequirement(id) { } /** - * Key guidance that also names the switched-off sources the layer could use - * with a key that is already set: `[{label, requiresKeyId}]`, e.g. Street - * View beside a Mapillary layer with no token. + * Key guidance that also names switched-off sources whose key is already set. * @param {string} requirement What `keySetupRequirement` said ('' allowed). * @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives] */ diff --git a/src/keySetupCore.test.mjs b/src/keySetupCore.test.mjs index 643c0c2a5..235deaa36 100644 --- a/src/keySetupCore.test.mjs +++ b/src/keySetupCore.test.mjs @@ -29,10 +29,7 @@ test('the boot provenance snapshot survives in-process Vite config re-evaluation // panel save has already set its values live on process.env. A recomputed // snapshot would classify the panel's own keys as external (read-only) until // a full process relaunch, so the first evaluation's snapshot must win. - const source = readFileSync( - new URL('../server/standalone/key-setup.js', import.meta.url), - 'utf8', - ); + const source = readFileSync(new URL('../server/standalone/key-setup.js', import.meta.url), 'utf8'); assert.match( source, /const PROVIDER_ENV_AT_BOOT\s*=\s*\(?globalThis\.__GEV_PROVIDER_ENV_AT_BOOT\s*\?\?=\s*Object\.freeze\(/, @@ -40,40 +37,24 @@ test('the boot provenance snapshot survives in-process Vite config re-evaluation }); test('external ownership uses boot provenance even when store and shell values match', () => { - assert.equal( - isKeySetupExternallyManaged({ - effectiveValue: 'same-value', - storedValue: 'same-value', - wasExternalAtBoot: true, - }), - true, - 'equal bytes cannot turn a shell/Keychain value into a file-owned value', - ); - assert.equal( - isKeySetupExternallyManaged({ - effectiveValue: 'file-value', - storedValue: 'file-value', - }), - false, - 'a value loaded only from the owned store remains editable', - ); - assert.equal( - isKeySetupExternallyManaged({ - effectiveValue: 'shell-value', - storedValue: 'stale-file-value', - }), - true, - 'a differing live value remains external', - ); - assert.equal( - isKeySetupExternallyManaged({ - effectiveValue: '', - storedValue: 'stale-file-value', - wasExternalAtBoot: true, - }), - false, - 'an absent live credential has no external owner', - ); + assert.equal(isKeySetupExternallyManaged({ + effectiveValue: 'same-value', + storedValue: 'same-value', + wasExternalAtBoot: true, + }), true, 'equal bytes cannot turn a shell/Keychain value into a file-owned value'); + assert.equal(isKeySetupExternallyManaged({ + effectiveValue: 'file-value', + storedValue: 'file-value', + }), false, 'a value loaded only from the owned store remains editable'); + assert.equal(isKeySetupExternallyManaged({ + effectiveValue: 'shell-value', + storedValue: 'stale-file-value', + }), true, 'a differing live value remains external'); + assert.equal(isKeySetupExternallyManaged({ + effectiveValue: '', + storedValue: 'stale-file-value', + wasExternalAtBoot: true, + }), false, 'an absent live credential has no external owner'); }); test('the status payload reports presence without any credential material', () => { @@ -83,35 +64,17 @@ test('the status payload reports presence without any credential material', () = // Secret missing: the OpenSky pair must read as NOT set. }; const status = keySetupStatus(env); - assert.equal( - status.total, - KEY_SETUP_KEYS.filter((key) => !key.hidden).length, - ); + assert.equal(status.total, KEY_SETUP_KEYS.filter((key) => !key.hidden).length); const google = status.keys.find((key) => key.id === 'google-maps'); assert.equal(google.set, true); const opensky = status.keys.find((key) => key.id === 'opensky'); assert.equal(opensky.set, false, 'half a credential pair is not configured'); const serialized = JSON.stringify(status); - assert.ok( - !serialized.includes('AIzaSyFakeFakeFakeFake1234'), - 'a value leaked into status', - ); - assert.ok( - !serialized.includes('client-id-abcdef'), - 'a value leaked into status', - ); - assert.ok( - !serialized.includes('1234'), - 'a credential suffix leaked into status', - ); - assert.ok( - !serialized.includes('abcdef'), - 'a credential suffix leaked into status', - ); - assert.ok( - !serialized.includes('tails'), - 'status must not expose a credential-tail field', - ); + assert.ok(!serialized.includes('AIzaSyFakeFakeFakeFake1234'), 'a value leaked into status'); + assert.ok(!serialized.includes('client-id-abcdef'), 'a value leaked into status'); + assert.ok(!serialized.includes('1234'), 'a credential suffix leaked into status'); + assert.ok(!serialized.includes('abcdef'), 'a credential suffix leaked into status'); + assert.ok(!serialized.includes('tails'), 'status must not expose a credential-tail field'); assert.equal(status.setCount, 1); }); @@ -122,57 +85,25 @@ test('whitespace-only env values do not count as configured', () => { test('subprocess success requires a clean zero exit', () => { assert.equal(commandCompletedSuccessfully({ status: 0, signal: null }), true); - assert.equal( - commandCompletedSuccessfully({ status: 1, signal: null }), - false, - ); - assert.equal( - commandCompletedSuccessfully({ status: 0, signal: 'SIGTERM' }), - false, - ); - assert.equal( - commandCompletedSuccessfully({ - status: 0, - signal: null, - error: new Error('spawn failed'), - }), - false, - ); + assert.equal(commandCompletedSuccessfully({ status: 1, signal: null }), false); + assert.equal(commandCompletedSuccessfully({ status: 0, signal: 'SIGTERM' }), false); + assert.equal(commandCompletedSuccessfully({ status: 0, signal: null, error: new Error('spawn failed') }), false); assert.equal(commandCompletedSuccessfully(null), false); }); test('Windows owner SID parsing reads only the structured user-SID CSV field', () => { const localUser = 'S-1-5-21-1111111111-2222222222-3333333333-1001'; const entraUser = 'S-1-12-1-1111111111-2222222222-3333333333-4444444444'; - assert.equal( - parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\r\n`), - localUser, - ); - assert.equal( - parseWindowsUserSid(`"AzureAD\\alice","${entraUser}"`), - entraUser, - ); + assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\r\n`), localUser); + assert.equal(parseWindowsUserSid(`"AzureAD\\alice","${entraUser}"`), entraUser); assert.equal( parseWindowsUserSid(`"${localUser}","S-1-5-32-545"`), null, 'an SID-looking account name must never be mistaken for the token SID', ); - assert.equal( - parseWindowsUserSid('"WORKSTATION\\alice","S-1-5-32-545"'), - null, - 'broad group SID refused', - ); - assert.equal( - parseWindowsUserSid( - `"WORKSTATION\\alice","${localUser}"\n"extra","${localUser}"`, - ), - null, - ); - assert.equal( - parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}`), - null, - 'unterminated CSV refused', - ); + assert.equal(parseWindowsUserSid('"WORKSTATION\\alice","S-1-5-32-545"'), null, 'broad group SID refused'); + assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\n"extra","${localUser}"`), null); + assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}`), null, 'unterminated CSV refused'); }); test('validation accepts every registry env var and only those', () => { @@ -182,16 +113,8 @@ test('validation accepts every registry env var and only those', () => { assert.equal(verdict.ok, true, `${name} should validate`); assert.equal(verdict.updates[name], 'valid-value-123'); } - assert.equal( - validateKeySetupUpdates({ PATH: '/usr/bin' }).ok, - false, - 'PATH must be refused', - ); - assert.equal( - validateKeySetupUpdates({ NODE_OPTIONS: '--x' }).ok, - false, - 'NODE_OPTIONS must be refused', - ); + assert.equal(validateKeySetupUpdates({ PATH: '/usr/bin' }).ok, false, 'PATH must be refused'); + assert.equal(validateKeySetupUpdates({ NODE_OPTIONS: '--x' }).ok, false, 'NODE_OPTIONS must be refused'); }); test('validation trims, and refuses empties, newlines, spaces, and oversize values', () => { @@ -200,25 +123,11 @@ test('validation trims, and refuses empties, newlines, spaces, and oversize valu assert.equal(trimmed.updates.OPENAI_API_KEY, 'sk-abc123'); assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false); assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }).ok, false); + assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a\nb' }).ok, false, 'newline injection'); + assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a b' }).ok, false, 'inner space'); + assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'kéy' }).ok, false, 'non-ASCII'); assert.equal( - validateKeySetupUpdates({ OPENAI_API_KEY: 'a\nb' }).ok, - false, - 'newline injection', - ); - assert.equal( - validateKeySetupUpdates({ OPENAI_API_KEY: 'a b' }).ok, - false, - 'inner space', - ); - assert.equal( - validateKeySetupUpdates({ OPENAI_API_KEY: 'kéy' }).ok, - false, - 'non-ASCII', - ); - assert.equal( - validateKeySetupUpdates({ - OPENAI_API_KEY: 'x'.repeat(KEY_SETUP_VALUE_LIMIT + 1), - }).ok, + validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(KEY_SETUP_VALUE_LIMIT + 1) }).ok, false, ); assert.equal(validateKeySetupUpdates(null).ok, false); @@ -236,16 +145,13 @@ test('upsert replaces the last active assignment in place', () => { '', ].join('\n'); const next = upsertDotenvValues(text, { OPENAI_API_KEY: 'new-key' }); - assert.equal( - next, - [ - '# comment stays', - 'OPENAI_API_KEY=old-one', - 'PORT=4173', - 'OPENAI_API_KEY=new-key', - '', - ].join('\n'), - ); + assert.equal(next, [ + '# comment stays', + 'OPENAI_API_KEY=old-one', + 'PORT=4173', + 'OPENAI_API_KEY=new-key', + '', + ].join('\n')); }); test('upsert uncomments a commented assignment in place, keeping file shape', () => { @@ -256,49 +162,36 @@ test('upsert uncomments a commented assignment in place, keeping file shape', () 'PORT=4173', ].join('\n'); const next = upsertDotenvValues(text, { FIRMS_MAP_KEY: 'firms-123' }); - assert.equal( - next, - [ - '# Optional: NASA FIRMS live active fires.', - 'FIRMS_MAP_KEY=firms-123', - '', - 'PORT=4173', - ].join('\n') + '\n', - ); + assert.equal(next, [ + '# Optional: NASA FIRMS live active fires.', + 'FIRMS_MAP_KEY=firms-123', + '', + 'PORT=4173', + ].join('\n') + '\n'); }); test('upsert appends unknown keys under one shared header, once', () => { const first = upsertDotenvValues('PORT=4173\n', { OPENAI_API_KEY: 'sk-1' }); - assert.equal( - first, - ['PORT=4173', '', KEY_SETUP_APPEND_HEADER, 'OPENAI_API_KEY=sk-1'].join( - '\n', - ) + '\n', - ); + assert.equal(first, [ + 'PORT=4173', + '', + KEY_SETUP_APPEND_HEADER, + 'OPENAI_API_KEY=sk-1', + ].join('\n') + '\n'); const second = upsertDotenvValues(first, { FIRMS_MAP_KEY: 'f-2' }); - assert.equal( - second, - [ - 'PORT=4173', - '', - KEY_SETUP_APPEND_HEADER, - 'OPENAI_API_KEY=sk-1', - 'FIRMS_MAP_KEY=f-2', - ].join('\n') + '\n', - ); - assert.equal( - second.split(KEY_SETUP_APPEND_HEADER).length, - 2, - 'header written once', - ); + assert.equal(second, [ + 'PORT=4173', + '', + KEY_SETUP_APPEND_HEADER, + 'OPENAI_API_KEY=sk-1', + 'FIRMS_MAP_KEY=f-2', + ].join('\n') + '\n'); + assert.equal(second.split(KEY_SETUP_APPEND_HEADER).length, 2, 'header written once'); }); test('upsert births a well-formed file from nothing', () => { const next = upsertDotenvValues('', { GOOGLE_MAPS_API_KEY: 'AIza-x' }); - assert.equal( - next, - `${KEY_SETUP_APPEND_HEADER}\nGOOGLE_MAPS_API_KEY=AIza-x\n`, - ); + assert.equal(next, `${KEY_SETUP_APPEND_HEADER}\nGOOGLE_MAPS_API_KEY=AIza-x\n`); }); test('upsert handles export-prefixed lines and never touches lookalike keys', () => { @@ -315,21 +208,15 @@ test('upsert handles export-prefixed lines and never touches lookalike keys', () }); test('upsert is idempotent for a repeated save', () => { - const once = upsertDotenvValues('', { - OPENAI_API_KEY: 'sk-1', - FIRMS_MAP_KEY: 'f-1', - }); - const twice = upsertDotenvValues(once, { - OPENAI_API_KEY: 'sk-1', - FIRMS_MAP_KEY: 'f-1', - }); + const once = upsertDotenvValues('', { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' }); + const twice = upsertDotenvValues(once, { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' }); assert.equal(once, twice); }); test('a real .env.example round-trip: the curated file keeps its shape', () => { // A representative slice of the shipped .env.example. const example = [ - "# God's Eye View — environment variables", + '# God\'s Eye View — environment variables', 'GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here', '', '# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.', @@ -346,16 +233,8 @@ test('a real .env.example round-trip: the curated file keeps its shape', () => { const lines = next.split('\n'); assert.equal(lines[1], 'GOOGLE_MAPS_API_KEY=AIza-real'); assert.equal(lines[4], 'OPENAI_API_KEY=sk-real'); - assert.equal( - lines[5], - 'OPENAI_REALTIME_MODEL=gpt-realtime-2', - 'sibling key untouched', - ); - assert.equal( - lines[7], - 'TOMTOM_API_KEY=tt-real', - 'commented key uncommented in place', - ); + assert.equal(lines[5], 'OPENAI_REALTIME_MODEL=gpt-realtime-2', 'sibling key untouched'); + assert.equal(lines[7], 'TOMTOM_API_KEY=tt-real', 'commented key uncommented in place'); }); test('the admission gate refuses every non-local shape, one assertion per refusal', async () => { @@ -368,111 +247,29 @@ test('the admission gate refuses every non-local shape, one assertion per refusa contentType: 'application/json', env: {}, }; - assert.equal( - admitKeySetupRequest(local).ok, - true, - 'the honest local request is admitted', - ); - assert.equal( - admitKeySetupRequest({ ...local, method: 'GET', contentType: undefined }) - .ok, - true, - 'local GET needs no content type', - ); - assert.equal( - admitKeySetupRequest({ ...local, origin: undefined }).ok, - false, - 'POST without Origin is refused', - ); - assert.equal( - admitKeySetupRequest({ - ...local, - method: 'GET', - origin: undefined, - contentType: undefined, - }).ok, - true, - 'local GET may omit Origin', - ); - assert.equal( - admitKeySetupRequest({ - ...local, - remoteAddress: '::ffff:127.0.0.1', - hostHeader: '[::1]:4173', - origin: 'http://[::1]:4173', - }).ok, - true, - 'IPv6 loopback forms are local', - ); + assert.equal(admitKeySetupRequest(local).ok, true, 'the honest local request is admitted'); + assert.equal(admitKeySetupRequest({ ...local, method: 'GET', contentType: undefined }).ok, true, 'local GET needs no content type'); + assert.equal(admitKeySetupRequest({ ...local, origin: undefined }).ok, false, 'POST without Origin is refused'); + assert.equal(admitKeySetupRequest({ ...local, method: 'GET', origin: undefined, contentType: undefined }).ok, true, 'local GET may omit Origin'); + assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '::ffff:127.0.0.1', hostHeader: '[::1]:4173', origin: 'http://[::1]:4173' }).ok, true, 'IPv6 loopback forms are local'); // Tunnel/LAN sharing of any kind removes the surface outright — tunnel // traffic arrives FROM loopback, so no socket check can carry this boundary. - assert.equal( - admitKeySetupRequest({ - ...local, - env: { PINOKIO_SHARE_CLOUDFLARE: 'true' }, - }).ok, - false, - 'sharing disables the surface', - ); - assert.equal( - admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, - false, - 'LAN sharing disables the surface', - ); + assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_CLOUDFLARE: 'true' } }).ok, false, 'sharing disables the surface'); + assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface'); // A LAN peer reaching a wide-bound server. - assert.equal( - admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, - false, - 'non-loopback socket refused', - ); + assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused'); // Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket. - assert.equal( - admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, - false, - 'foreign Host refused', - ); - assert.equal( - admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, - false, - 'non-localhost hostnames refused', - ); - assert.equal( - admitKeySetupRequest({ ...local, hostHeader: '' }).ok, - false, - 'missing Host refused', - ); - assert.equal( - admitKeySetupRequest({ ...local, hostHeader: '[::1].evil:4173' }).ok, - false, - 'malformed bracketed Host refused', - ); + assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused'); + assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused'); + assert.equal(admitKeySetupRequest({ ...local, hostHeader: '' }).ok, false, 'missing Host refused'); + assert.equal(admitKeySetupRequest({ ...local, hostHeader: '[::1].evil:4173' }).ok, false, 'malformed bracketed Host refused'); // A hostile web page POSTing at localhost carries its own Origin. - assert.equal( - admitKeySetupRequest({ ...local, origin: 'https://evil.example' }).ok, - false, - 'cross-origin refused', - ); - assert.equal( - admitKeySetupRequest({ ...local, origin: 'not a url' }).ok, - false, - 'unparseable Origin refused', - ); - assert.equal( - admitKeySetupRequest({ ...local, origin: 'http://localhost:4174' }).ok, - false, - 'cross-port Origin refused', - ); - assert.equal( - admitKeySetupRequest({ ...local, origin: 'https://localhost:4173' }).ok, - false, - 'cross-scheme Origin refused', - ); - assert.equal( - admitKeySetupRequest({ ...local, origin: 'http://127.0.0.1:4173' }).ok, - false, - 'different loopback host Origin refused', - ); + assert.equal(admitKeySetupRequest({ ...local, origin: 'https://evil.example' }).ok, false, 'cross-origin refused'); + assert.equal(admitKeySetupRequest({ ...local, origin: 'not a url' }).ok, false, 'unparseable Origin refused'); + assert.equal(admitKeySetupRequest({ ...local, origin: 'http://localhost:4174' }).ok, false, 'cross-port Origin refused'); + assert.equal(admitKeySetupRequest({ ...local, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme Origin refused'); + assert.equal(admitKeySetupRequest({ ...local, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host Origin refused'); // A simple-request POST (no JSON content type) is the CSRF write shape. const noJson = admitKeySetupRequest({ ...local, contentType: 'text/plain' }); assert.equal(noJson.ok, false, 'non-JSON POST refused'); @@ -483,16 +280,8 @@ test('a null value validates as a removal; an empty string still does not', () = const removal = validateKeySetupUpdates({ OPENAI_API_KEY: null }); assert.equal(removal.ok, true); assert.equal(removal.updates.OPENAI_API_KEY, null); - assert.equal( - validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, - false, - 'empty is a mistake, not a removal', - ); - assert.equal( - validateKeySetupUpdates({ PATH: null }).ok, - false, - 'removal is registry-bound too', - ); + assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false, 'empty is a mistake, not a removal'); + assert.equal(validateKeySetupUpdates({ PATH: null }).ok, false, 'removal is registry-bound too'); }); test('removal comments the assignment back out, returning the file to template shape', () => { @@ -503,11 +292,7 @@ test('removal comments the assignment back out, returning the file to template s ].join('\n'); const next = upsertDotenvValues(text, { OPENAI_API_KEY: null }); const lines = next.split('\n'); - assert.equal( - lines[1], - '# OPENAI_API_KEY=', - 'active line commented out, not deleted', - ); + assert.equal(lines[1], '# OPENAI_API_KEY=', 'active line commented out, not deleted'); assert.equal(lines[2], 'PORT=4173', 'neighbors untouched'); // Removing a key with no active assignment changes nothing. assert.equal(upsertDotenvValues(next, { FIRMS_MAP_KEY: null }), next); @@ -519,148 +304,62 @@ test('removal comments the assignment back out, returning the file to template s test('the sharing gate treats a real PINOKIO_SHARE_VAR as sharing, but not the empty/sentinel normal state', async () => { const { admitKeySetupRequest } = await import('./keySetupCore.mjs'); const base = { - method: 'POST', - remoteAddress: '127.0.0.1', - hostHeader: 'localhost:4173', - origin: 'http://localhost:4173', - contentType: 'application/json', + method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', contentType: 'application/json', }; // The ordinary launch states: unset, empty, or the explicit disabled sentinel. - assert.equal( - admitKeySetupRequest({ ...base, env: {} }).ok, - true, - 'unset SHARE_VAR is normal', - ); - assert.equal( - admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '' } }).ok, - true, - 'empty SHARE_VAR is normal', - ); - assert.equal( - admitKeySetupRequest({ - ...base, - env: { PINOKIO_SHARE_VAR: '__gev_sharing_disabled__' }, - }).ok, - true, - 'the disabled sentinel is normal', - ); + assert.equal(admitKeySetupRequest({ ...base, env: {} }).ok, true, 'unset SHARE_VAR is normal'); + assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '' } }).ok, true, 'empty SHARE_VAR is normal'); + assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '__gev_sharing_disabled__' } }).ok, true, 'the disabled sentinel is normal'); // A real tunnel var disables the surface. - assert.equal( - admitKeySetupRequest({ - ...base, - env: { PINOKIO_SHARE_VAR: 'MY_TUNNEL_TOKEN' }, - }).ok, - false, - 'a real share var is sharing', - ); + assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: 'MY_TUNNEL_TOKEN' } }).ok, false, 'a real share var is sharing'); }); test('the gate refuses proxied requests even from a loopback socket with local headers', async () => { const { admitKeySetupRequest } = await import('./keySetupCore.mjs'); const base = { - method: 'POST', - remoteAddress: '127.0.0.1', - hostHeader: 'localhost:4173', - origin: 'http://localhost:4173', - contentType: 'application/json', - env: {}, + method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', contentType: 'application/json', env: {}, }; - assert.equal( - admitKeySetupRequest(base).ok, - true, - 'no proxy headers → admitted', - ); - for (const header of [ - 'x-forwarded-for', - 'forwarded', - 'via', - 'cf-connecting-ip', - 'cf-ray', - 'x-real-ip', - 'x-forwarded-host', - 'x-forwarded-port', - 'x-forwarded-proto', - ]) { + assert.equal(admitKeySetupRequest(base).ok, true, 'no proxy headers → admitted'); + for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) { assert.equal( - admitKeySetupRequest({ ...base, proxyHeaders: { [header]: 'anything' } }) - .ok, + admitKeySetupRequest({ ...base, proxyHeaders: { [header]: 'anything' } }).ok, false, `${header} present → refused`, ); } // An empty forwarding header is not a proxy signal. - assert.equal( - admitKeySetupRequest({ ...base, proxyHeaders: { 'x-forwarded-for': '' } }) - .ok, - true, - ); + assert.equal(admitKeySetupRequest({ ...base, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true); }); test('validation rejects dotenv metacharacters that would round-trip wrong', () => { for (const bad of ['abc#def', 'ab"cd', "ab'cd", 'ab$cd', 'ab\\cd', 'ab`cd']) { - assert.equal( - validateKeySetupUpdates({ OPENAI_API_KEY: bad }).ok, - false, - `${JSON.stringify(bad)} refused`, - ); + assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: bad }).ok, false, `${JSON.stringify(bad)} refused`); } // Real key alphabets still pass: base64url, JWT dots, hex, plus/slash. - for (const good of [ - 'sk-AbC0-9_x', - 'eyJhbGc.eyJzdWI.QWxpY2U', - 'a1b2c3d4e5f6', - 'AB+cd/ef=', - ]) { - assert.equal( - validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok, - true, - `${good} accepted`, - ); + for (const good of ['sk-AbC0-9_x', 'eyJhbGc.eyJzdWI.QWxpY2U', 'a1b2c3d4e5f6', 'AB+cd/ef=']) { + assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok, true, `${good} accepted`); } }); test('server Google key remains supported without appearing in setup or its missing count', () => { const secret = 'server-key-fixture'; - assert.deepEqual( - validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: secret }), - { - ok: true, - updates: { GOOGLE_MAPS_SERVER_API_KEY: secret }, - }, - ); - assert.equal( - validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: null }).ok, - true, - ); + assert.deepEqual(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: secret }), { + ok: true, updates: { GOOGLE_MAPS_SERVER_API_KEY: secret }, + }); + assert.equal(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: null }).ok, true); const status = keySetupStatus({ GOOGLE_MAPS_SERVER_API_KEY: secret }); assert.deepEqual(status, keySetupStatus({})); - assert.equal( - status.keys.some((key) => key.id === 'google-maps-server'), - false, - ); + assert.equal(status.keys.some((key) => key.id === 'google-maps-server'), false); assert.equal(keySetupRequirement('google-maps-server'), ''); - assert.equal( - status.keys.find((key) => key.id === 'google-maps').title, - 'GOOGLE MAPS', - ); - const allVisibleConfigured = Object.fromEntries( - status.keys.flatMap((key) => - key.envVars.map((name) => [name, 'configured-fixture']), - ), - ); + assert.equal(status.keys.find((key) => key.id === 'google-maps').title, 'GOOGLE MAPS'); + const allVisibleConfigured = Object.fromEntries(status.keys.flatMap((key) => + key.envVars.map((name) => [name, 'configured-fixture']), + )); const complete = keySetupStatus(allVisibleConfigured); - assert.equal( - complete.setCount, - complete.total, - 'an absent server key must not leave setup incomplete', - ); - assert.deepEqual( - complete, - keySetupStatus({ - ...allVisibleConfigured, - GOOGLE_MAPS_SERVER_API_KEY: secret, - }), - ); + assert.equal(complete.setCount, complete.total, 'an absent server key must not leave setup incomplete'); + assert.deepEqual(complete, keySetupStatus({ ...allVisibleConfigured, GOOGLE_MAPS_SERVER_API_KEY: secret })); assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY')); assert.ok(!JSON.stringify(status).includes(secret)); }); diff --git a/src/layers/streetLevel/glyphs.js b/src/layers/streetLevel/glyphs.js index d25c22016..233a7bdde 100644 --- a/src/layers/streetLevel/glyphs.js +++ b/src/layers/streetLevel/glyphs.js @@ -65,10 +65,7 @@ export function imageConeGlyph({ }); } -/** - * Street View-style marker: a soft field-of-view cone in `color` from a - * white-ringed dot, as Google Maps shows where its panorama looks. - */ +/** Street View-style marker: a field-of-view cone from a white-ringed dot. */ export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) { return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => { wedge(ctx, c, size * 0.48, 0.62, color, 0.38); diff --git a/src/layers/streetLevel/groundClick.js b/src/layers/streetLevel/groundClick.js index 0f69f74d8..fef6ddb47 100644 --- a/src/layers/streetLevel/groundClick.js +++ b/src/layers/streetLevel/groundClick.js @@ -11,11 +11,9 @@ export function groundClickHeightOk(viewer, groundAt) { } /** - * A click on the map itself (nothing picked, or Google 3D tile content) at - * street zoom opens the nearest image of the active, available providers - * that have no coverage to click (`groundClick`, Google Street View). The - * camera stays put: the user chose the spot. - * The height is measured at the click, not read from the cached flag. + * A click on the map itself at street zoom opens the nearest image of the + * active `groundClick` providers, without moving the camera. The height is + * measured at the click, not read from the cached flag. * @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options * @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise|false} */ diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 817b2db5c..eed4230ab 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -128,11 +128,13 @@ export function createStreetLevelLayer({ }); } + // One source at a time: the first provider that defaults on. + const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false); for (const def of definitionsFrozen) { const entry = { def, instance: null, - on: def.defaultOn !== false, + on: def === startsOn, status: null, /** The error this provider last reported, until it withdraws it. */ reportedError: null, @@ -153,19 +155,13 @@ export function createStreetLevelLayer({ notify(); } - /** - * A provider that names a map stack (Google Street View: Google's terms - * forbid its imagery beside non-Google maps) is usable only on that stack. - */ + /** Usable only on its map stack (Google's terms: Street View on Google 3D only). */ function providerAvailable(entry) { const required = entry.def.requiresMapStack; return !required || mapStack?.getActiveId?.() === required; } - /** - * A provider appears only on its map stack: leaving it closes the image it - * shows, releases its viewer and takes its credit off the globe. - */ + /** Leaving a provider's map stack closes its image, viewer and credit. */ function syncProviderAvailability() { const owner = state.providers.get(state.street.providerId); if (state.street.open && owner && !providerAvailable(owner)) @@ -276,10 +272,9 @@ export function createStreetLevelLayer({ if (!state.viewer) return; entry.instance.activate(state.viewer); if (!entry.status) refreshStatus(entry); - // Off its map stack a provider shows nothing: no credit, no viewer load. if (!providerAvailable(entry)) return; parts.credits.show(state.viewer, entry.def); - // Checked again when it runs: the map may have changed meanwhile. + // The map may have changed by then. whenIdle(() => { if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]); }, 1500); @@ -299,10 +294,7 @@ export function createStreetLevelLayer({ nearestLookup = null; } - /** - * The ground under the middle of the screen; null when the middle is sky; - * undefined when nothing can aim (no scenePick, or a canvas with no size). - */ + /** Ground under the screen centre; null for sky, undefined when it cannot aim. */ function screenCentreGround() { const canvas = state.viewer?.scene?.canvas; const pick = state.services.scenePick?.pickGroundPosition; @@ -326,6 +318,7 @@ export function createStreetLevelLayer({ abortNearest(); const entry = state.providers.get(providerId); if (entry && !providerAvailable(entry)) { + state.street.loading = false; state.street.error = unavailableReason(entry); notify(); return Promise.resolve(false); @@ -337,20 +330,44 @@ export function createStreetLevelLayer({ return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`; } + /** One imagery source at a time: lighting a provider darkens the others. */ function setProviderEnabled(providerId, on) { const entry = state.providers.get(providerId); if (!entry) return false; const next = on !== false; - if (entry.on === next) return true; - entry.on = next; - if (state.enabled) { - if (next) activate(entry); - else deactivate(entry); - } + if (next) + for (const other of state.providers.values()) + if (other !== entry && other.on) switchProvider(other, false); + switchProvider(entry, next); notify(); return true; } + function switchProvider(entry, on) { + if (entry.on === on) return; + entry.on = on; + if (!state.enabled) return; + if (on) activate(entry); + else deactivate(entry); + } + + /** + * The one provider params leave lit: of those they switch on, the one + * already on (reapplying never flips), else the first; with none, the lit + * one unless they switch it off. + */ + function litByParams(switches) { + const asked = [...state.providers.keys()].filter( + (id) => switches.get(id) === true, + ); + const current = [...state.providers] + .filter(([, entry]) => entry.on) + .map(([id]) => id); + if (asked.length) + return asked.find((id) => current.includes(id)) ?? asked[0]; + return current.find((id) => switches.get(id) !== false) ?? null; + } + function setCoverageFilter(next) { const filter = normalizeFilter(next, state.filter); if (sameFilter(filter, state.filter)) return; @@ -390,7 +407,6 @@ export function createStreetLevelLayer({ hint: hintFor(entry, stats), error: stats.error || null, color: entry.def.colors.coverage, - /** No coverage to click: it opens where the user points. */ groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; @@ -418,7 +434,7 @@ export function createStreetLevelLayer({ function getUIState() { const { host, ...street } = state.street; - return composeUIState({ + const ui = composeUIState({ enabled: state.enabled, groundClickReady: state.groundClickReady, filter: state.filter, @@ -427,6 +443,13 @@ export function createStreetLevelLayer({ sequence: sequenceSnapshot(), surface: state.surface, }); + // The lit source cannot run on this map: say so, not a blank panel. + const stranded = [...state.providers.values()].find( + (entry) => entry.on && !providerAvailable(entry), + ); + if (stranded && !ui.providers.some((p) => p.on)) + ui.coverage.hint = unavailableReason(stranded); + return ui; } const layer = { @@ -533,7 +556,6 @@ export function createStreetLevelLayer({ count: coverage.count, loading: coverage.loading, keyRequired: coverage.keyRequired, - /** Switched-off sources that would draw with a key already set. */ keyAlternatives: keyAlternatives(snapshots), error: coverage.keyRequired ? coverage.keyRejected @@ -583,7 +605,11 @@ export function createStreetLevelLayer({ providerIds: state.providers.keys(), filter: state.filter, }); - for (const [id, on] of decoded.providers) setProviderEnabled(id, on); + const lit = litByParams(decoded.providers); + for (const [id, entry] of state.providers) + if (id !== lit) switchProvider(entry, false); + if (lit) switchProvider(state.providers.get(lit), true); + notify(); setCoverageFilter(decoded.filter); return true; }, @@ -612,10 +638,9 @@ export function createStreetLevelLayer({ setCoverageFilter, openImage, /** - * Open the nearest image any active provider (or only `providerIds`) - * has around a point; without one, around the view centre, or with - * `aim: 'screen-centre'` the ground under the middle of the screen. An - * aim at the sky opens nothing rather than somewhere else. + * Open the nearest image of the active providers (or `providerIds`) near + * a point, else the view centre, or with `aim: 'screen-centre'` the ground + * under the screen centre (sky: nothing). */ async openNearest( point, @@ -663,14 +688,16 @@ export function createStreetLevelLayer({ } if (signal.aborted) return false; if (!state.enabled) break; - // This provider was switched off while it looked: try the next. - if (!imageId || !entry.on) continue; + // Switched off, or its map left, while it looked: try the next. + if (!imageId || !entry.on || !providerAvailable(entry)) continue; nearestLookup = null; return openImage(entry.def.id, imageId, { frame }); } if (nearestLookup === lookup) nearestLookup = null; // Switched off while it looked: nothing to open, nothing to report. - const stillOn = activeEntries().some(asked); + const stillOn = entries.some( + (entry) => entry.on && providerAvailable(entry), + ); if (state.enabled && stillOn) state.street.error = lastError?.message || diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 38ee76498..5ae19c2d0 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -532,7 +532,105 @@ test('Street View starts off, and the layer row names the key of the providers t assert.equal(google().on, false); assert.equal(layer.requiresKeyId, 'mapillary'); layer.setProviderEnabled('google', true); - assert.equal(layer.requiresKeyId, null, 'two keys: each chip names its own'); + assert.equal(layer.requiresKeyId, 'google-maps', 'Street View alone'); +}); + +test('the layer starts with one source lit: the first that defaults on', async (t) => { + const { layer } = await enabledLayer(t, [ + fakeProvider(), + fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }), + ]); + assert.deepEqual( + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id), + ['mapillary'], + ); +}); + +test('leaving Google 3D during a Street View lookup ends it without an error or a stuck spinner', async (t) => { + const { layer, stack, fake } = await withStreetView(t); + layer.setProviderEnabled('google', true); + let answer; + fake.library.StreetViewService.prototype.getPanorama = () => + new Promise((resolve) => { + answer = resolve; + }); + const opening = layer.openNearest({ lat: 38.5816, lon: -121.4944 }); + await settle(); + assert.equal(layer.getUIState().street.loading, true); + stack.switchTo('bing'); + answer({ data: { location: { pano: 'pano-a' }, imageDate: '2024-05' } }); + assert.equal(await opening, false); + const { street } = layer.getUIState(); + assert.equal(street.loading, false, 'OPEN STREET VIEW is usable again'); + assert.equal(street.error, null); + assert.equal(street.open, false); +}); + +test('a source swap during a lookup reports no false "no imagery"', async (t) => { + let answer; + const mapillary = fakeProvider({ + nearestImage: () => + new Promise((resolve) => { + answer = resolve; + }), + }); + const other = fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }); + const { layer } = await enabledLayer(t, [mapillary, other]); + const opening = layer.openNearest({ lat: 1, lon: 2 }); + layer.setProviderEnabled('panoramax', true); + answer('img-1'); + assert.equal(await opening, false); + assert.equal(layer.getUIState().street.error, null); + assert.equal(layer.getUIState().street.loading, false); +}); + +test('Street View lit off Google 3D says why the panel is empty', async (t) => { + const { layer, stack } = await withStreetView(t); + layer.setProviderEnabled('google', true); + stack.switchTo('esri-imagery'); + assert.match( + layer.getUIState().coverage.hint, + /Google Street View needs the Google 3D map/, + ); + layer.setProviderEnabled('mapillary', true); + assert.doesNotMatch(layer.getUIState().coverage.hint, /Google 3D/); +}); + +test('one imagery source at a time: lighting one darkens the other, either way', async (t) => { + const { layer } = await withStreetView(t); + const lit = () => + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id); + assert.deepEqual(lit(), ['mapillary'], 'Mapillary by default: it is free'); + layer.setProviderEnabled('google', true); + assert.deepEqual(lit(), ['google']); + assert.equal(layer.getParams().mapillary, false); + layer.setProviderEnabled('mapillary', true); + assert.deepEqual(lit(), ['mapillary']); + assert.equal(layer.getParams().google, false); + // Params that light one switch to it; reapplying them changes nothing. + layer.setParams({ google: true }); + assert.deepEqual(lit(), ['google']); + layer.setParams({ google: true }); + assert.deepEqual(lit(), ['google']); + // Params lighting both keep the one already on, however often applied. + for (let i = 0; i < 2; i++) { + layer.setParams({ mapillary: true, providerSwitches: 'google-1' }); + assert.deepEqual(lit(), ['google']); + } + layer.setParams({ mapillary: true, google: false }); + assert.deepEqual(lit(), ['mapillary']); + // Filter-only params leave the source alone. + layer.setParams({ pano: 'pano' }); + assert.deepEqual(lit(), ['mapillary']); + // Darkening the only lit one leaves none: the panel turns the layer off. + layer.setProviderEnabled('mapillary', false); + assert.deepEqual(lit(), []); }); test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => { diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index b2fdaa6f9..047eebcc2 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -6,7 +6,6 @@ import { createHorizonCull } from './view.js'; const SPRITE_ID = 'street-level:marker'; -/** The billboard look for a marker style: GEV's pin, or a provider's view cone. */ function markerLook(style) { if (style?.kind === 'view-cone') { const color = style.color || COLORS.position; @@ -75,11 +74,7 @@ export function createMarker({ state, parts }) { set(last.position, last.bearing, last.style); }); - /** - * Move (or create) the marker; a null position removes it. `style` is the - * open image's provider marker ({kind: 'view-cone', color}); GEV's pin - * without one. - */ + /** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */ function set(position, bearing, style = null) { const collection = state.marker.collection; if (!collection) return; @@ -102,7 +97,6 @@ export function createMarker({ state, parts }) { ? Cesium.HeightReference.CLAMP_TO_GROUND : Cesium.HeightReference.NONE; const look = markerLook(style); - // Another provider's photo: the marker changes its look. if (state.marker.billboard && state.marker.look !== look.key) { collection.remove?.(state.marker.billboard); state.marker.billboard = null; diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 4a25d9c6d..6f310274f 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -37,12 +37,8 @@ export const FOLLOW_MAP_STACK_ID = 'photoreal'; /** Eye height above the sampled ground when the globe camera follows the viewer. */ export const FOLLOW_EYE_HEIGHT_M = 2.4; -/** - * A ground click opens the nearest image of a provider without coverage lines - * (Google Street View) only from this close: from higher up it is a misclick. - */ +/** Above this, a ground click is a misclick, not a street. */ export const GROUND_CLICK_MAX_HEIGHT_M = 3_000; -/** OPEN STREET VIEW aimed at the sky: it opens what the user aims at, or nothing. */ export const AIM_AT_GROUND_MESSAGE = 'Aim the middle of the view at a street to look for imagery there'; diff --git a/src/layers/streetLevel/providers/google/index.js b/src/layers/streetLevel/providers/google/index.js index 5beaaed60..54fcc02f5 100644 --- a/src/layers/streetLevel/providers/google/index.js +++ b/src/layers/streetLevel/providers/google/index.js @@ -16,11 +16,7 @@ import { PICK_PREFIX, } from './policy.js'; -/** - * `promise`, unless `ms` pass first (NO_ANSWER_MESSAGE), `signal` aborts, or - * Google refuses the key: a lookup Google never answers must not keep the - * panel loading. - */ +/** `promise`, unless it times out, `signal` aborts or Google refuses the key. */ function settleWithin(promise, { ms, signal, loader }) { return new Promise((resolve, reject) => { const timer = setTimeout( @@ -46,10 +42,8 @@ function settleWithin(promise, { ms, signal, loader }) { } /** - * Google Street View Street Level provider: Google's own panorama viewer and - * nearest-panorama lookups. Google offers no coverage layer for other maps, - * so it draws none; a click on a street opens the panorama there. - * `getApiKey` returns the browser Maps key; `loader` is ./mapsLoader.js or a stand-in. + * Google Street View provider: Google's panorama viewer and nearest-panorama + * lookups. It draws no coverage; a click on a street opens the panorama. * @returns {import('../../registry.js').StreetLevelProvider} */ export function createGoogleProvider({ @@ -70,19 +64,16 @@ export function createGoogleProvider({ groundClick: true, // Google's terms forbid Street View beside non-Google maps. requiresMapStack: FOLLOW_MAP_STACK_ID, - // Every panorama is billed to the Maps key: the user lights the chip. + // Every panorama is billed to the Maps key. defaultOn: false, - // Every panorama is 360°, and with no coverage drawn there is nothing - // for a date filter to thin: the user picks the place by clicking it. + // All 360°, and no coverage for a date filter to thin. filters: [], - // Google Maps' own look for where a panorama stands and looks. marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), create(context) { let keyRequired = !getApiKey(); let active = false; let service = null; - /** The one StreetViewService the lookups and the viewer share. */ async function getService() { const lib = await loader.importLibrary('streetView'); service ||= new lib.StreetViewService(); @@ -94,7 +85,7 @@ export function createGoogleProvider({ render: context.services?.render, }); const stopAuthWatch = loader.onAuthFailure(() => { - // Switched off since: its chip says so; no error over another photo. + // Switched off: its chip says so. if (context.isActive?.()) context.actions.reportError(KEY_REJECTED_MESSAGE); context.notify(); diff --git a/src/layers/streetLevel/providers/google/mapsLoader.js b/src/layers/streetLevel/providers/google/mapsLoader.js index 1d9f0555d..dd66e1c92 100644 --- a/src/layers/streetLevel/providers/google/mapsLoader.js +++ b/src/layers/streetLevel/providers/google/mapsLoader.js @@ -1,16 +1,11 @@ /** - * Loads the Google Maps JavaScript API once per page, on first use, and hands - * out its libraries. Google calls `gm_authFailure` when it refuses the key - * (an unknown key, a referrer it does not allow, or the Maps JavaScript API - * not enabled for it); that reaches every `onAuthFailure` listener. + * Loads the Google Maps JavaScript API once per page, on first use. Google's + * `gm_authFailure` (key refused) reaches every `onAuthFailure` listener. */ const SCRIPT_ORIGIN = 'https://maps.googleapis.com'; const READY_CALLBACK = '__gevGoogleMapsReady'; -/** - * The page's one script load, kept on the page: every loader on it (two - * application instances, say) waits on the same script and callback. - */ +/** The page's one script load, shared by every loader on it. */ const PAGE_LOAD = Symbol.for('gev.googleMapsLoad'); /** @@ -25,7 +20,7 @@ export function createMapsLoader({ let authHandlerInstalled = false; const authListeners = new Set(); - /** Once per loader: a retried load must not wrap its own handler again. */ + /** Once per loader: a retry must not wrap its own handler. */ function installAuthHandler() { if (authHandlerInstalled) return; authHandlerInstalled = true; @@ -51,7 +46,6 @@ export function createMapsLoader({ if (global[PAGE_LOAD]) return global[PAGE_LOAD]; if (global.google?.maps?.importLibrary) return Promise.resolve(global.google.maps); - // Cleared when the script fails, so a retry (from any loader) can. const loading = new Promise((resolve, reject) => { global[READY_CALLBACK] = () => { delete global[READY_CALLBACK]; @@ -78,12 +72,10 @@ export function createMapsLoader({ } return { - /** A Maps library ('streetView', …), loading the API first. */ async importLibrary(name) { const maps = await load(); return maps.importLibrary(name); }, - /** Whether Google has refused the key on this page. */ authFailed: () => authFailed, onAuthFailure(listener) { authListeners.add(listener); diff --git a/src/layers/streetLevel/providers/google/policy.js b/src/layers/streetLevel/providers/google/policy.js index 580c01bbc..2f253ab96 100644 --- a/src/layers/streetLevel/providers/google/policy.js +++ b/src/layers/streetLevel/providers/google/policy.js @@ -2,14 +2,12 @@ import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; export { NEAREST_RADIUS_M } from '../../policy.js'; -/** Identity of the Google Street View street-level provider. */ export const GOOGLE_PROVIDER_ID = 'google'; export const GOOGLE_NAME = 'Google Street View'; export const GOOGLE_LABEL = 'GOOGLE STREET VIEW'; /** The browser Maps key; Street View also needs the Maps JavaScript API on it. */ export const GOOGLE_KEY_ID = 'google-maps'; -/** Every primitive id this provider creates starts with it. */ export const PICK_PREFIX = Object.freeze({ root: 'gsv:' }); export const COLORS = Object.freeze({ @@ -20,27 +18,20 @@ export const COLORS = Object.freeze({ export const GOOGLE_CREDIT_HTML = 'Street View imagery © Google'; -/** - * Shown when Google refuses the key (gm_authFailure): the Maps JavaScript API - * is not enabled for it, this site is not an allowed referrer, or billing is off. - */ +/** Shown when Google refuses the key (gm_authFailure). */ export const KEY_REJECTED_MESSAGE = 'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer'; -/** A nearest-panorama lookup that takes longer has failed. */ +/** Past these, a nearest lookup, an open or a details lookup has failed. */ export const NEAREST_TIMEOUT_MS = 10_000; -/** A panorama that has not shown by then has failed to open. */ export const OPEN_TIMEOUT_MS = 20_000; -/** A panorama-details lookup that takes longer has failed (and can retry). */ export const DETAILS_TIMEOUT_MS = 10_000; -/** Minimum pause after a failed panorama-details lookup before a view can retry. */ +/** Pause after a failed details lookup before a view event can retry. */ export const DETAILS_RETRY_COOLDOWN_MS = 5_000; -/** Google answered nothing: the network, or a key Google ignores. */ export const NO_ANSWER_MESSAGE = 'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY'; -/** Shown while the provider is on, in place of coverage lines it does not have. */ export const GROUND_CLICK_HINT = 'click a street to open Street View'; /** Deep link to a panorama on Google Maps (Maps URLs). */ diff --git a/src/layers/streetLevel/providers/google/viewer.js b/src/layers/streetLevel/providers/google/viewer.js index c04a3acbd..1c7fdfa34 100644 --- a/src/layers/streetLevel/providers/google/viewer.js +++ b/src/layers/streetLevel/providers/google/viewer.js @@ -10,18 +10,16 @@ import { streetViewUrl, } from './policy.js'; -/** Google's own controls, minus those the Street Level panel already has. */ const PANORAMA_OPTIONS = Object.freeze({ visible: false, - // The address goes in the panel caption: Google's card covers half a - // docked panorama. + // The caption has the address; Google's card covers a docked panorama. addressControl: false, linksControl: true, panControl: true, zoomControl: true, clickToGo: true, showRoadLabels: true, - // The panel has EXPAND and ×; Google's fullscreen would leave the modal. + // The panel has EXPAND and ×. fullscreenControl: false, enableCloseButton: false, motionTracking: false, @@ -29,10 +27,8 @@ const PANORAMA_OPTIONS = Object.freeze({ }); /** - * Viewer adapter over Google's StreetViewPanorama. It renders in its own - * element inside the core's host, and emits a provider-neutral pose for every - * panorama and view change, including steps along Google's own arrows. - * `getService` hands out the StreetViewService the provider shares. + * Viewer adapter over Google's StreetViewPanorama: emits a pose for every + * panorama and view change, including steps along Google's arrows. * @param {{loader: ReturnType, getService?: () => Promise, render?: object}} options * @returns {import('../../registry.js').ViewerAdapter & {remember: Function}} */ @@ -43,19 +39,13 @@ export function createGoogleViewer({ loader, getService, render } = {}) { let host = null; let service = null; let pendingOpen = null; - /** Settles the open waiting for its panorama, when a close overtakes it. */ let cancelShown = null; - /** Bumped by `unmount`, so a construction it overtook builds nothing. */ + /** Bumped by `unmount`: a construction it overtook builds nothing. */ let generation = 0; let subscriptions = []; - /** Panorama id → its {capturedAt, creator, title}. */ const described = new Map(); - /** - * Panorama id → its lookup in flight ({timer}). A late answer to a lookup - * no longer here (timed out, or unmounted) is dropped. - */ + /** Panorama id → its lookup in flight; a late answer to a dropped one is ignored. */ const lookingUp = new Map(); - /** Panorama id → earliest retry time after a failed details lookup. */ const retryAfter = new Map(); const listeners = new Set(); @@ -87,9 +77,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) { } /** - * Look up a panorama's date, photographer and address once; the pose - * already out is sent again with them. A lookup that fails or takes longer - * than DETAILS_TIMEOUT_MS lets view events retry once the cooldown expires. + * Look up a panorama's date, photographer and address once, then re-emit + * the pose. A failed or timed-out lookup can retry after the cooldown. */ async function describe(panoId) { if (described.has(panoId) || lookingUp.has(panoId)) return; @@ -110,7 +99,6 @@ export function createGoogleViewer({ loader, getService, render } = {}) { return service.getPanorama({ pano: panoId }); })().then(resolve, reject); }); - // Released by an unmount: the viewer it was for is gone. if (!current()) return; described.set(panoId, metaFrom(data)); retryAfter.delete(panoId); @@ -125,13 +113,11 @@ export function createGoogleViewer({ loader, getService, render } = {}) { publishPose(); } - /** Drop every lookup in flight: a remount looks again. */ function releaseLookups() { for (const lookup of lookingUp.values()) clearTimeout(lookup.timer); lookingUp.clear(); } - /** Emit the pose on screen now, dated once its lookup has answered. */ function publishPose() { const panoId = panorama?.getPano?.(); const latLng = panorama?.getPosition?.(); @@ -173,8 +159,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) { target.append(element); host = target; panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS); - // Not pano_changed: it can fire before the new panorama's data, with the - // old position. A step along an arrow moves the position. + // Not pano_changed: it can fire with the old panorama's position. subscriptions = ['position_changed', 'pov_changed'].map((name) => panorama.addListener(name, () => publishPose()), ); @@ -196,10 +181,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) { } /** - * Resolves once `panoId` shows: an OK status, or (when the status was - * already OK and does not change) the new position. Rejects when Google has - * no imagery for it, refuses the key (reported apart from the status), or - * does not answer. Google may show a newer id for the same place. + * Resolves once `panoId` shows (OK status, or a new position when the + * status stays OK); rejects on no imagery, a refused key or no answer. */ function shown(instance, panoId) { if (instance.getPano() === panoId && instance.getStatus() === 'OK') @@ -213,8 +196,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) { finish(reject, new Error(KEY_REJECTED_MESSAGE)), ); cancelShown = () => finish(resolve); - // Only a status event can fail the open: at a position event the - // status may still be the previous panorama's failure. + // At a position event the status may still be the last panorama's failure. const onStatus = () => { if (pendingOpen !== panoId) return finish(resolve); const status = instance.getStatus(); @@ -249,7 +231,6 @@ export function createGoogleViewer({ loader, getService, render } = {}) { await ensurePanorama(target); }, - /** Resolves once the panorama is on screen and its first pose was emitted. */ async open(imageId) { const panoId = String(imageId); if (!host) throw new Error('Street View is not mounted'); @@ -297,7 +278,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) { return () => listeners.delete(listener); }, - /** Keep a lookup's answer, so the panorama it found needs no second one. */ + /** Seed the details cache from a nearest lookup's answer. */ remember(panoId, data) { if (panoId && data) described.set(String(panoId), metaFrom(data)); }, diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index a057c3a82..a9ed18331 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,9 +1,7 @@ import { encodeIdSwitches } from '../../data/idSwitches.js'; -/** The imagery filters a provider can honour. */ export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); -/** The filters `provider` honours: both, unless it says otherwise. */ export function filtersOf(provider) { return provider?.filters ?? IMAGERY_FILTERS; } @@ -19,11 +17,11 @@ export function filtersOf(provider) { * @property {string} pickPrefix Every primitive id the provider creates starts with it. * @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS. * @property {{html: string}} credit On-globe attribution while the provider is active. - * @property {boolean} [groundClick] No coverage to click: a click on the ground at street zoom opens its nearest image. - * @property {string} [requiresMapStack] Usable only on this map stack (Google Street View: 'photoreal', per Google's terms). - * @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); a share link without its switch keeps it. - * @property {{kind: 'view-cone', color: string}} [marker] On-globe marker for its open image (default GEV's pin): Street View's blue field-of-view cone. - * @property {Array<'pano'|'since'>} [filters] The imagery filters it honours (default both); the panel hides a filter no lit provider honours. + * @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. + * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. + * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index b81226b30..4163d473f 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -15,8 +15,8 @@ import { COLORS } from './policy.js'; * @property {string} hint * @property {string|null} error * @property {string} color The source's one colour. - * @property {boolean} [groundClick] Draws no coverage: opens where the user clicks. - * @property {Array<'pano'|'since'>} [filters] Imagery filters it honours. + * @property {boolean} [groundClick] + * @property {Array<'pano'|'since'>} [filters] */ /** @@ -45,8 +45,7 @@ export function summarizeCoverage(providers) { /** * Switched-off providers that would draw with a key already set, offered - * while the switched-on ones lack theirs (Street View beside a Mapillary - * layer with no token). + * while the switched-on ones lack theirs. * @param {Array} providers * @returns {Array<{id: string, label: string, requiresKeyId: string|null}>} */ @@ -58,8 +57,7 @@ export function keyAlternatives(providers) { } /** - * Which filter controls mean anything: those a switched-on provider honours. - * With none switched on, both show (they apply once one is). + * The filter controls a switched-on provider honours (both when none is on). * @param {Array} providers */ export function filterControls(providers) { @@ -72,8 +70,7 @@ export function filterControls(providers) { /** * The panel snapshot from core state and provider snapshots. The legend has - * one swatch per active source that draws coverage lines, then the shared - * selection colour; a source that only opens where the user clicks has none. + * one swatch per active source that draws coverage lines, then Selected. * @param {{enabled: boolean, filter: object, providers: Array, street: object, sequence: object}} input */ export function composeUIState({ @@ -99,7 +96,6 @@ export function composeUIState({ legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); return { enabled, - /** The camera is at street zoom: a ground-click provider can open here. */ groundClickReady: groundClickReady === true, keyRequired, keyAlternatives: keyAlternatives(providers), diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 4d8966e11..628633927 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -75,8 +75,7 @@ export function createViewerHost({ state, parts }) { active.adapter.unmount(); active = null; } - // Another provider's prewarmed viewer shares the host: it would sit - // under this one, holding its WebGL context. + // A prewarmed viewer would sit under this one, holding its WebGL context. for (const [id, warm] of warmed) { if (id === entry.def.id) continue; warmed.delete(id); @@ -208,8 +207,7 @@ export function createViewerHost({ state, parts }) { } if (active?.id === entry.def.id || mounting?.id === entry.def.id) continue; - // Switched off while it loaded, or another provider's viewer took the - // host meanwhile: release it now rather than keep it under that one. + // Switched off, or another viewer took the host meanwhile. if (!state.enabled || !entry.on || active || mounting) adapter.unmount(); else warmed.set(entry.def.id, adapter); } diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 5bc323286..7e6759199 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -106,18 +106,11 @@ export class StreetLevelControls { this.listen(el.providerChips, 'click', (event) => { const button = event.target?.closest?.('.data-toggle-chip'); if (!button || button.disabled) return; - // A chip without a usable key stays focusable for its tooltip; a click - // that would light it, or switch the layer off through it, says the - // same thing instead. Other chips still switch (a refused Street View - // key cannot lock Mapillary out), and it darkens beside a lit one. - const providers = this._view?.providers || []; - const chip = providers.find( + // A keyless chip shows its tooltip instead of switching; others switch. + const chip = this._view?.providers.find( (entry) => entry.id === button.dataset.chipId, ); - const othersLit = providers.some( - (entry) => entry.id !== chip?.id && entry.active, - ); - if (chip?.state === 'error' && (!chip.active || !othersLit)) { + if (chip?.state === 'error') { if (button.title) this.actions.showToast?.(button.title); return; } @@ -141,8 +134,6 @@ export class StreetLevelControls { this.listen(el.sinceRange, 'change', () => this._setParams({ sinceDays: sinceDays() }), ); - // The keyboard path to Street View: its nearest image at the view centre, - // without moving the camera. this.listen(el.nearestBtn, 'click', () => { const nearest = this._view?.nearest; if (!nearest?.visible || nearest.disabled) return; @@ -261,24 +252,23 @@ export class StreetLevelControls { } /** - * Lighting a chip turns the layer on; darkening the last lit one turns the - * layer off but keeps the provider on, so the layer comes back with it. + * Lighting a chip turns the layer on and darkens the others (one source at + * a time); darkening the lit one turns the layer off, keeping the provider. */ async _toggleProvider(providerId) { const chip = this._view?.providers.find((entry) => entry.id === providerId); if (!chip) return; if (!chip.active) { - this._setParams({ [providerId]: true }); + // Every registered provider in one update (those hidden on this map + // too), so the share link records the swap. + const ids = + this.layer.providerIds || this._view.providers.map((entry) => entry.id); + this._setParams( + Object.fromEntries(ids.map((id) => [id, id === providerId])), + ); await this._ensureEnabled(); return; } - const othersLit = this._view.providers.some( - (entry) => entry.id !== providerId && entry.active, - ); - if (othersLit) { - this._setParams({ [providerId]: false }); - return; - } try { await this.actions.setEnabled?.(false); } catch (error) { diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 5332310ac..a27afd468 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -1176,7 +1176,7 @@ test('OPEN STREET VIEW opens the nearest panorama at the view centre without mov assert.equal(button.hidden, true); })); -test('a refused key on one provider leaves the other chip switchable, and the refused chip darkens beside it', () => +test('a refused Street View key cannot lock Mapillary out; its chip only explains itself', () => withDom(async (dom) => { const state = uiState({ on: false, keyRequired: true }); state.providers.push({ @@ -1194,23 +1194,37 @@ test('a refused key on one provider leaves the other chip switchable, and the re const chips = () => dom.root.querySelector('#sl-provider-chips').children; chips()[0].click(); // Mapillary: configured, dark await settle(); + // One source at a time: lighting Mapillary darkens Street View too. assert.deepEqual(calls.setParams, [ - [{ mapillary: true }, { origin: 'user' }], + [{ mapillary: true, google: false }, { origin: 'user' }], ]); - // With Mapillary lit, the refused Street View chip can be darkened. + // Now dark, the refused chip says why instead of lighting. state.providers[0].on = true; + state.providers[1].on = false; state.keyRequired = false; layer.publish({ ...state }); chips()[1].click(); await settle(); - assert.deepEqual(calls.setParams.at(-1), [ - { google: false }, - { origin: 'user' }, + assert.equal(calls.setParams.length, 1); + assert.deepEqual(calls.toasts, [ + dom.root.querySelector('#sl-provider-chips').children[1].title, ]); - assert.equal(calls.toasts.length, 0); })); -test('Street View alone hides the imagery filters and the legend; Mapillary beside it brings them back', () => +test('a swap names every registered provider, also those hidden on this map', () => + withDom(async (dom) => { + const state = uiState({ on: false }); + const { calls, layer, chip } = stubPanel(dom, state); + // Off Google 3D the Street View chip is not listed, but stays registered. + layer.providerIds = ['mapillary', 'google']; + chip().click(); + await settle(); + assert.deepEqual(calls.setParams, [ + [{ mapillary: true, google: false }, { origin: 'user' }], + ]); + })); + +test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () => withDom(async (dom) => { const state = uiState(); const { layer } = stubPanel(dom, state); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 650f045bf..6955e63b2 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -153,10 +153,7 @@ function presentViewer(state) { }; } -/** - * A button for the providers with no coverage to click (Street View): opens - * their nearest image at the view centre, so they need no pointer. - */ +/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */ function presentNearest(state) { const targets = (state.providers || []).filter( (provider) => @@ -222,7 +219,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { ? null : state.street.error || state.coverage.error || null, filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 }, - // Only the filters a lit provider honours (none for Street View alone). filterControls: { pano: state.filterControls?.pano !== false, since: state.filterControls?.since !== false,