fix(street-level): IC8 review of the Street View provider

Google's Maps Platform terms forbid Street View imagery beside non-Google
maps, so the provider declares requiresMapStack: 'photoreal': off Google
3D its chip is dashed with the reason, lookups and opens skip it, and
leaving Google 3D closes its panorama.

- Ground clicks: only a click on the map itself (scenePick.isSurfacePick:
  nothing picked or Google 3D tile content) at street zoom opens Street
  View, and the camera stays put; higher up the hint says to zoom in.
  scenePick.pickGroundPosition replaces the copy in Directions.
- Viewer: poses go out at once and again when the date and address
  arrive, so an open never hangs on that lookup; one lookup per panorama,
  seeded by the nearest lookup; a close settles a pending open; failed
  lookups are asked again; poses follow position changes, not pano_changed.
- Loader: the gm_authFailure handler is installed once, so a retried load
  reports a refusal once.
- Viewer host: a prewarm that lands after another provider took the host
  is released; FIT/FILL availability is set on every open.
- Provider: a refusal while switched off reports no error over another
  photo; SINCE compares against the end of Google's month; messages name
  the referrer and network causes; FLAT says Street View is 360° only.
- The layer row names the key of the providers that are on; the MCP panel
  CSP admits Street View's tiles, control images and photos.
- Tests for each, against fakes that now follow Google's event order; the
  browser gate restores its state, waits instead of sleeping, aims its
  panel clicks and corner drags, and asserts the camera stays put.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-08 02:59:51 +01:00
1 parent 22b0009138
commit 7bfcf177ac
35 files changed
+1108 -388

No files matched your search

