mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(street-level): one imagery source at a time; IC8 review fixes
- One provider is lit at a time: lighting a chip darkens the others, and a chip click records every registered provider's switch in one update so the share link keeps the swap (also off Google 3D, where Street View is not listed). The layer starts with the first default-on provider (Mapillary, free); params that light several keep the one already on, so reapplying them never flips (litByParams). The now-unreachable "several lit" chip paths are gone. - Leaving Google 3D during a Street View lookup ends it without an error or a stuck loading state (openImage's early return resets it; the lookup loop rechecks availability). - A source swap during a lookup no longer reports "no imagery". - Street View lit off Google 3D says the panel needs the Google 3D map. - Comments trimmed to the why; keySetupCore.test.mjs back to upstream's formatting plus the one new test (was a 551-line reformat). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
64d939f3d2
commit
b1efd22f02
24 files changed
+388
-625
No files matched your search
+5
-3
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+13
-6
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
*/
|
||||
|
||||
+118
-419
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean>|false}
|
||||
*/
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
|
||||
@@ -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<import('./mapsLoader.js').createMapsLoader>, getService?: () => Promise<object>, 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));
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ProviderSnapshot>} 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<ProviderSnapshot>} 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<ProviderSnapshot>, 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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user