mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(street-level): OPEN STREET VIEW, and Street View only on Google 3D
- OPEN STREET VIEW in the PROVIDERS row opens Street View's nearest panorama at the ground under the middle of the screen, without moving the camera: Street View no longer needs a pointer. It is disabled (and says so) until the camera is at street zoom. - Off the Google 3D map Street View does not appear at all: no chip, legend swatch, globe credit, layer source or button, and Google's script is not loaded (the viewer has no prewarm; the script loads on first use). The share option keeps the user's choice for when Google 3D returns. - A ground click measures the camera height at the click rather than reading the cached flag. - A chip without a usable key explains it instead of lighting; it darkens beside a lit chip, and the other chips keep switching, so a refused Street View key cannot lock Mapillary out. - The gate checks that nothing of Street View shows off Google 3D even when a share link switches it on, presses OPEN STREET VIEW on Google 3D, and asserts that each chip it switches really switched. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7bfcf177ac
commit
ef396655fc
18 files changed
+505
-110
No files matched your search
+2
-1
@@ -66,7 +66,8 @@
|
|||||||
- Street Level: Google Street View is the second provider. On the Google 3D
|
- Street Level: Google Street View is the second provider. On the Google 3D
|
||||||
map (Google's terms keep it off other maps), light its chip (off by
|
map (Google's terms keep it off other maps), light its chip (off by
|
||||||
default: every panorama is billed to the Google Maps key, which needs the
|
default: every panorama is billed to the Google Maps key, which needs the
|
||||||
Maps JavaScript API) and click a street to open Google's own panorama in
|
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
|
||||||
|
open Google's own panorama in
|
||||||
the panel, with its arrows, the address and month in the caption, a link
|
the panel, with its arrows, the address and month in the caption, a link
|
||||||
to Google Maps and FOLLOW. Share links carry it as `g`.
|
to Google Maps and FOLLOW. Share links carry it as `g`.
|
||||||
|
|
||||||
|
|||||||
@@ -338,7 +338,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.
|
**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 every provider. Click a coverage line for its image cones and a cone to open the photo. With **STREET VIEW** lit on the **Google 3D** map (Google's terms keep Street View off other maps), click a street from below 3 km to open Google's panorama there, and 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). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. 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 **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.g.1` Street View on, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.g.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`.
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -3060,7 +3060,10 @@ ground level. Framing a photo ignores mesh samples far below the bare earth
|
|||||||
(unloaded tiles).
|
(unloaded tiles).
|
||||||
|
|
||||||
The header pill is the layer switch. With one provider its chip is a layer
|
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. The
|
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. The
|
||||||
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
||||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
||||||
stops when the stack changes. Camera moves go through the application's
|
stops when the stack changes. Camera moves go through the application's
|
||||||
@@ -3090,14 +3093,18 @@ on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
|
|||||||
Its chip starts off (`defaultOn: false`, option default `g` off) because every
|
Its chip starts off (`defaultOn: false`, option default `g` off) because every
|
||||||
panorama load is billed to the key. Google's terms forbid Street View beside
|
panorama load is billed to the key. Google's terms forbid Street View beside
|
||||||
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
|
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
|
||||||
off Google 3D its chip is dashed with the reason, lookups and opens skip it,
|
off Google 3D it does not appear at all (no chip, legend swatch, globe
|
||||||
and leaving Google 3D closes its panorama. Google offers no coverage layer
|
credit, layer source or button, and Google's script is not loaded), lookups
|
||||||
|
and opens skip it, and leaving Google 3D closes its panorama; the share
|
||||||
|
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
|
||||||
for other maps, so it draws none: with it on, a click on the map itself
|
for other maps, so it draws none: with it on, a click on the map itself
|
||||||
(`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, so a
|
(`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, so a
|
||||||
Mapillary line under the pointer wins; no tool holding the pointer; camera
|
Mapillary line under the pointer wins; no tool holding the pointer; camera
|
||||||
below 3 km) opens its nearest Google-collected outdoor panorama within 50 m
|
below 3 km) opens its nearest Google-collected outdoor panorama within 50 m
|
||||||
and leaves the camera where it is (`groundClick.js`). Higher up the hint
|
and leaves the camera where it is (`groundClick.js`). Higher up the hint
|
||||||
says to zoom in. The viewer is Google's `StreetViewPanorama` in its own
|
says to zoom in. **OPEN STREET VIEW** in the PROVIDERS row (`#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. The viewer is Google's `StreetViewPanorama` in its own
|
||||||
element in the viewer host, without Google's fullscreen, close and address
|
element in the viewer host, without Google's fullscreen, close and address
|
||||||
controls (the address goes in the caption); a pose goes out at once and
|
controls (the address goes in the caption); a pose goes out at once and
|
||||||
again when the panorama's month, photographer and address arrive (one
|
again when the panorama's month, photographer and address arrive (one
|
||||||
@@ -3105,8 +3112,9 @@ lookup per panorama per session, seeded by the nearest lookup). A key
|
|||||||
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
|
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
|
||||||
not allow, billing off) reads KEY REJECTED and fails a pending open at
|
not allow, billing off) reads KEY REJECTED and fails a pending open at
|
||||||
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
|
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
|
||||||
(no Google key, no Google 3D) only the chip and share-link step runs; the
|
(no Google key, no Google 3D) the gate checks that nothing of Street View
|
||||||
click step skips as `no Google key`.
|
shows off Google 3D, even switched on by a share link; the click and button
|
||||||
|
step skips as `no Google key`.
|
||||||
|
|
||||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||||
|
|||||||
+107
-33
@@ -653,7 +653,20 @@ async function main() {
|
|||||||
),
|
),
|
||||||
].map((chip) => chip.dataset.chipId),
|
].map((chip) => chip.dataset.chipId),
|
||||||
);
|
);
|
||||||
assert.deepEqual(chips, EXPECTED_PROVIDERS);
|
// Every registered provider usable on this map, in order (Street View
|
||||||
|
// only on Google 3D).
|
||||||
|
const listed = await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.getUIState()
|
||||||
|
.providers.map((provider) => provider.id),
|
||||||
|
);
|
||||||
|
assert.deepEqual(chips, listed);
|
||||||
|
assert.deepEqual(
|
||||||
|
EXPECTED_PROVIDERS.filter((id) => listed.includes(id)),
|
||||||
|
listed,
|
||||||
|
);
|
||||||
|
assert.ok(listed.includes('mapillary'));
|
||||||
// One swatch per switched-on source plus "Selected" (Street View
|
// One swatch per switched-on source plus "Selected" (Street View
|
||||||
// starts off); each chip wears its colour.
|
// starts off); each chip wears its colour.
|
||||||
const legend = await page.evaluate(() => ({
|
const legend = await page.evaluate(() => ({
|
||||||
@@ -1733,35 +1746,57 @@ async function main() {
|
|||||||
Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||||
);
|
);
|
||||||
await step(
|
await step(
|
||||||
'Street View starts off, lights from its chip, and travels in the share link',
|
'Street View starts off, appears only on the Google 3D map, and travels in the share link',
|
||||||
async () => {
|
async () => {
|
||||||
const read = () =>
|
const module = () =>
|
||||||
page.$eval(
|
window.__godsEyeView.dataManager.layers.get('street-level').module;
|
||||||
'#sl-provider-chips [data-chip-id="google"]',
|
|
||||||
(node) => ({
|
|
||||||
active: node.classList.contains('active'),
|
|
||||||
className: node.className,
|
|
||||||
title: node.title,
|
|
||||||
color: node.style.getPropertyValue('--chip-color'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const off = await read();
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
off.active,
|
await page.evaluate(`(${module})().getParams().google`),
|
||||||
false,
|
false,
|
||||||
'off until lit: panoramas are billed',
|
'off until lit: panoramas are billed',
|
||||||
);
|
);
|
||||||
assert.equal(off.color, '#4285f4');
|
const setStreetView = (on) =>
|
||||||
await clickPanelControl(page, googleChip);
|
page.evaluate(
|
||||||
await uiUntil(
|
(value) =>
|
||||||
(u) => u.providers.find((p) => p.id === 'google')?.on === true,
|
window.__godsEyeView.dataManager.setLayerParams(
|
||||||
|
'street-level',
|
||||||
|
{ google: value },
|
||||||
|
{ origin: 'user' },
|
||||||
|
),
|
||||||
|
on,
|
||||||
|
);
|
||||||
|
const shown = () =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
chip: Boolean(
|
||||||
|
document.querySelector(
|
||||||
|
'#sl-provider-chips [data-chip-id="google"]',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
legend: [...document.querySelectorAll('#sl-legend li')].some(
|
||||||
|
(item) => /Street View/.test(item.textContent),
|
||||||
|
),
|
||||||
|
credit: document.body.innerHTML.includes('Street View imagery'),
|
||||||
|
button: !document.getElementById('sl-nearest-btn').hidden,
|
||||||
|
}));
|
||||||
|
const original = await stacks.active();
|
||||||
|
// Off Google 3D (CI's only map) nothing of Street View shows, even
|
||||||
|
// switched on: Google's terms keep it off other maps.
|
||||||
|
if (original === 'photoreal') await stacks.set('esri-imagery');
|
||||||
|
await setStreetView(true);
|
||||||
|
await uiUntil((u) => u.providers.every((p) => p.id !== 'google'));
|
||||||
|
assert.ok(
|
||||||
|
await settle(
|
||||||
|
page,
|
||||||
|
() => !document.body.innerHTML.includes('Street View imagery'),
|
||||||
|
),
|
||||||
|
'no Google credit on another map',
|
||||||
);
|
);
|
||||||
const lit = await read();
|
assert.deepEqual(await shown(), {
|
||||||
if (googleKey) assert.match(lit.className, /chip-active/);
|
chip: false,
|
||||||
else {
|
legend: false,
|
||||||
assert.match(lit.className, /chip-error/, 'no key: an error chip');
|
credit: false,
|
||||||
assert.match(lit.title, /GOOGLE_MAPS_API_KEY/);
|
button: false,
|
||||||
}
|
});
|
||||||
const option = `${STREET_LEVEL_TOKEN}.g.1`;
|
const option = `${STREET_LEVEL_TOKEN}.g.1`;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
await settle(
|
await settle(
|
||||||
@@ -1772,13 +1807,34 @@ async function main() {
|
|||||||
.includes(token),
|
.includes(token),
|
||||||
option,
|
option,
|
||||||
),
|
),
|
||||||
`the share link carries ${option}`,
|
`the share link keeps the choice: ${option}`,
|
||||||
);
|
);
|
||||||
assert.ok((await ui()).coverage.count > 0, 'Mapillary still draws');
|
if (await stacks.photoreal()) {
|
||||||
// Leave it off, so later steps run the same with and without a key.
|
await stacks.set('photoreal');
|
||||||
await clickPanelControl(page, googleChip);
|
await uiUntil((u) => u.providers.some((p) => p.id === 'google'));
|
||||||
|
const chipNow = await page.$eval(googleChip, (node) => ({
|
||||||
|
className: node.className,
|
||||||
|
title: node.title,
|
||||||
|
color: node.style.getPropertyValue('--chip-color'),
|
||||||
|
}));
|
||||||
|
assert.equal(chipNow.color, '#4285f4');
|
||||||
|
if (googleKey) assert.match(chipNow.className, /chip-active/);
|
||||||
|
else {
|
||||||
|
assert.match(chipNow.className, /chip-error/);
|
||||||
|
assert.match(chipNow.title, /GOOGLE_MAPS_API_KEY/);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
await settle(page, () =>
|
||||||
|
document.body.innerHTML.includes('Street View imagery'),
|
||||||
|
),
|
||||||
|
'the Google credit shows on Google 3D',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Back off, as the next steps expect.
|
||||||
|
await setStreetView(false);
|
||||||
|
await stacks.set(original);
|
||||||
await uiUntil(
|
await uiUntil(
|
||||||
(u) => u.providers.find((p) => p.id === 'google')?.on === false,
|
(u) => u.providers.find((p) => p.id === 'google')?.on !== true,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1799,10 +1855,13 @@ async function main() {
|
|||||||
const now = (await ui()).providers.find((p) => p.id === id)?.on;
|
const now = (await ui()).providers.find((p) => p.id === id)?.on;
|
||||||
if (now === on) return;
|
if (now === on) return;
|
||||||
await clickPanelControl(page, selector);
|
await clickPanelControl(page, selector);
|
||||||
await uiUntil(
|
assert.ok(
|
||||||
(u, want) =>
|
await uiUntil(
|
||||||
u.providers.find((p) => p.id === want.id)?.on === want.on,
|
(u, want) =>
|
||||||
{ id, on },
|
u.providers.find((p) => p.id === want.id)?.on === want.on,
|
||||||
|
{ id, on },
|
||||||
|
),
|
||||||
|
`the ${id} chip switched ${on ? 'on' : 'off'}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
await closePhoto();
|
await closePhoto();
|
||||||
@@ -1918,6 +1977,21 @@ async function main() {
|
|||||||
),
|
),
|
||||||
'the pose bearing follows the drag',
|
'the pose bearing follows the drag',
|
||||||
);
|
);
|
||||||
|
// The keyboard path: OPEN STREET VIEW at the view centre.
|
||||||
|
await closePhoto();
|
||||||
|
await uiUntil((u) => u.street.open === false);
|
||||||
|
await clickPanelControl(page, '#sl-nearest-btn');
|
||||||
|
assert.ok(
|
||||||
|
await uiUntil(
|
||||||
|
(u) =>
|
||||||
|
u.street.providerId === 'google' &&
|
||||||
|
Boolean(u.street.imageId) &&
|
||||||
|
!u.street.loading,
|
||||||
|
null,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
),
|
||||||
|
'OPEN STREET VIEW opens the panorama at the view centre',
|
||||||
|
);
|
||||||
// Leaving Google 3D closes the panorama (Google's terms).
|
// Leaving Google 3D closes the panorama (Google's terms).
|
||||||
await stacks.set('esri-imagery');
|
await stacks.set('esri-imagery');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ test('each provider starts as its share-link option does, so a fresh page and a
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const options = createDefaultLayerState().options['street-level'];
|
const options = createDefaultLayerState().options['street-level'];
|
||||||
const providers = layer.getUIState().providers;
|
// Every registered provider, listed on this map or not.
|
||||||
assert.deepEqual(
|
const started = layer.getParams();
|
||||||
providers.map((p) => p.id),
|
for (const id of layer.providerIds)
|
||||||
layer.providerIds,
|
assert.equal(started[id], options[id], id);
|
||||||
);
|
|
||||||
for (const provider of providers)
|
|
||||||
assert.equal(provider.on, options[provider.id], provider.id);
|
|
||||||
assert.equal(options.google, false, 'Street View is billed: off');
|
assert.equal(options.google, false, 'Street View is billed: off');
|
||||||
});
|
});
|
||||||
@@ -15,15 +15,24 @@ export function groundClickHeightOk(viewer, groundAt) {
|
|||||||
* street zoom opens the nearest image of the active, available providers
|
* street zoom opens the nearest image of the active, available providers
|
||||||
* that have no coverage to click (`groundClick`, Google Street View). The
|
* that have no coverage to click (`groundClick`, Google Street View). The
|
||||||
* camera stays put: the user chose the spot.
|
* camera stays put: the user chose the spot.
|
||||||
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
|
* 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}
|
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
|
||||||
*/
|
*/
|
||||||
export function createGroundClick({ state, openNearest, isAvailable }) {
|
export function createGroundClick({
|
||||||
|
state,
|
||||||
|
openNearest,
|
||||||
|
isAvailable,
|
||||||
|
groundAt = null,
|
||||||
|
}) {
|
||||||
return function openAtGround(screenPosition, picked) {
|
return function openAtGround(screenPosition, picked) {
|
||||||
const scenePick = state.services.scenePick;
|
const scenePick = state.services.scenePick;
|
||||||
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
|
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
|
||||||
return false;
|
return false;
|
||||||
if (!scenePick.isSurfacePick(picked) || !state.groundClickReady)
|
if (
|
||||||
|
!scenePick.isSurfacePick(picked) ||
|
||||||
|
!groundClickHeightOk(state.viewer, groundAt)
|
||||||
|
)
|
||||||
return false;
|
return false;
|
||||||
const providerIds = [...state.providers.values()]
|
const providerIds = [...state.providers.values()]
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function harness({
|
|||||||
{ id: 'mapillary', groundClick: false },
|
{ id: 'mapillary', groundClick: false },
|
||||||
{ id: 'google', groundClick: true },
|
{ id: 'google', groundClick: true },
|
||||||
],
|
],
|
||||||
ready = true,
|
height = 400,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
picked = GROUND,
|
picked = GROUND,
|
||||||
ellipsoid = undefined,
|
ellipsoid = undefined,
|
||||||
@@ -24,7 +24,6 @@ function harness({
|
|||||||
const opened = [];
|
const opened = [];
|
||||||
const state = {
|
const state = {
|
||||||
enabled,
|
enabled,
|
||||||
groundClickReady: ready,
|
|
||||||
services: { scenePick },
|
services: { scenePick },
|
||||||
providers: new Map(
|
providers: new Map(
|
||||||
providers.map(
|
providers.map(
|
||||||
@@ -42,7 +41,14 @@ function harness({
|
|||||||
),
|
),
|
||||||
viewer: {
|
viewer: {
|
||||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||||
camera: { pickEllipsoid: () => ellipsoid },
|
camera: {
|
||||||
|
pickEllipsoid: () => ellipsoid,
|
||||||
|
positionCartographic: Cesium.Cartographic.fromDegrees(
|
||||||
|
-121.4944,
|
||||||
|
38.5816,
|
||||||
|
height,
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const openAtGround = createGroundClick({
|
const openAtGround = createGroundClick({
|
||||||
@@ -87,7 +93,7 @@ test('nothing opens over another layer’s object, the sky, from high up, off it
|
|||||||
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
|
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
|
||||||
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
|
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
|
||||||
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
|
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
|
||||||
{ options: { ready: false }, label: 'high up' },
|
{ options: { height: 5000 }, label: 'high up' },
|
||||||
{
|
{
|
||||||
options: {
|
options: {
|
||||||
providers: [{ id: 'google', groundClick: true, available: false }],
|
providers: [{ id: 'google', groundClick: true, available: false }],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
import { createState } from './state.js';
|
import { createState } from './state.js';
|
||||||
import { createMarker } from './marker.js';
|
import { createMarker } from './marker.js';
|
||||||
import { createCameraFollow } from './cameraFollow.js';
|
import { createCameraFollow } from './cameraFollow.js';
|
||||||
@@ -71,6 +72,7 @@ export function createStreetLevelLayer({
|
|||||||
state,
|
state,
|
||||||
openNearest: (point, options) => layer.openNearest(point, options),
|
openNearest: (point, options) => layer.openNearest(point, options),
|
||||||
isAvailable: (entry) => providerAvailable(entry),
|
isAvailable: (entry) => providerAvailable(entry),
|
||||||
|
groundAt: parts.groundCaster?.groundAt ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let notifyQueued = false;
|
let notifyQueued = false;
|
||||||
@@ -155,11 +157,23 @@ export function createStreetLevelLayer({
|
|||||||
return !required || mapStack?.getActiveId?.() === required;
|
return !required || mapStack?.getActiveId?.() === required;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leaving a provider's map stack closes the image it shows. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
function syncProviderAvailability() {
|
function syncProviderAvailability() {
|
||||||
const owner = state.providers.get(state.street.providerId);
|
const owner = state.providers.get(state.street.providerId);
|
||||||
if (state.street.open && owner && !providerAvailable(owner))
|
if (state.street.open && owner && !providerAvailable(owner))
|
||||||
layer.closeViewer();
|
layer.closeViewer();
|
||||||
|
for (const entry of state.providers.values()) {
|
||||||
|
if (!entry.def.requiresMapStack) continue;
|
||||||
|
if (state.enabled && entry.on && providerAvailable(entry))
|
||||||
|
parts.credits.show(state.viewer, entry.def);
|
||||||
|
else {
|
||||||
|
parts.credits.hide(state.viewer, entry.def);
|
||||||
|
parts.viewerHost.unmount(entry.def.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,9 +270,14 @@ export function createStreetLevelLayer({
|
|||||||
function activate(entry) {
|
function activate(entry) {
|
||||||
if (!state.viewer) return;
|
if (!state.viewer) return;
|
||||||
entry.instance.activate(state.viewer);
|
entry.instance.activate(state.viewer);
|
||||||
parts.credits.show(state.viewer, entry.def);
|
|
||||||
if (!entry.status) refreshStatus(entry);
|
if (!entry.status) refreshStatus(entry);
|
||||||
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
|
// 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.
|
||||||
|
whenIdle(() => {
|
||||||
|
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deactivate(entry) {
|
function deactivate(entry) {
|
||||||
@@ -275,6 +294,24 @@ export function createStreetLevelLayer({
|
|||||||
nearestLookup = null;
|
nearestLookup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ground under the middle of the screen, or null (no scenePick, sky). */
|
||||||
|
function screenCentreGround() {
|
||||||
|
const canvas = state.viewer?.scene?.canvas;
|
||||||
|
const pick = state.services.scenePick?.pickGroundPosition;
|
||||||
|
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight) return null;
|
||||||
|
const position = pick(state.viewer, {
|
||||||
|
x: canvas.clientWidth / 2,
|
||||||
|
y: canvas.clientHeight / 2,
|
||||||
|
});
|
||||||
|
const carto = position && Cesium.Cartographic.fromCartesian(position);
|
||||||
|
return carto
|
||||||
|
? {
|
||||||
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/** `frame: false` keeps the camera where it is (the user picked the spot). */
|
/** `frame: false` keeps the camera where it is (the user picked the spot). */
|
||||||
function openImage(providerId, imageId, { frame = true } = {}) {
|
function openImage(providerId, imageId, { frame = true } = {}) {
|
||||||
abortNearest();
|
abortNearest();
|
||||||
@@ -317,16 +354,18 @@ export function createStreetLevelLayer({
|
|||||||
|
|
||||||
/** The hint a ground-click provider shows, or the provider's own. */
|
/** The hint a ground-click provider shows, or the provider's own. */
|
||||||
function hintFor(entry, stats) {
|
function hintFor(entry, stats) {
|
||||||
if (!providerAvailable(entry)) return '';
|
|
||||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
||||||
return `zoom in to a street to open ${entry.def.name}`;
|
return `zoom in to a street to open ${entry.def.name}`;
|
||||||
return stats.hint || '';
|
return stats.hint || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Providers usable on this map stack; the others are not shown at all. */
|
||||||
|
const listedEntries = () =>
|
||||||
|
[...state.providers.values()].filter((entry) => providerAvailable(entry));
|
||||||
|
|
||||||
function providerSnapshots() {
|
function providerSnapshots() {
|
||||||
return [...state.providers.values()].map((entry) => {
|
return listedEntries().map((entry) => {
|
||||||
const stats = entry.instance.coverageStats();
|
const stats = entry.instance.coverageStats();
|
||||||
const available = providerAvailable(entry);
|
|
||||||
return {
|
return {
|
||||||
id: entry.def.id,
|
id: entry.def.id,
|
||||||
name: entry.def.name,
|
name: entry.def.name,
|
||||||
@@ -342,8 +381,8 @@ export function createStreetLevelLayer({
|
|||||||
hint: hintFor(entry, stats),
|
hint: hintFor(entry, stats),
|
||||||
error: stats.error || null,
|
error: stats.error || null,
|
||||||
color: entry.def.colors.coverage,
|
color: entry.def.colors.coverage,
|
||||||
/** Why the provider cannot be used right now, or null. */
|
/** No coverage to click: it opens where the user points. */
|
||||||
unavailable: available ? null : unavailableReason(entry),
|
groundClick: entry.def.groundClick === true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -371,6 +410,7 @@ export function createStreetLevelLayer({
|
|||||||
const { host, ...street } = state.street;
|
const { host, ...street } = state.street;
|
||||||
return composeUIState({
|
return composeUIState({
|
||||||
enabled: state.enabled,
|
enabled: state.enabled,
|
||||||
|
groundClickReady: state.groundClickReady,
|
||||||
filter: state.filter,
|
filter: state.filter,
|
||||||
providers: providerSnapshots(),
|
providers: providerSnapshots(),
|
||||||
street,
|
street,
|
||||||
@@ -383,7 +423,12 @@ export function createStreetLevelLayer({
|
|||||||
id: STREET_LEVEL_LAYER_ID,
|
id: STREET_LEVEL_LAYER_ID,
|
||||||
name: 'Street Level',
|
name: 'Street Level',
|
||||||
icon: '📷',
|
icon: '📷',
|
||||||
source: definitionsFrozen.map((def) => def.name).join(' · '),
|
/** The sources usable on this map (Street View only on Google 3D). */
|
||||||
|
get source() {
|
||||||
|
return listedEntries()
|
||||||
|
.map((entry) => entry.def.name)
|
||||||
|
.join(' · ');
|
||||||
|
},
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
/** The key the switched-on providers share (or null), for the layer row. */
|
/** The key the switched-on providers share (or null), for the layer row. */
|
||||||
@@ -463,6 +508,7 @@ export function createStreetLevelLayer({
|
|||||||
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
||||||
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||||
return {
|
return {
|
||||||
|
source: layer.source,
|
||||||
count: coverage.count,
|
count: coverage.count,
|
||||||
loading: coverage.loading,
|
loading: coverage.loading,
|
||||||
keyRequired: coverage.keyRequired,
|
keyRequired: coverage.keyRequired,
|
||||||
@@ -530,7 +576,13 @@ export function createStreetLevelLayer({
|
|||||||
attachViewerHost(element) {
|
attachViewerHost(element) {
|
||||||
parts.viewerHost.attach(element);
|
parts.viewerHost.attach(element);
|
||||||
if (element && state.enabled)
|
if (element && state.enabled)
|
||||||
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
|
whenIdle(
|
||||||
|
() =>
|
||||||
|
parts.viewerHost.prewarm(
|
||||||
|
activeEntries().filter((entry) => providerAvailable(entry)),
|
||||||
|
),
|
||||||
|
1500,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
setProviderEnabled,
|
setProviderEnabled,
|
||||||
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||||
@@ -538,10 +590,17 @@ export function createStreetLevelLayer({
|
|||||||
openImage,
|
openImage,
|
||||||
/**
|
/**
|
||||||
* Open the nearest image any active provider (or only `providerIds`)
|
* Open the nearest image any active provider (or only `providerIds`)
|
||||||
* has around a point, by default the view centre.
|
* has around a point; without one, around the view centre, or with
|
||||||
|
* `aim: 'screen-centre'` the ground under the middle of the screen.
|
||||||
*/
|
*/
|
||||||
async openNearest(point, { providerIds = null, frame = true } = {}) {
|
async openNearest(
|
||||||
const view = point || viewCentre(state.viewer);
|
point,
|
||||||
|
{ providerIds = null, frame = true, aim = 'view' } = {},
|
||||||
|
) {
|
||||||
|
const view =
|
||||||
|
point ||
|
||||||
|
(aim === 'screen-centre' && screenCentreGround()) ||
|
||||||
|
viewCentre(state.viewer);
|
||||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||||
return false;
|
return false;
|
||||||
abortNearest();
|
abortNearest();
|
||||||
|
|||||||
@@ -502,15 +502,16 @@ async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
mapillary.requiresKeyId = 'mapillary';
|
mapillary.requiresKeyId = 'mapillary';
|
||||||
|
const loader = fakeMapsLoader(fake.library);
|
||||||
const google = createGoogleProvider({
|
const google = createGoogleProvider({
|
||||||
getApiKey: () => 'browser-key',
|
getApiKey: () => 'browser-key',
|
||||||
loader: fakeMapsLoader(fake.library),
|
loader,
|
||||||
});
|
});
|
||||||
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
|
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
|
||||||
const stack = fakeMapStack(mapStack);
|
const stack = fakeMapStack(mapStack);
|
||||||
layer.attachMapStackController(stack);
|
layer.attachMapStackController(stack);
|
||||||
layer.attachViewerHost(fakeHost());
|
layer.attachViewerHost(fakeHost());
|
||||||
return { layer, viewer, stack, fake, mapillaryLookups };
|
return { layer, viewer, stack, fake, loader, mapillaryLookups };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
|
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
|
||||||
@@ -523,27 +524,42 @@ test('Street View starts off, and the layer row names the key of the providers t
|
|||||||
assert.equal(layer.requiresKeyId, null, 'two keys: each chip names its own');
|
assert.equal(layer.requiresKeyId, null, 'two keys: each chip names its own');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Street View opens only on the Google 3D map, and leaving that map closes it', async (t) => {
|
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
|
||||||
const { layer, stack } = await withStreetView(t, {
|
const { layer, viewer, stack } = await withStreetView(t, {
|
||||||
mapStack: 'esri-imagery',
|
mapStack: 'esri-imagery',
|
||||||
});
|
});
|
||||||
layer.setProviderEnabled('google', true);
|
layer.setProviderEnabled('google', true);
|
||||||
await settle();
|
await settle();
|
||||||
const google = () =>
|
const ui = () => layer.getUIState();
|
||||||
layer.getUIState().providers.find((p) => p.id === 'google');
|
const listed = () => ui().providers.map((p) => p.id);
|
||||||
assert.match(google().unavailable, /needs the Google 3D map/);
|
const credited = () =>
|
||||||
assert.equal(google().hint, '', 'no "click a street" off Google 3D');
|
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
|
||||||
|
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
|
||||||
|
assert.ok(!ui().legend.some((item) => item.key === 'google'));
|
||||||
|
assert.equal(credited(), false, 'no Google credit on another map');
|
||||||
|
assert.equal(layer.source, 'Mapillary');
|
||||||
|
assert.equal(layer.getStats().source, 'Mapillary');
|
||||||
assert.equal(await layer.openImage('google', 'pano-a'), false);
|
assert.equal(await layer.openImage('google', 'pano-a'), false);
|
||||||
assert.match(layer.getUIState().street.error, /needs the Google 3D map/);
|
assert.match(ui().street.error, /needs the Google 3D map/);
|
||||||
|
assert.equal(layer.getParams().google, true, 'the choice is kept');
|
||||||
|
|
||||||
stack.switchTo('photoreal');
|
stack.switchTo('photoreal');
|
||||||
assert.equal(google().unavailable, null);
|
assert.deepEqual(listed(), ['mapillary', 'google']);
|
||||||
|
assert.equal(credited(), true);
|
||||||
|
assert.equal(layer.source, 'Mapillary · Google Street View');
|
||||||
assert.equal(await layer.openImage('google', 'pano-a'), true);
|
assert.equal(await layer.openImage('google', 'pano-a'), true);
|
||||||
assert.equal(layer.getUIState().street.providerId, 'google');
|
assert.equal(ui().street.providerId, 'google');
|
||||||
|
|
||||||
stack.switchTo('bing');
|
stack.switchTo('bing');
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(layer.getUIState().street.open, false, 'closed with the map');
|
assert.equal(ui().street.open, false, 'closed with the map');
|
||||||
|
assert.equal(credited(), false);
|
||||||
|
assert.deepEqual(listed(), ['mapillary']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
|
||||||
|
const { layer } = await withStreetView(t);
|
||||||
|
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
|
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
|
||||||
@@ -575,3 +591,49 @@ test('an image opened from a ground click leaves the camera where it is', async
|
|||||||
assert.equal(layer.getUIState().street.imageId, 'pano-a');
|
assert.equal(layer.getUIState().street.imageId, 'pano-a');
|
||||||
assert.equal(viewer.flights.started, 0, 'no framing flight');
|
assert.equal(viewer.flights.started, 0, 'no framing flight');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
|
||||||
|
const { layer, loader } = await withStreetView(t, {
|
||||||
|
mapStack: 'esri-imagery',
|
||||||
|
});
|
||||||
|
layer.setProviderEnabled('google', true);
|
||||||
|
layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
assert.deepEqual(loader.imports, [], 'no prewarm, no lookup');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the button’s “centre of the view” is the ground under the middle of the screen', async (t) => {
|
||||||
|
const { createStreetLevelLayer: create } = await import('./index.js');
|
||||||
|
const scenePick = await import('../../data/scenePick.js');
|
||||||
|
const lookups = [];
|
||||||
|
const provider = fakeProvider({
|
||||||
|
nearestImage: async (point) => {
|
||||||
|
lookups.push(point);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const saved = globalThis.document;
|
||||||
|
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||||
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
|
createElement: () => ({ getContext: () => drawing }),
|
||||||
|
});
|
||||||
|
const viewer = fakeViewer();
|
||||||
|
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
|
||||||
|
Object.assign(viewer.scene, {
|
||||||
|
pickPositionSupported: true,
|
||||||
|
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
|
||||||
|
});
|
||||||
|
viewer.scene.canvas.clientWidth = 1000;
|
||||||
|
viewer.scene.canvas.clientHeight = 600;
|
||||||
|
const layer = create({ providers: [provider], services: { scenePick } });
|
||||||
|
layer.init(viewer);
|
||||||
|
layer.enable(viewer);
|
||||||
|
t.after(() => {
|
||||||
|
layer.destroy();
|
||||||
|
globalThis.document = saved;
|
||||||
|
});
|
||||||
|
await layer.openNearest(undefined, { aim: 'screen-centre' });
|
||||||
|
assert.equal(lookups.length, 1);
|
||||||
|
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
|
||||||
|
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
|
||||||
|
});
|
||||||
@@ -254,15 +254,6 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Load the library ahead of the first panorama; a panorama is billed, so none is built. */
|
|
||||||
async prewarm() {
|
|
||||||
try {
|
|
||||||
await ensureLibrary();
|
|
||||||
} catch {
|
|
||||||
/* the real open reports errors */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onPose(listener) {
|
onPose(listener) {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => listeners.delete(listener);
|
return () => listeners.delete(listener);
|
||||||
|
|||||||
@@ -130,11 +130,8 @@ test('an open overtaken by a newer one only shows the newer panorama', async ()
|
|||||||
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
|
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unmounting removes the element and every listener; prewarm builds no panorama', async () => {
|
test('unmounting removes the element and every listener', async () => {
|
||||||
const { fake, loader, viewer, host } = setup();
|
const { fake, viewer, host } = setup();
|
||||||
await viewer.prewarm(host);
|
|
||||||
assert.deepEqual(loader.imports, ['streetView']);
|
|
||||||
assert.equal(fake.built.length, 0, 'a panorama is billed: none on prewarm');
|
|
||||||
await viewer.mount(host);
|
await viewer.mount(host);
|
||||||
const [panorama] = fake.built;
|
const [panorama] = fake.built;
|
||||||
assert.ok(panorama.listenerCount() > 0);
|
assert.ok(panorama.listenerCount() > 0);
|
||||||
@@ -144,6 +141,12 @@ test('unmounting removes the element and every listener; prewarm builds no panor
|
|||||||
await assert.rejects(viewer.open('pano-a'), /not mounted/);
|
await assert.rejects(viewer.open('pano-a'), /not mounted/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('there is no prewarm: Google\u2019s script loads on first use only, never on another map', () => {
|
||||||
|
const { viewer, loader } = setup();
|
||||||
|
assert.equal(viewer.prewarm, undefined);
|
||||||
|
assert.deepEqual(loader.imports, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('a key Google refuses fails the open at once, not after the timeout', async () => {
|
test('a key Google refuses fails the open at once, not after the timeout', async () => {
|
||||||
const { fake, loader, viewer, host } = setup();
|
const { fake, loader, viewer, host } = setup();
|
||||||
await viewer.mount(host);
|
await viewer.mount(host);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function summarizeCoverage(providers) {
|
|||||||
*/
|
*/
|
||||||
export function composeUIState({
|
export function composeUIState({
|
||||||
enabled,
|
enabled,
|
||||||
|
groundClickReady = false,
|
||||||
filter,
|
filter,
|
||||||
providers,
|
providers,
|
||||||
street,
|
street,
|
||||||
@@ -63,6 +64,8 @@ export function composeUIState({
|
|||||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
/** The camera is at street zoom: a ground-click provider can open here. */
|
||||||
|
groundClickReady: groundClickReady === true,
|
||||||
keyRequired,
|
keyRequired,
|
||||||
keyRejected,
|
keyRejected,
|
||||||
filter: { ...filter },
|
filter: { ...filter },
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export class StreetLevelControls {
|
|||||||
status: byId('sl-status'),
|
status: byId('sl-status'),
|
||||||
controls: byId('sl-controls'),
|
controls: byId('sl-controls'),
|
||||||
providerChips: byId('sl-provider-chips'),
|
providerChips: byId('sl-provider-chips'),
|
||||||
|
nearestBtn: byId('sl-nearest-btn'),
|
||||||
error: byId('sl-error'),
|
error: byId('sl-error'),
|
||||||
errorText: byId('sl-error-text'),
|
errorText: byId('sl-error-text'),
|
||||||
sinceRange: byId('sl-since'),
|
sinceRange: byId('sl-since'),
|
||||||
@@ -103,9 +104,18 @@ export class StreetLevelControls {
|
|||||||
this.listen(el.providerChips, 'click', (event) => {
|
this.listen(el.providerChips, 'click', (event) => {
|
||||||
const button = event.target?.closest?.('.data-toggle-chip');
|
const button = event.target?.closest?.('.data-toggle-chip');
|
||||||
if (!button || button.disabled) return;
|
if (!button || button.disabled) return;
|
||||||
// Without a key the chips stay focusable for their tooltip; a click
|
// A chip without a usable key stays focusable for its tooltip; a click
|
||||||
// says the same thing rather than switching on a layer that cannot draw.
|
// that would light it, or switch the layer off through it, says the
|
||||||
if (this._view?.controlsDisabled) {
|
// 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(
|
||||||
|
(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 (button.title) this.actions.showToast?.(button.title);
|
if (button.title) this.actions.showToast?.(button.title);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -129,6 +139,17 @@ export class StreetLevelControls {
|
|||||||
this.listen(el.sinceRange, 'change', () =>
|
this.listen(el.sinceRange, 'change', () =>
|
||||||
this._setParams({ sinceDays: sinceDays() }),
|
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;
|
||||||
|
this.layer.openNearest?.(undefined, {
|
||||||
|
providerIds: nearest.providerIds,
|
||||||
|
frame: false,
|
||||||
|
aim: 'screen-centre',
|
||||||
|
});
|
||||||
|
});
|
||||||
this.listen(el.followBtn, 'click', () => {
|
this.listen(el.followBtn, 'click', () => {
|
||||||
this.layer.setFollow?.(!(this._state?.street?.follow === true));
|
this.layer.setFollow?.(!(this._state?.street?.follow === true));
|
||||||
});
|
});
|
||||||
@@ -466,6 +487,7 @@ export class StreetLevelControls {
|
|||||||
this._renderFilters(view);
|
this._renderFilters(view);
|
||||||
this._renderViewer(view);
|
this._renderViewer(view);
|
||||||
this._renderMeta(view);
|
this._renderMeta(view);
|
||||||
|
this._renderNearest(view);
|
||||||
this._reactToTransitions(view, state);
|
this._reactToTransitions(view, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,6 +617,17 @@ export class StreetLevelControls {
|
|||||||
setProp(this._elements.coverageMeta, 'textContent', view.meta);
|
setProp(this._elements.coverageMeta, 'textContent', view.meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderNearest(view) {
|
||||||
|
const button = this._elements.nearestBtn;
|
||||||
|
if (!button) return;
|
||||||
|
const { nearest } = view;
|
||||||
|
setProp(button, 'hidden', !nearest.visible);
|
||||||
|
if (!nearest.visible) return;
|
||||||
|
setProp(button, 'textContent', nearest.label);
|
||||||
|
setProp(button, 'title', nearest.title);
|
||||||
|
setProp(button, 'disabled', nearest.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the panel on a switch-on or when an image opens. Never persisted:
|
* Open the panel on a switch-on or when an image opens. Never persisted:
|
||||||
* the stored collapse state stays the user's own choice.
|
* the stored collapse state stays the user's own choice.
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ function panelDom() {
|
|||||||
const settings = add(main, 'div', { classes: ['sl-settings'] });
|
const settings = add(main, 'div', { classes: ['sl-settings'] });
|
||||||
for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text'])
|
for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text'])
|
||||||
add(settings, 'div', { id });
|
add(settings, 'div', { id });
|
||||||
|
add(settings, 'button', { id: 'sl-nearest-btn' });
|
||||||
// A missing key gates the filters only; the chips stay outside the gate.
|
// A missing key gates the filters only; the chips stay outside the gate.
|
||||||
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
|
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
|
||||||
for (const pano of ['all', 'pano', 'flat'])
|
for (const pano of ['all', 'pano', 'flat'])
|
||||||
@@ -1136,3 +1137,72 @@ test('a <body> child added while the viewer is expanded goes inert with the rest
|
|||||||
assert.equal(observer.watching(), false);
|
assert.equal(observer.watching(), false);
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const state = uiState();
|
||||||
|
state.providers.push({
|
||||||
|
...state.providers[0],
|
||||||
|
id: 'google',
|
||||||
|
name: 'Google Street View',
|
||||||
|
label: 'STREET VIEW',
|
||||||
|
groundClick: true,
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
state.groundClickReady = true;
|
||||||
|
const { layer } = stubPanel(dom, state);
|
||||||
|
const asked = [];
|
||||||
|
layer.openNearest = (point, options) => asked.push([point, options]);
|
||||||
|
const button = dom.root.querySelector('#sl-nearest-btn');
|
||||||
|
assert.equal(button.hidden, false);
|
||||||
|
assert.equal(button.textContent, 'OPEN STREET VIEW');
|
||||||
|
button.click();
|
||||||
|
assert.deepEqual(asked, [
|
||||||
|
[
|
||||||
|
undefined,
|
||||||
|
{ providerIds: ['google'], frame: false, aim: 'screen-centre' },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
// Too high to mean a street: disabled, and a click does nothing.
|
||||||
|
layer.publish({ ...state, groundClickReady: false });
|
||||||
|
assert.equal(button.disabled, true);
|
||||||
|
button.click();
|
||||||
|
assert.equal(asked.length, 1);
|
||||||
|
// Off Google 3D the provider is not listed: no button.
|
||||||
|
layer.publish({ ...state, providers: [state.providers[0]] });
|
||||||
|
assert.equal(button.hidden, true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('a refused key on one provider leaves the other chip switchable, and the refused chip darkens beside it', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const state = uiState({ on: false, keyRequired: true });
|
||||||
|
state.providers.push({
|
||||||
|
...state.providers[0],
|
||||||
|
id: 'google',
|
||||||
|
name: 'Google Street View',
|
||||||
|
label: 'STREET VIEW',
|
||||||
|
on: true,
|
||||||
|
keyRequired: true,
|
||||||
|
keyRejected: true,
|
||||||
|
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
|
||||||
|
});
|
||||||
|
state.providers[0].keyRequired = false;
|
||||||
|
const { calls, layer } = stubPanel(dom, state);
|
||||||
|
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
|
||||||
|
chips()[0].click(); // Mapillary: configured, dark
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(calls.setParams, [
|
||||||
|
[{ mapillary: true }, { origin: 'user' }],
|
||||||
|
]);
|
||||||
|
// With Mapillary lit, the refused Street View chip can be darkened.
|
||||||
|
state.providers[0].on = true;
|
||||||
|
state.keyRequired = false;
|
||||||
|
layer.publish({ ...state });
|
||||||
|
chips()[1].click();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(calls.setParams.at(-1), [
|
||||||
|
{ google: false },
|
||||||
|
{ origin: 'user' },
|
||||||
|
]);
|
||||||
|
assert.equal(calls.toasts.length, 0);
|
||||||
|
}));
|
||||||
@@ -80,8 +80,6 @@ function presentProviders(state) {
|
|||||||
else if (keyRequired && provider.requiresKeyId)
|
else if (keyRequired && provider.requiresKeyId)
|
||||||
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
|
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
|
||||||
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
||||||
// Usable only on another map stack (Street View off Google 3D).
|
|
||||||
else if (provider.unavailable) title = provider.unavailable;
|
|
||||||
return {
|
return {
|
||||||
id: provider.id,
|
id: provider.id,
|
||||||
label: provider.label,
|
label: provider.label,
|
||||||
@@ -91,13 +89,11 @@ function presentProviders(state) {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
state: keyRequired
|
state: keyRequired
|
||||||
? 'error'
|
? 'error'
|
||||||
: on && provider.unavailable
|
: on && provider.loading
|
||||||
? 'warn'
|
? 'loading'
|
||||||
: on && provider.loading
|
: on
|
||||||
? 'loading'
|
? 'active'
|
||||||
: on
|
: 'idle',
|
||||||
? 'active'
|
|
||||||
: 'idle',
|
|
||||||
busy: on && provider.loading === true,
|
busy: on && provider.loading === true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -134,6 +130,36 @@ 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.
|
||||||
|
*/
|
||||||
|
function presentNearest(state) {
|
||||||
|
const targets = (state.providers || []).filter(
|
||||||
|
(provider) =>
|
||||||
|
provider.on && provider.groundClick && provider.keyRequired !== true,
|
||||||
|
);
|
||||||
|
if (state.enabled !== true || !targets.length)
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
label: '',
|
||||||
|
title: '',
|
||||||
|
disabled: true,
|
||||||
|
providerIds: [],
|
||||||
|
};
|
||||||
|
const names = targets.map((provider) => provider.name).join(' or ');
|
||||||
|
const ready = state.groundClickReady === true;
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`,
|
||||||
|
title: ready
|
||||||
|
? `Open ${names} at the centre of the view`
|
||||||
|
: `Zoom in to a street to open ${names} here`,
|
||||||
|
disabled: !ready || state.street.loading === true,
|
||||||
|
providerIds: targets.map((provider) => provider.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function presentMeta(state) {
|
function presentMeta(state) {
|
||||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||||
if (state.sequence.selectedId)
|
if (state.sequence.selectedId)
|
||||||
@@ -173,6 +199,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
|||||||
legend: state.legend || [],
|
legend: state.legend || [],
|
||||||
viewer: presentViewer(state),
|
viewer: presentViewer(state),
|
||||||
meta: presentMeta(state),
|
meta: presentMeta(state),
|
||||||
|
nearest: presentNearest(state),
|
||||||
wantsOpen: state.street.open === true,
|
wantsOpen: state.street.open === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -434,3 +434,52 @@ test('the meta line keeps the sequence count and adds the other providers’ hin
|
|||||||
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
|
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
|
||||||
|
const streetView = provider({
|
||||||
|
id: 'google',
|
||||||
|
name: 'Google Street View',
|
||||||
|
label: 'STREET VIEW',
|
||||||
|
groundClick: true,
|
||||||
|
requiresKeyId: 'google-maps',
|
||||||
|
});
|
||||||
|
const view = (overrides) =>
|
||||||
|
presentStreetLevelPanel(
|
||||||
|
snapshot({
|
||||||
|
enabled: true,
|
||||||
|
providers: [provider(), streetView],
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
).nearest;
|
||||||
|
const ready = view({ groundClickReady: true });
|
||||||
|
assert.deepEqual(ready, {
|
||||||
|
visible: true,
|
||||||
|
label: 'OPEN STREET VIEW',
|
||||||
|
title: 'Open Google Street View at the centre of the view',
|
||||||
|
disabled: false,
|
||||||
|
providerIds: ['google'],
|
||||||
|
});
|
||||||
|
const high = view({ groundClickReady: false });
|
||||||
|
assert.equal(high.disabled, true);
|
||||||
|
assert.equal(
|
||||||
|
high.title,
|
||||||
|
'Zoom in to a street to open Google Street View here',
|
||||||
|
);
|
||||||
|
// Hidden with Street View off, without its key, or with the layer off
|
||||||
|
// (off Google 3D the provider is not listed at all).
|
||||||
|
assert.equal(
|
||||||
|
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
|
||||||
|
.visible,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(view({ providers: [provider()] }).visible, false);
|
||||||
|
assert.equal(
|
||||||
|
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
|
||||||
|
.nearest.visible,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -183,10 +183,12 @@ button.sl-status:focus-visible {
|
|||||||
border-color: rgba(255, 179, 0, 0.55);
|
border-color: rgba(255, 179, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On, but not usable on this map stack (Street View off Google 3D). */
|
.sl-nearest-btn {
|
||||||
.sl-chips .data-toggle-chip.chip-warn {
|
margin-left: auto;
|
||||||
opacity: 0.7;
|
}
|
||||||
border-style: dashed;
|
|
||||||
|
.sl-nearest-btn[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -113,6 +113,7 @@
|
|||||||
<div class="sl-providers" role="group" aria-label="Imagery providers">
|
<div class="sl-providers" role="group" aria-label="Imagery providers">
|
||||||
<span class="cctv-summary-label">PROVIDERS</span>
|
<span class="cctv-summary-label">PROVIDERS</span>
|
||||||
<div id="sl-provider-chips" class="sl-chips"></div>
|
<div id="sl-provider-chips" class="sl-chips"></div>
|
||||||
|
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
||||||
<!-- A missing key gates the filters only: the chips above stay
|
<!-- A missing key gates the filters only: the chips above stay
|
||||||
|
|||||||
Reference in new issue
Block a user