+6 -6
View File
@@ -63,12 +63,12 @@
## [Unreleased]
- Street Level: Google Street View is the second provider. Light its chip
(off by 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 the panel, with its arrows, the address and month in the
caption, a link to Google Maps and FOLLOW on Google 3D. Share links carry
it as `g`.
- 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
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
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`.
- Stdio servers from one install share a panel key so a page read from one
process can make requests through another. Concurrent malformed-key repairs
+1 -1
View File
@@ -21,7 +21,7 @@ How to read this:
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level: Google's panorama viewer and nearest-panorama lookups; nothing cached | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
+1 -1
View File
@@ -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.
**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, click a street (from below 3 km) to open Google's panorama there, and step along its arrows. Above the photo: **EXPAND**, **FIT / FILL**, 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 "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on.
**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").
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`.
+1 -1
View File
@@ -26,7 +26,7 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search and Street View (Maps JavaScript API). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search and Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
+19 -10
View File
@@ -3088,16 +3088,25 @@ Google Street View (`providers/google/`) runs on the Maps JavaScript API with
the browser `GOOGLE_MAPS_API_KEY`, which needs that API enabled; it is loaded
on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
Its chip starts off (`defaultOn: false`, option default `g` off) because every
panorama load is billed to the key. Google offers no coverage layer for
other maps, so it draws none: with it on, a click on bare ground (nothing
picked, so a 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 (`groundClick.js`). The viewer
is Google's `StreetViewPanorama` in its own element in the viewer host,
without Google's fullscreen, close and address controls (the address goes in
the caption); its poses carry the month the panorama was taken. A key Google
refuses (`gm_authFailure`) reads KEY REJECTED and fails a pending open at
once. The gate's Street View steps skip with `no Google key` in CI.
panorama load is billed to the key. Google's terms forbid Street View beside
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
off Google 3D its chip is dashed with the reason, lookups and opens skip it,
and leaving Google 3D closes its panorama. Google offers no coverage layer
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
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
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
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
again when the panorama's month, photographer and address arrive (one
lookup per panorama per session, seeded by the nearest lookup). A key
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
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
click step skips as `no Google key`.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
+1
View File
@@ -899,6 +899,7 @@
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
+17 -2
View File
@@ -381,8 +381,23 @@ async function main() {
);
await nextFrames(page);
};
await drag(handlePoint(await readPanel(), 'se'), 0, 200);
const chosen = await readPanel();
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const corner = handlePoint(await readPanel(), 'se');
const onHandle = await page.evaluate(
({ x, y }) => document.elementFromPoint(x, y)?.dataset?.dir === 'se',
corner,
);
if (onHandle) await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px`,
+150 -104
View File
@@ -628,7 +628,8 @@ async function main() {
}
await page.setViewport(VIEWPORTS[0]);
const expandStrip = async (target = page) => {
await target.click(
await clickPanelControl(
target,
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
);
await settle(
@@ -1774,116 +1775,161 @@ async function main() {
`the share link carries ${option}`,
);
assert.ok((await ui()).coverage.count > 0, 'Mapillary still draws');
// Leave it off, so later steps run the same with and without a key.
await clickPanelControl(page, googleChip);
await uiUntil(
(u) => u.providers.find((p) => p.id === 'google')?.on === false,
);
},
);
await step(
'a click on a street opens Google Street View, captioned and linked, and its view drives the pose',
'on Google 3D a click on a street opens Google Street View where it is, captioned and linked, and its view drives the pose',
async ({ skip }) => {
if (!googleKey) return skip('no Google key');
// A Mapillary line under the pointer wins the click: aim at bare
// ground with only Street View on.
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
await clickPanelControl(page, chip);
await uiUntil(
(u) => u.providers.find((p) => p.id === 'mapillary')?.on === false,
);
const street = STREET_VIEW_STREET;
await page.evaluate((at) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
const C = v.camera.positionCartographic.constructor;
v.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(at.lon, at.lat, 300),
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, street);
await sleep(1500);
const at = await page.evaluate((point) => {
const v = window.__godsEyeView.viewer;
const C = v.camera.positionCartographic.constructor;
const ground = v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(point.lon, point.lat, 0),
// Google's terms: Street View only beside Google's own map.
if (!(await stacks.photoreal())) return skip('no Google 3D');
const original = await stacks.active();
const closePhoto = () =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
const canvas = v.scene.cartesianToCanvasCoordinates(ground);
const box = v.scene.canvas.getBoundingClientRect();
return canvas && { x: box.left + canvas.x, y: box.top + canvas.y };
}, street);
assert.ok(at, 'the street is on screen');
await page.mouse.click(at.x, at.y);
await uiUntil(
(u) =>
Boolean(u.street.error) ||
(u.street.providerId === 'google' &&
Boolean(u.street.imageId) &&
!u.street.loading),
null,
{ timeout: 30_000 },
);
const opened = (await ui()).street;
if (/GOOGLE_MAPS_API_KEY/.test(opened.error || '')) {
await clickPanelControl(page, chip);
return skip('Google key refused');
}
assert.equal(opened.error, null);
assert.equal(opened.providerId, 'google');
const shown = await page.evaluate(() => ({
by: document.getElementById('sl-image-by').textContent,
when: document.getElementById('sl-image-when').textContent,
label: document.getElementById('sl-image-link').textContent,
href: document.getElementById('sl-image-link').href,
fitFillHidden:
document.querySelector('[data-sl-render]').parentElement.hidden,
panorama: Boolean(
document.querySelector('#sl-viewer .sl-google-panorama'),
),
mapillaryLeft: Boolean(
document.querySelector('#sl-viewer .mapillary-dom'),
),
}));
assert.match(shown.by, /Image by /);
assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month');
assert.equal(shown.label, 'STREET VIEW ↗');
assert.match(shown.href, /map_action=pano&pano=/);
assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google');
assert.equal(shown.panorama, true);
assert.equal(shown.mapillaryLeft, false, 'no viewer left under it');
// A real drag turns Google's view, and the pose follows it.
const box = await page.$eval(
'#sl-viewer .sl-google-panorama',
(node) => {
const r = node.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
},
);
await page.mouse.move(box.x, box.y);
await page.mouse.down();
await page.mouse.move(box.x - 120, box.y, { steps: 12 });
await page.mouse.up();
assert.ok(
const setOn = async (selector, id, on) => {
const now = (await ui()).providers.find((p) => p.id === id)?.on;
if (now === on) return;
await clickPanelControl(page, selector);
await uiUntil(
(u, before) =>
Number.isFinite(u.street.bearing) &&
Math.abs(u.street.bearing - before) > 5,
opened.bearing ?? 0,
{ timeout: 10_000 },
),
'the pose bearing follows the drag',
);
await clickPanelControl(page, '#sl-viewer-close');
await uiUntil((u) => u.street.open === false);
await clickPanelControl(page, chip);
await clickPanelControl(page, googleChip);
await uiUntil(
(u) =>
u.providers.find((p) => p.id === 'google')?.on === false &&
u.providers.find((p) => p.id === 'mapillary')?.on === true,
);
(u, want) =>
u.providers.find((p) => p.id === want.id)?.on === want.on,
{ id, on },
);
};
await closePhoto();
await stacks.set('photoreal');
// A Mapillary line under the pointer wins the click: aim at bare
// ground with only Street View on (lit first: the last lit chip
// would switch the layer off).
await setOn(googleChip, 'google', true);
await setOn(chip, 'mapillary', false);
try {
const street = STREET_VIEW_STREET;
await page.evaluate((at) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
const C = v.camera.positionCartographic.constructor;
v.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(at.lon, at.lat, 300),
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, street);
// At street zoom the hint invites the click.
assert.ok(
await uiUntil(
(u) =>
u.providers.find((p) => p.id === 'google')?.hint ===
'click a street to open Street View',
),
'the click hint shows at street zoom',
);
const camera = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionWC;
return [c.x, c.y, c.z];
});
const at = await page.evaluate((point) => {
const v = window.__godsEyeView.viewer;
const C = v.camera.positionCartographic.constructor;
const ground = v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(point.lon, point.lat, 0),
);
const canvas = v.scene.cartesianToCanvasCoordinates(ground);
const box = v.scene.canvas.getBoundingClientRect();
return (
canvas && { x: box.left + canvas.x, y: box.top + canvas.y }
);
}, street);
assert.ok(at, 'the street is on screen');
const before = await camera();
await page.mouse.click(at.x, at.y);
await uiUntil(
(u) =>
Boolean(u.street.error) ||
(u.street.providerId === 'google' &&
Boolean(u.street.imageId) &&
!u.street.loading),
null,
{ timeout: 30_000 },
);
const opened = (await ui()).street;
if (/refused GOOGLE_MAPS_API_KEY/.test(opened.error || ''))
return skip('Google key refused');
if (/did not answer/.test(opened.error || ''))
return skip('Google did not answer');
assert.equal(opened.error, null);
assert.equal(opened.providerId, 'google');
const after = await camera();
assert.ok(
Math.hypot(...after.map((v, i) => v - before[i])) < 1,
'the camera stays where the user clicked',
);
const shown = await page.evaluate(() => ({
by: document.getElementById('sl-image-by').textContent,
when: document.getElementById('sl-image-when').textContent,
label: document.getElementById('sl-image-link').textContent,
href: document.getElementById('sl-image-link').href,
fitFillHidden:
document.querySelector('[data-sl-render]').parentElement.hidden,
panorama: Boolean(
document.querySelector('#sl-viewer .sl-google-panorama'),
),
mapillaryLeft: Boolean(
document.querySelector('#sl-viewer .mapillary-dom'),
),
}));
assert.match(shown.by, /Image by /);
assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month');
assert.equal(shown.label, 'STREET VIEW ↗');
assert.match(shown.href, /map_action=pano&pano=/);
assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google');
assert.equal(shown.panorama, true);
assert.equal(shown.mapillaryLeft, false, 'no viewer left under it');
// A real drag turns Google's view, and the pose follows it.
const box = await page.$eval(
'#sl-viewer .sl-google-panorama',
(node) => {
const r = node.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
},
);
await page.mouse.move(box.x, box.y);
await page.mouse.down();
await page.mouse.move(box.x - 120, box.y, { steps: 12 });
await page.mouse.up();
assert.ok(
await uiUntil(
(u, start) =>
Number.isFinite(u.street.bearing) &&
Math.abs(u.street.bearing - start) > 5,
opened.bearing ?? 0,
{ timeout: 10_000 },
),
'the pose bearing follows the drag',
);
// Leaving Google 3D closes the panorama (Google's terms).
await stacks.set('esri-imagery');
assert.ok(
await uiUntil((u) => u.street.open === false),
'the panorama closes off Google 3D',
);
} finally {
await closePhoto();
await setOn(chip, 'mapillary', true);
await setOn(googleChip, 'google', false);
await stacks.set(original);
}
},
);
await step(
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationStreetLevel } from './streetLevel.js';
import { createDefaultLayerState } from '../../data/layerState.js';
test('each provider starts as its share-link option does, so a fresh page and a restored one agree', () => {
const layer = createApplicationStreetLevel({
surface: null,
sources: {
mapillary: {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
},
},
});
const options = createDefaultLayerState().options['street-level'];
const providers = layer.getUIState().providers;
assert.deepEqual(
providers.map((p) => p.id),
layer.providerIds,
);
for (const provider of providers)
assert.equal(provider.on, options[provider.id], provider.id);
assert.equal(options.google, false, 'Street View is billed: off');
});
+50
View File
@@ -47,3 +47,53 @@ export function isPickedWorldPosition(position) {
const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
}
/**
* 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.
*
* @param {object} viewer A Cesium viewer.
* @param {{x: number, y: number}} screenPosition
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
*/
export function pickGroundPosition(viewer, screenPosition) {
const scene = viewer?.scene;
let position = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
position = scene.pickPosition(screenPosition);
} catch {
position = null;
}
}
if (
!isPickedWorldPosition(position) &&
typeof viewer?.camera?.pickEllipsoid === 'function'
) {
try {
position = viewer.camera.pickEllipsoid(screenPosition);
} catch {
position = null;
}
}
return isPickedWorldPosition(position) ? position : null;
}
/**
* Whether a `scene.pick()` result is the map itself: nothing, or Google 3D
* tile content, which picks without an id.
*
* @param {object|null|undefined} picked
* @returns {boolean}
*/
export function isSurfacePick(picked) {
if (!picked) return true;
if (picked.id !== undefined) return false;
return (
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
);
}
+68 -13
View File
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from './scenePick.js';
const describe = (position) => (position
? `(${position.x}, ${position.y}, ${position.z})`
: String(position));
const describe = (position) =>
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
// These are the shapes `scene.pickPosition()` can produce when the depth read
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
new Cesium.Cartesian3(0.2, -0.3, 0.1),
];
for (const position of degenerate) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
});
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(0, 0, 5_999_999),
];
for (const position of belowTheFloor) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
const carto = Cesium.Cartographic.fromCartesian(position);
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
assert.ok(
carto.height < -300_000,
'and puts the "target" hundreds of km underground',
);
}
const aboveTheCeiling = [
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(1e155, 0, 0),
];
for (const position of aboveTheCeiling) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
assert.throws(
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
);
// Both edges of the band are inclusive, and both are real magnitudes.
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
true,
);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
true,
);
});
test('a real picked position is accepted', () => {
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
];
for (const position of real) {
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
true,
`must accept ${describe(position)}`,
);
}
// The floor has real margin under the smallest legitimate magnitude, so no
// plausible pick sits anywhere near the boundary.
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
const polarSurface = Cesium.Cartesian3.magnitude(
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
);
assert.ok(
polarSurface > 6_300_000,
`smallest real magnitude was ${polarSurface}`,
);
});
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
// Earth's core. That is the quietest failure of the three and the reason
// the guard needs a magnitude band, not just a finiteness check.
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
assert.ok(
Cesium.Cartographic.fromCartesian(nearCenter),
'Cesium converts it without complaint',
);
assert.equal(isPickedWorldPosition(nearCenter), false);
// …and every position the guard accepts must convert to the right place.
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
});
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
const { pickGroundPosition } = await import('./scenePick.js');
const ground = { x: 6378137, y: 0, z: 0 };
const sky = { x: 500, y: 0, z: 0 };
const viewer = (picked, ellipsoid) => ({
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid },
});
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
assert.equal(pickGroundPosition(null, {}), null);
});
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
const { isSurfacePick } = await import('./scenePick.js');
assert.equal(isSurfacePick(undefined), true);
assert.equal(isSurfacePick({ content: {} }), true);
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
});
+5 -26
View File
@@ -618,32 +618,11 @@ export function createDirectionsLayer({ services }) {
}
function pickGround(screenPosition) {
const scene = _viewer?.scene;
let cartesian = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (
!services.scenePick.isPickedWorldPosition(cartesian) &&
typeof _viewer?.camera?.pickEllipsoid === 'function'
) {
try {
cartesian = _viewer.camera.pickEllipsoid(
screenPosition,
Cesium.Ellipsoid.WGS84,
);
} catch {
cartesian = null;
}
}
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
const cartesian = services.scenePick.pickGroundPosition(
_viewer,
screenPosition,
);
if (!cartesian) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
+37 -19
View File
@@ -1,31 +1,49 @@
import * as Cesium from 'cesium';
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround, groundPointAt } from './view.js';
import { cameraHeightAboveGround } from './view.js';
/** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer, groundAt) {
if (!viewer?.camera?.positionCartographic) return false;
return (
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
);
}
/**
* A click on bare ground at street zoom opens the nearest image of the active
* providers that have no coverage to click (`groundClick`, Google Street
* View). `openNearest(point, {providerIds})` is the layer's lookup.
* @returns {(screenPosition: {x: number, y: number}) => Promise<boolean>|false}
* 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.
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
*/
export function createGroundClick({ state, parts, openNearest }) {
return function openAtGround(screenPosition) {
const isWorldPosition = state.services.scenePick?.isPickedWorldPosition;
if (!state.enabled || !state.viewer || !isWorldPosition) return false;
export function createGroundClick({ state, openNearest, isAvailable }) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (!scenePick.isSurfacePick(picked) || !state.groundClickReady)
return false;
const providerIds = [...state.providers.values()]
.filter(
(entry) =>
entry.on && entry.def.groundClick && entry.status?.configured,
entry.on &&
entry.def.groundClick &&
entry.status?.configured &&
isAvailable(entry),
)
.map((entry) => entry.def.id);
if (!providerIds.length) return false;
// From higher up a click is a misclick, not a street.
const height = cameraHeightAboveGround(state.viewer, {
groundAt: parts.groundCaster?.groundAt,
});
if (!(height <= GROUND_CLICK_MAX_HEIGHT_M)) return false;
const point = groundPointAt(state.viewer, screenPosition, {
isWorldPosition,
});
return point ? openNearest(point, { providerIds }) : false;
const position = scenePick.pickGroundPosition(state.viewer, screenPosition);
const carto = position && Cesium.Cartographic.fromCartesian(position);
if (!carto) return false;
return openNearest(
{
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
},
{ providerIds, frame: false },
);
};
}
+93 -50
View File
@@ -1,84 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGroundClick } from './groundClick.js';
import { isPickedWorldPosition } from '../../data/scenePick.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import * as scenePick from '../../data/scenePick.js';
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10);
const TILESET = { isCesium3DTileset: true };
/** A layer state with providers `{id, on, groundClick, configured}` and a camera `height` m up. */
/**
* A layer state with providers `{id, on, groundClick, configured, available}`;
* `picked` is where the depth buffer lands, `ellipsoid` the fallback hit.
*/
function harness({
providers = [
{ id: 'mapillary', groundClick: false },
{ id: 'google', groundClick: true },
],
height = 400,
ready = true,
enabled = true,
picked = GROUND,
ellipsoid = undefined,
} = {}) {
const opened = [];
const state = {
enabled,
services: { scenePick: { isPickedWorldPosition } },
groundClickReady: ready,
services: { scenePick },
providers: new Map(
providers.map(({ id, on = true, groundClick, configured = true }) => [
id,
{
on,
def: { id, groundClick },
status: { configured },
},
]),
providers.map(
({
id,
on = true,
groundClick,
configured = true,
available = true,
}) => [
id,
{ on, def: { id, groundClick }, status: { configured }, available },
],
),
),
viewer: {
scene: {
pickPositionSupported: true,
pickPosition: () => picked,
globe: { getHeight: () => 10 },
},
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
10 + height,
),
pickEllipsoid: () => undefined,
},
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid },
},
};
const openAtGround = createGroundClick({
state,
parts: {},
openNearest: (point, options) => {
opened.push({ point, options });
return Promise.resolve(true);
},
isAvailable: (entry) => entry.available,
});
return { state, opened, openAtGround };
return { opened, openAtGround };
}
test('a ground click at street zoom opens the nearest image of the click-to-open providers there', () => {
const { opened, openAtGround } = harness();
openAtGround({ x: 10, y: 20 });
assert.equal(opened.length, 1);
const [{ point, options }] = opened;
assert.ok(Math.abs(point.lon - -121.4944) < 1e-9);
assert.ok(Math.abs(point.lat - 38.5816) < 1e-9);
assert.deepEqual(options, { providerIds: ['google'] });
});
const near = (point) =>
Math.abs(point.lon - -121.4944) < 1e-9 &&
Math.abs(point.lat - 38.5816) < 1e-9;
test('nothing opens from high up, over the sky, with the provider off or unconfigured, or the layer off', () => {
for (const options of [
{ height: 5000 },
{ picked: null },
{ picked: new Cesium.Cartesian3(500, 0, 0) },
{ providers: [{ id: 'google', groundClick: true, on: false }] },
{ providers: [{ id: 'google', groundClick: true, configured: false }] },
{ providers: [{ id: 'mapillary', groundClick: false }] },
{ enabled: false },
]) {
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }), false, JSON.stringify(options));
assert.equal(opened.length, 0);
test('a click on the map opens the click-to-open providers there, without moving the camera', () => {
for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) {
const { opened, openAtGround } = harness();
openAtGround({ x: 10, y: 20 }, picked);
assert.equal(opened.length, 1, JSON.stringify(picked));
assert.ok(near(opened[0].point));
assert.deepEqual(opened[0].options, {
providerIds: ['google'],
frame: false,
});
}
});
test('where the depth buffer names no place, the ellipsoid answers', () => {
const { opened, openAtGround } = harness({
picked: new Cesium.Cartesian3(500, 0, 0),
ellipsoid: GROUND,
});
openAtGround({ x: 1, y: 1 });
assert.ok(near(opened[0].point));
});
test('nothing opens over another layer’s object, the sky, from high up, off its map stack, or with the provider off', () => {
const cases = [
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
{ options: { ready: false }, label: 'high up' },
{
options: {
providers: [{ id: 'google', groundClick: true, available: false }],
},
label: 'not Google 3D',
},
{
options: { providers: [{ id: 'google', groundClick: true, on: false }] },
label: 'off',
},
{
options: {
providers: [{ id: 'google', groundClick: true, configured: false }],
},
label: 'no key',
},
{ options: { enabled: false }, label: 'layer off' },
];
for (const { picked, options = {}, label } of cases) {
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }, picked), false, label);
assert.equal(opened.length, 0, label);
}
});
test('street zoom is measured above the ground under the camera', () => {
const viewer = (height) => ({
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height),
},
scene: { globe: { getHeight: () => 1500 } },
});
// 1,500 m up a plateau, 2,000 m above it: still a street.
assert.equal(groundClickHeightOk(viewer(3500)), true);
assert.equal(groundClickHeightOk(viewer(5000)), false);
assert.equal(groundClickHeightOk(null), false);
});
+71 -14
View File
@@ -10,7 +10,7 @@ import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick } from './groundClick.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
@@ -69,8 +69,8 @@ export function createStreetLevelLayer({
parts.selection = createSelection(context);
parts.openAtGround = createGroundClick({
state,
parts,
openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
});
let notifyQueued = false;
@@ -146,6 +146,33 @@ 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.
*/
function providerAvailable(entry) {
const required = entry.def.requiresMapStack;
return !required || mapStack?.getActiveId?.() === required;
}
/** Leaving a provider's map stack closes the image it shows. */
function syncProviderAvailability() {
const owner = state.providers.get(state.street.providerId);
if (state.street.open && owner && !providerAvailable(owner))
layer.closeViewer();
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
function syncGroundClickReady() {
const ready =
state.enabled &&
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
@@ -168,6 +195,7 @@ export function createStreetLevelLayer({
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
syncGroundClickReady();
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
@@ -247,9 +275,20 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
function openImage(providerId, imageId) {
/** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
const entry = state.providers.get(providerId);
if (entry && !providerAvailable(entry)) {
state.street.error = unavailableReason(entry);
notify();
return Promise.resolve(false);
}
return parts.viewerHost.open(providerId, imageId, { frame });
}
function unavailableReason(entry) {
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
}
function setProviderEnabled(providerId, on) {
@@ -276,9 +315,18 @@ export function createStreetLevelLayer({
notify();
}
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (!providerAvailable(entry)) return '';
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return `zoom in to a street to open ${entry.def.name}`;
return stats.hint || '';
}
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
const stats = entry.instance.coverageStats();
const available = providerAvailable(entry);
return {
id: entry.def.id,
name: entry.def.name,
@@ -291,9 +339,11 @@ export function createStreetLevelLayer({
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
hint: hintFor(entry, stats),
error: stats.error || null,
color: entry.def.colors.coverage,
/** Why the provider cannot be used right now, or null. */
unavailable: available ? null : unavailableReason(entry),
};
});
}
@@ -336,7 +386,13 @@ export function createStreetLevelLayer({
source: definitionsFrozen.map((def) => def.name).join(' · '),
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
/** The key the switched-on providers share (or null), for the layer row. */
get requiresKeyId() {
const on = [...state.providers.values()].filter((entry) => entry.on);
return requiresKeyIdFor(
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
);
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
@@ -437,9 +493,11 @@ export function createStreetLevelLayer({
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
},
@@ -482,7 +540,7 @@ export function createStreetLevelLayer({
* Open the nearest image any active provider (or only `providerIds`)
* has around a point, by default the view centre.
*/
async openNearest(point, { providerIds = null } = {}) {
async openNearest(point, { providerIds = null, frame = true } = {}) {
const view = point || viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
@@ -494,9 +552,10 @@ export function createStreetLevelLayer({
state.street.error = null;
notify();
let lastError = null;
const entries = activeEntries().filter(
(entry) => !providerIds || providerIds.includes(entry.def.id),
);
const asked = (entry) =>
(!providerIds || providerIds.includes(entry.def.id)) &&
providerAvailable(entry);
const entries = activeEntries().filter(asked);
for (const entry of entries) {
let imageId = null;
try {
@@ -516,13 +575,11 @@ export function createStreetLevelLayer({
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
nearestLookup = null;
return openImage(entry.def.id, imageId);
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(
(entry) => !providerIds || providerIds.includes(entry.def.id),
);
const stillOn = activeEntries().some(asked);
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
+93
View File
@@ -482,3 +482,96 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
assert.equal(provider.calls.activate, 1);
assert.equal(viewer.credits.length, 1);
});
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) {
const { createGoogleProvider } = await import('./providers/google/index.js');
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
await import('../../testSupport/googleStreetViewFakes.mjs');
const fake = fakeStreetViewLibrary({
panoramas: {
'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' },
},
nearest: nearest ?? (() => 'pano-a'),
});
const mapillaryLookups = [];
const mapillary = fakeProvider({
nearestImage: async (point) => {
mapillaryLookups.push(point);
return null;
},
});
mapillary.requiresKeyId = 'mapillary';
const google = createGoogleProvider({
getApiKey: () => 'browser-key',
loader: fakeMapsLoader(fake.library),
});
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack);
layer.attachViewerHost(fakeHost());
return { layer, viewer, stack, fake, mapillaryLookups };
}
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
const { layer } = await withStreetView(t);
const google = () =>
layer.getUIState().providers.find((p) => p.id === 'google');
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');
});
test('Street View opens only on the Google 3D map, and leaving that map closes it', async (t) => {
const { layer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
await settle();
const google = () =>
layer.getUIState().providers.find((p) => p.id === 'google');
assert.match(google().unavailable, /needs the Google 3D map/);
assert.equal(google().hint, '', 'no "click a street" off Google 3D');
assert.equal(await layer.openImage('google', 'pano-a'), false);
assert.match(layer.getUIState().street.error, /needs the Google 3D map/);
stack.switchTo('photoreal');
assert.equal(google().unavailable, null);
assert.equal(await layer.openImage('google', 'pano-a'), true);
assert.equal(layer.getUIState().street.providerId, 'google');
stack.switchTo('bing');
await settle();
assert.equal(layer.getUIState().street.open, false, 'closed with the map');
});
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
const { layer, mapillaryLookups } = await withStreetView(t, {
nearest: () => null,
});
layer.setProviderEnabled('google', true);
await settle();
const opened = await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(opened, false);
assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked');
assert.equal(
layer.getUIState().street.error,
'No street-level imagery within 50 m of that point',
);
});
test('an image opened from a ground click leaves the camera where it is', async (t) => {
const { layer, viewer } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(layer.getUIState().street.imageId, 'pano-a');
assert.equal(viewer.flights.started, 0, 'no framing flight');
});
@@ -1,4 +1,5 @@
import { passesImageryFilter } from '../../filter.js';
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js';
import {
@@ -8,13 +9,14 @@ import {
GOOGLE_LABEL,
GOOGLE_NAME,
GOOGLE_PROVIDER_ID,
FLAT_FILTER_HINT,
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
imageDateMs,
imageMonthEndMs,
} from './policy.js';
/**
@@ -69,6 +71,8 @@ export function createGoogleProvider({
colors: COLORS,
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
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.
defaultOn: false,
@@ -76,12 +80,21 @@ export function createGoogleProvider({
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();
return service;
}
const viewer = createGoogleViewer({
loader,
getService,
render: context.services?.render,
});
const stopAuthWatch = loader.onAuthFailure(() => {
context.actions.reportError(KEY_REJECTED_MESSAGE);
// Switched off since: its chip says so; no error over another photo.
if (context.isActive?.())
context.actions.reportError(KEY_REJECTED_MESSAGE);
context.notify();
});
@@ -120,7 +133,11 @@ export function createGoogleProvider({
kind: null,
loading: false,
hint:
active && !keyRequired && !keyRejected ? GROUND_CLICK_HINT : '',
!active || keyRequired || keyRejected
? ''
: context.getFilter().pano === 'flat'
? FLAT_FILTER_HINT
: GROUND_CLICK_HINT,
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired,
keyRejected,
@@ -140,13 +157,14 @@ export function createGoogleProvider({
if (filter.pano === 'flat') return null;
const lookup = (async () => {
const lib = await loader.importLibrary('streetView');
service ||= new lib.StreetViewService();
await getService();
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
sources: GOOGLE ? [GOOGLE, OUTDOOR] : [OUTDOOR],
// Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, OUTDOOR],
preference: lib.StreetViewPreference.NEAREST,
});
return data;
@@ -163,10 +181,12 @@ export function createGoogleProvider({
const panoId = data?.location?.pano;
if (!panoId) return null;
const passes = passesImageryFilter(
{ isPano: true, capturedAt: imageDateMs(data.imageDate) ?? 0 },
{ isPano: true, capturedAt: imageMonthEndMs(data.imageDate) ?? 0 },
filter,
);
return passes ? panoId : null;
if (!passes) return null;
viewer.remember(panoId, data);
return panoId;
},
viewer,
@@ -19,9 +19,13 @@ export function createMapsLoader({
/** The script load in flight or done; cleared when it fails, so a retry can. */
let loading = null;
let authFailed = false;
let authHandlerInstalled = false;
const authListeners = new Set();
/** Once per loader: a retried load must not wrap its own handler again. */
function installAuthHandler() {
if (authHandlerInstalled) return;
authHandlerInstalled = true;
const previous = global.gm_authFailure;
global.gm_authFailure = () => {
authFailed = true;
@@ -93,3 +93,21 @@ test("Google's refusal of the key reaches every listener and the page's own hand
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
});
test('a retried load still reports a refusal once, and the page handler once', async () => {
const page = fakePage();
let before = 0;
page.global.gm_authFailure = () => before++;
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const heard = [];
loader.onAuthFailure(() => heard.push('a'));
const failed = loader.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed);
loader.importLibrary('streetView');
await settle();
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
assert.equal(before, 1);
});
@@ -20,16 +20,24 @@ 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 for the Maps JavaScript API. */
/**
* 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.
*/
export const KEY_REJECTED_MESSAGE =
'Google rejected GOOGLE_MAPS_API_KEY for Street View — enable the Maps JavaScript API for it';
'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. */
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;
/** Google answered nothing (most often: the Maps JavaScript API is not enabled). */
/** Google answered nothing: the network, or a key Google ignores. */
export const NO_ANSWER_MESSAGE =
'Street View did not answer — check that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
/** Under FLAT, in place of the click hint: every Street View photo is 360°. */
export const FLAT_FILTER_HINT = 'Street View has 360° photos only';
/** 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';
@@ -55,6 +63,17 @@ export function imageDateMs(imageDate) {
return Date.UTC(Number(match[1]), month - 1, 1);
}
/**
* The last millisecond of an image's month: Google dates by month, so a
* "since" window keeps a panorama taken any time in its cut-off month.
*/
export function imageMonthEndMs(imageDate) {
const start = imageDateMs(imageDate);
if (start === null) return null;
const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) - 1;
}
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) {
const text = String(copyright ?? '')
@@ -12,6 +12,7 @@ import { PROVIDER_COLORS } from '../../policy.js';
import { requiresKeyIdFor, validateProviders } from '../../registry.js';
import { createMapillaryProvider } from '../mapillary/index.js';
import {
fakeHost,
fakeMapsLoader,
fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs';
@@ -28,13 +29,27 @@ function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
const def = createGoogleProvider({ getApiKey: () => apiKey, loader });
const reported = [];
let filter = { pano: 'all', sinceMs: null };
let active = false;
const context = {
services: {},
getFilter: () => filter,
isActive: () => active,
notify() {},
actions: { reportError: (message) => reported.push(message) },
};
const instance = def.create(context);
const created = def.create(context);
// The core switches it on and off; mirror that for isActive().
const instance = {
...created,
activate() {
active = true;
created.activate();
},
deactivate() {
active = false;
created.deactivate();
},
};
return {
def,
instance,
@@ -106,7 +121,7 @@ test('no panorama nearby, or one the imagery filter excludes, is null', async ()
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
});
test('an aborted lookup gives up before and after asking Google', async () => {
test('an aborted lookup gives up before asking Google', async () => {
const { instance } = setup();
const controller = new AbortController();
controller.abort();
@@ -190,3 +205,52 @@ test('aborting a lookup in flight gives up at once', async () => {
controller.abort();
await assert.rejects(lookup, { name: 'AbortError' });
});
test('a Google outage is an error, not "no imagery here"', async () => {
const { instance, fake } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
Promise.reject(
Object.assign(new Error('UNKNOWN_ERROR'), {
code: 'UNKNOWN_ERROR',
endpoint: 'STREETVIEW_GET_PANORAMA',
}),
);
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
code: 'UNKNOWN_ERROR',
});
});
test('a refusal while the provider is off reports no error over another photo', () => {
const { instance, loader, reported } = setup();
loader.refuseKey();
assert.deepEqual(reported, []);
assert.equal(
instance.coverageStats().keyRejected,
true,
'its chip still says so',
);
});
test('a since window keeps a panorama taken any time in its cut-off month', async () => {
const { instance, setFilter } = setup({ nearest: () => 'fresh' }); // 2025-06
setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 5, 20) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'fresh');
setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 6, 1) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null);
});
test('the panorama a lookup found is dated without a second lookup', async () => {
const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 });
const host = fakeHost();
const poses = [];
instance.viewer.onPose((pose) => poses.push(pose));
await instance.viewer.mount(host);
await instance.viewer.open('fresh');
assert.equal(poses.at(-1).capturedAt, Date.UTC(2025, 5, 1));
assert.deepEqual(
fake.lookups.filter((request) => request.pano),
[],
'no getPanorama({pano}) for a panorama the lookup already described',
);
});
@@ -1,14 +1,13 @@
import {
GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE,
OPEN_TIMEOUT_MS,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
/** An open that sees no panorama by then has failed. */
const OPEN_TIMEOUT_MS = 20_000;
/** Google's own controls, minus those the Street Level panel already has. */
const PANORAMA_OPTIONS = Object.freeze({
visible: false,
@@ -31,21 +30,26 @@ 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.
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, render?: object}} options
* @returns {import('../../registry.js').ViewerAdapter}
* `getService` hands out the StreetViewService the provider shares.
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, getService?: () => Promise<object>, render?: object}} options
* @returns {import('../../registry.js').ViewerAdapter & {remember: Function}}
*/
export function createGoogleViewer({ loader, render } = {}) {
export function createGoogleViewer({ loader, getService, render } = {}) {
let library = null;
let panorama = null;
let element = null;
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. */
let generation = 0;
let subscriptions = [];
/** Panorama id → its {capturedAt, creator} lookup. */
const metadata = new Map();
/** Panorama id → its {capturedAt, creator, title}. */
const described = new Map();
/** Panorama ids whose lookup is in flight. */
const lookingUp = new Set();
const listeners = new Set();
function requestRender() {
@@ -67,41 +71,44 @@ export function createGoogleViewer({ loader, render } = {}) {
}
}
function metaFrom(data) {
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
}
/**
* Date and photographer of a panorama, looked up once: pano_changed and
* position_changed arrive together, so the lookup itself is shared.
* Look up a panorama's date, photographer and address once; the pose
* already out is sent again with them. A failed lookup is asked again on
* the next event.
*/
function describe(panoId) {
if (!metadata.has(panoId)) metadata.set(panoId, lookUp(panoId));
return metadata.get(panoId);
}
async function lookUp(panoId) {
async function describe(panoId) {
if (described.has(panoId) || lookingUp.has(panoId)) return;
lookingUp.add(panoId);
try {
service ||= new library.StreetViewService();
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
const { data } = await service.getPanorama({ pano: panoId });
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
described.set(panoId, metaFrom(data));
} catch {
// The pose still goes out, without a date.
return { capturedAt: null, creator: null, title: null };
return;
} finally {
lookingUp.delete(panoId);
}
publishPose();
}
async function publishPose() {
const shown = panorama;
const panoId = shown?.getPano?.();
const position = shown?.getPosition?.();
if (!panoId || !position || pendingOpen === null) return;
const meta = await describe(panoId);
// Closed, unmounted or moved on while the date was looked up.
if (pendingOpen === null || shown !== panorama) return;
if (panorama.getPano() !== panoId) return;
/** Emit the pose on screen now, dated once its lookup has answered. */
function publishPose() {
const panoId = panorama?.getPano?.();
const latLng = panorama?.getPosition?.();
if (!panoId || !latLng || pendingOpen === null) return;
const meta = described.get(panoId);
if (!meta) describe(panoId);
const pov = panorama.getPov() || {};
const latLng = panorama.getPosition() || position;
emit({
providerId: GOOGLE_PROVIDER_ID,
imageId: panoId,
@@ -110,10 +117,10 @@ export function createGoogleViewer({ loader, render } = {}) {
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
altitude: null,
isPano: true,
capturedAt: meta.capturedAt,
capturedAt: meta?.capturedAt ?? null,
capturedAtPrecision: 'month',
creator: meta.creator,
title: meta.title,
creator: meta?.creator ?? null,
title: meta?.title ?? null,
sequenceId: null,
externalUrl: streetViewUrl(panoId, {
heading: pov.heading || 0,
@@ -136,8 +143,10 @@ export function createGoogleViewer({ loader, render } = {}) {
target.append(element);
host = target;
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
subscriptions = ['pano_changed', 'position_changed', 'pov_changed'].map(
(name) => panorama.addListener(name, () => publishPose()),
// Not pano_changed: it can fire before the new panorama's data, with the
// old position. A step along an arrow moves the position.
subscriptions = ['position_changed', 'pov_changed'].map((name) =>
panorama.addListener(name, () => publishPose()),
);
return panorama;
}
@@ -157,41 +166,42 @@ export function createGoogleViewer({ loader, render } = {}) {
}
/**
* Resolves once `panoId` shows; rejects when Google has no imagery for it,
* or refuses the key (which it reports apart from the panorama's status).
* 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.
*/
function shown(instance, panoId) {
if (instance.getPano() === panoId && instance.getStatus() === 'OK')
return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
done();
reject(new Error('Street View did not answer'));
}, OPEN_TIMEOUT_MS);
const stopAuthWatch = loader.onAuthFailure(() => {
done();
reject(new Error(KEY_REJECTED_MESSAGE));
});
const subscription = instance.addListener('status_changed', () => {
if (pendingOpen !== panoId) {
done();
resolve();
return;
}
// Google may answer with a newer id for the same place: any OK counts.
const timer = setTimeout(
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
OPEN_TIMEOUT_MS,
);
const stopAuthWatch = loader.onAuthFailure(() =>
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
);
cancelShown = () => finish(resolve);
const check = () => {
if (pendingOpen !== panoId) return finish(resolve);
const status = instance.getStatus();
if (status === 'OK') {
done();
resolve();
} else if (status && status !== 'OK') {
done();
reject(new Error('Street View has no imagery for this place'));
}
});
function done() {
if (status === 'OK') finish(resolve);
else if (status)
finish(
reject,
new Error('Street View has no imagery for this place'),
);
};
const subscriptions = ['status_changed', 'position_changed'].map((name) =>
instance.addListener(name, check),
);
function finish(settle, value) {
cancelShown = null;
clearTimeout(timer);
subscription.remove();
for (const subscription of subscriptions) subscription.remove();
stopAuthWatch();
settle(value);
}
});
}
@@ -215,11 +225,12 @@ export function createGoogleViewer({ loader, render } = {}) {
instance.setVisible(true);
await ready;
if (pendingOpen !== panoId) return;
await publishPose();
publishPose();
},
close() {
pendingOpen = null;
cancelShown?.();
try {
panorama?.setVisible(false);
} catch {
@@ -229,6 +240,7 @@ export function createGoogleViewer({ loader, render } = {}) {
unmount() {
pendingOpen = null;
cancelShown?.();
generation++;
destroyPanorama();
},
@@ -255,5 +267,10 @@ export function createGoogleViewer({ loader, render } = {}) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Keep a lookup's answer, so the panorama it found needs no second one. */
remember(panoId, data) {
if (panoId && data) described.set(String(panoId), metaFrom(data));
},
};
}
@@ -153,7 +153,128 @@ test('a key Google refuses fails the open at once, not after the timeout', async
panorama.pano = id;
queueMicrotask(() => loader.refuseKey());
};
await assert.rejects(viewer.open('pano-a'), /enable the Maps JavaScript API/);
await assert.rejects(viewer.open('pano-b'), /enable the Maps JavaScript API/);
await assert.rejects(viewer.open('pano-a'), /Maps JavaScript API is enabled/);
await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/);
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind');
});
test('a viewer unmounted while the library loads builds no panorama (none is billed)', async () => {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library, { deferred: true });
const viewer = createGoogleViewer({ loader });
const host = fakeHost();
const mounting = viewer.mount(host);
viewer.unmount();
loader.release();
await assert.rejects(mounting, /unmounted/);
assert.equal(fake.built.length, 0);
assert.equal(host.children.length, 0);
});
test('a date that arrives after the photo was closed emits nothing', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
let answer;
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise((resolve) => {
answer = resolve;
});
panorama.setPano('pano-b'); // Google's arrow; its date is still unknown
await settle();
assert.equal(poses.at(-1).imageId, 'pano-b', 'the step shows at once');
assert.equal(
poses.at(-1).capturedAt,
null,
'undated until the lookup answers',
);
viewer.close();
const count = poses.length;
answer({ data: { imageDate: '2019-08' } });
await settle();
assert.equal(poses.length, count);
});
test('an open never waits on the date lookup', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {}); // the metadata never answers
await viewer.open('pano-a');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('closing while a panorama loads settles the open and drops its listeners', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // still loading
};
const opening = viewer.open('pano-a');
await settle();
viewer.close();
await opening;
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('a second open whose status stays OK still resolves, on the new position', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
assert.equal(fake.built[0].getStatus(), 'OK');
await viewer.open('pano-b'); // OK → OK: no status_changed
assert.equal(poses.at(-1).imageId, 'pano-b');
});
test('Google answering with a newer id for the place shows that id', async () => {
const fake = fakeStreetViewLibrary({
panoramas: PANORAMAS,
substitutes: { 'old-id': 'pano-a' },
});
const viewer = createGoogleViewer({ loader: fakeMapsLoader(fake.library) });
const poses = [];
viewer.onPose((pose) => poses.push(pose));
await viewer.mount(fakeHost());
await viewer.open('old-id');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('a panorama that never shows fails after the timeout and leaves no listener', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { OPEN_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // Google never answers
};
const opening = viewer.open('pano-a');
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(OPEN_TIMEOUT_MS);
await assert.rejects(opening, { message: NO_ANSWER_MESSAGE });
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('a date lookup that failed is asked again on the next view change', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
let failures = 1;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
if (failures-- > 0) return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
fake.built[0].setPov({ heading: 10, pitch: 0 });
await settle();
await settle();
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
});
+2 -1
View File
@@ -10,11 +10,12 @@
* @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); match the share-link option default.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it).
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
+2 -2
View File
@@ -21,9 +21,9 @@ export function createSelection({ state, parts }) {
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
// Nothing of ours, nor anyone else's: the ground itself.
// Not ours: the ground handler decides whether it is the map itself.
if (!route?.instance) {
if (!id) parts.openAtGround?.(click.position);
parts.openAtGround?.(click.position, picked);
return;
}
const entry = state.providers.get(route.providerId);
+10 -5
View File
@@ -67,7 +67,8 @@ function harness({ selected = false } = {}) {
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
openAtGround: (position) => groundClicks.push(position),
openAtGround: (position, picked) =>
groundClicks.push({ position, picked }),
clearSequences() {
sequences.cleared++;
sequences.selected = false;
@@ -264,15 +265,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
}
});
test('a click on bare ground goes to the ground handler; a click on anything with an id does not', () => {
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
h.click(5);
assert.deepEqual(h.groundClicks, [{ x: 5, y: 10 }]);
// Another layer's aircraft, or an id nobody registered: not the ground.
h.scene.under = 'flight:abc';
h.click(6);
assert.equal(h.groundClicks.length, 1);
assert.deepEqual(
h.groundClicks.map(({ position, picked }) => [position.x, picked?.id]),
[
[5, undefined],
[6, 'flight:abc'],
],
);
} finally {
h.restore();
}
+3
View File
@@ -56,6 +56,9 @@ export function createState({ services }) {
...freshStreet(),
},
/** Whether the camera is low enough for a ground click to open an image. */
groundClickReady: false,
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
-31
View File
@@ -295,34 +295,3 @@ export function createHorizonCull({ getViewer, items, onChange }) {
return { update, stop };
}
/**
* The ground under a screen position as {lon, lat}: the rendered surface
* (Google 3D, terrain) where it was picked, else the ellipsoid. Null over sky
* or for a pick that names no place (`isWorldPosition` rejects it).
*/
export function groundPointAt(viewer, screenPosition, { isWorldPosition }) {
const scene = viewer?.scene;
let cartesian = null;
if (scene?.pickPositionSupported) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (!isWorldPosition(cartesian)) {
try {
cartesian = viewer?.camera?.pickEllipsoid(screenPosition);
} catch {
cartesian = null;
}
}
if (!isWorldPosition(cartesian)) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
+7 -4
View File
@@ -79,7 +79,6 @@ export function createViewerHost({ state, parts }) {
warm.unmount();
}
const adapter = entry.instance.viewer;
state.street.renderModes = typeof adapter.setRenderMode === 'function';
const promise = (async () => {
await adapter.mount(state.street.host);
if (mounting?.promise !== promise) {
@@ -139,6 +138,8 @@ export function createViewerHost({ state, parts }) {
try {
const adapter = await mount(entry);
if (!current()) return false;
// Per open: close() resets it, and a reopen skips mount().
state.street.renderModes = typeof adapter.setRenderMode === 'function';
await adapter.open(String(imageId));
if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
@@ -201,9 +202,11 @@ export function createViewerHost({ state, parts }) {
} catch {
/* the real open reports errors */
}
if (active?.id === entry.def.id) continue;
// Switched off while it loaded: release it now rather than keep it.
if (!state.enabled || !entry.on) adapter.unmount();
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.
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
}
@@ -633,3 +633,29 @@ test('a pose can date its image by month and name its place', async () => {
['day', null],
);
});
test('a prewarm that lands after another provider took the host is released, not built under it', async () => {
const { state, host, mapillary } = twoProviders();
let release;
mapillary.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const warming = host.prewarm([state.providers.get('mapillary')]);
await host.open('google', 'g-1'); // the user opens Street View meanwhile
release();
await warming;
assert.equal(mapillary.calls.unmount, 1, 'released at once');
// Not kept warm: the next Mapillary open mounts afresh.
await host.open('mapillary', 'm-1');
assert.equal(state.street.providerId, 'mapillary');
});
test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => {
const { state, host } = twoProviders();
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false);
host.close();
await host.open('google', 'g-2');
assert.equal(state.street.renderModes, false);
});
+36 -11
View File
@@ -1,7 +1,11 @@
/**
* Stand-ins for the Google Maps JavaScript API's Street View library and the
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate, copyright}};
* `nearest(location, radius)` answers location lookups with an id or null.
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate,
* copyright, description}}; `nearest(location, radius)` answers location
* lookups with an id or null; `substitutes` maps an id Google answers with a
* newer one. Like Google, `setPano` sets the id (pano_changed) at once, and
* the position and status follow when the data arrives; status_changed fires
* only when the status changes.
*/
function latLng(lat, lng) {
@@ -11,6 +15,7 @@ function latLng(lat, lng) {
export function fakeStreetViewLibrary({
panoramas = {},
nearest = () => null,
substitutes = {},
} = {}) {
const built = [];
const lookups = [];
@@ -22,6 +27,7 @@ export function fakeStreetViewLibrary({
this.visible = options?.visible ?? true;
this.pano = null;
this.status = null;
this.position = null;
this.pov = { heading: 0, pitch: 0 };
this.listeners = new Map();
built.push(this);
@@ -43,18 +49,27 @@ export function fakeStreetViewLibrary({
return count;
}
/** Like Google: the panorama loads asynchronously, then reports. */
/** Like Google: the id is set at once; the data loads asynchronously. */
setPano(id) {
this.pano = id;
this.fire('pano_changed');
queueMicrotask(() => {
if (this.pano !== id) return;
const known = panoramas[id];
this.status = known ? 'OK' : 'ZERO_RESULTS';
if (known) {
const shownId = substitutes[id] ?? id;
const known = panoramas[shownId];
const status = known ? 'OK' : 'ZERO_RESULTS';
if (shownId !== id) {
this.pano = shownId;
this.fire('pano_changed');
}
if (known) {
this.position = latLng(known.lat, known.lng);
this.fire('position_changed');
}
this.fire('status_changed');
if (status !== this.status) {
this.status = status;
this.fire('status_changed');
}
});
}
@@ -67,8 +82,7 @@ export function fakeStreetViewLibrary({
}
getPosition() {
const known = panoramas[this.pano];
return known ? latLng(known.lat, known.lng) : null;
return this.position;
}
getPov() {
@@ -121,17 +135,28 @@ export function fakeStreetViewLibrary({
};
}
/** A loader over `library`; `refuseKey()` plays Google's gm_authFailure. */
export function fakeMapsLoader(library) {
/**
* A loader over `library`; `refuseKey()` plays Google's gm_authFailure. With
* `deferred`, imports wait for `release()`.
*/
export function fakeMapsLoader(library, { deferred = false } = {}) {
let failed = false;
const listeners = new Set();
const imports = [];
let release = null;
const gate = deferred
? new Promise((resolve) => {
release = resolve;
})
: null;
return {
imports,
async importLibrary(name) {
imports.push(name);
await gate;
return library;
},
release: () => release?.(),
authFailed: () => failed,
onAuthFailure(listener) {
listeners.add(listener);
+5
View File
@@ -37,6 +37,11 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
'https://graph.mapillary.com',
// Google Street View's tiles, controls and contributed photos (the Maps
// JavaScript API itself comes from maps.googleapis.com above).
'https://streetviewpixels-pa.googleapis.com',
'https://maps.gstatic.com',
'https://*.ggpht.com',
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
'https://*.xx.fbcdn.net',
]);
+17
View File
@@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async ()
assert.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/);
});
test('the panel lets Street Level reach Google Street View from inside the embed', () => {
const { csp } = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
})._meta.ui;
// The API script and lookups, panorama tiles, control images, contributed photos.
for (const url of [
'https://maps.googleapis.com/maps/api/js?key=k&v=weekly',
'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1',
'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png',
'https://lh3.ggpht.com/p/photo.jpg',
]) {
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
}
});
+9 -5
View File
@@ -80,6 +80,8 @@ function presentProviders(state) {
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
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 {
id: provider.id,
label: provider.label,
@@ -89,11 +91,13 @@ function presentProviders(state) {
disabled: false,
state: keyRequired
? 'error'
: on && provider.loading
? 'loading'
: on
? 'active'
: 'idle',
: on && provider.unavailable
? 'warn'
: on && provider.loading
? 'loading'
: on
? 'active'
: 'idle',
busy: on && provider.loading === true,
};
});
+6
View File
@@ -183,6 +183,12 @@ button.sl-status:focus-visible {
border-color: rgba(255, 179, 0, 0.55);
}
/* On, but not usable on this map stack (Street View off Google 3D). */
.sl-chips .data-toggle-chip.chip-warn {
opacity: 0.7;
border-style: dashed;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error {
font-family: var(--font-sans);