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] ## [Unreleased]
- Street Level: Google Street View is the second provider. Light its chip - Street Level: Google Street View is the second provider. On the Google 3D
(off by default: every panorama is billed to the Google Maps key, which map (Google's terms keep it off other maps), light its chip (off by
needs the Maps JavaScript API) and click a street to open Google's own default: every panorama is billed to the Google Maps key, which needs the
panorama in the panel, with its arrows, the address and month in the Maps JavaScript API) and click a street to open Google's own panorama in
caption, a link to Google Maps and FOLLOW on Google 3D. Share links carry the panel, with its arrows, the address and month in the caption, a link
it as `g`. 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 - 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 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 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" | | **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 | | **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 | | **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 | | **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 | | **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. **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`. 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: 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. 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. 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 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`). 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 offers no coverage layer for panorama load is billed to the key. Google's terms forbid Street View beside
other maps, so it draws none: with it on, a click on bare ground (nothing non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
picked, so a Mapillary line under the pointer wins; no tool holding the off Google 3D its chip is dashed with the reason, lookups and opens skip it,
pointer; camera below 3 km) opens its nearest and leaving Google 3D closes its panorama. Google offers no coverage layer
Google-collected outdoor panorama within 50 m (`groundClick.js`). The viewer for other maps, so it draws none: with it on, a click on the map itself
is Google's `StreetViewPanorama` in its own element in the viewer host, (`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, so a
without Google's fullscreen, close and address controls (the address goes in Mapillary line under the pointer wins; no tool holding the pointer; camera
the caption); its poses carry the month the panorama was taken. A key Google below 3 km) opens its nearest Google-collected outdoor panorama within 50 m
refuses (`gm_authFailure`) reads KEY REJECTED and fails a pending open at and leaves the camera where it is (`groundClick.js`). Higher up the hint
once. The gate's Street View steps skip with `no Google key` in CI. 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>` 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
+1
View File
@@ -899,6 +899,7 @@
"src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js", "src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs", "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/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs", "src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.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 nextFrames(page);
}; };
await drag(handlePoint(await readPanel(), 'se'), 0, 200); // Grow it from the corner once the restored window holds still, with the
const chosen = await readPanel(); // 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( assert.ok(
chosen.height > MIN_SIZE.height + 150, chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px`, `the window grew to ${chosen.height}px`,
+150 -104
View File
@@ -628,7 +628,8 @@ async function main() {
} }
await page.setViewport(VIEWPORTS[0]); await page.setViewport(VIEWPORTS[0]);
const expandStrip = async (target = page) => { const expandStrip = async (target = page) => {
await target.click( await clickPanelControl(
target,
'.panel-collapse-btn[data-collapse-target="street-level-panel"]', '.panel-collapse-btn[data-collapse-target="street-level-panel"]',
); );
await settle( await settle(
@@ -1774,116 +1775,161 @@ async function main() {
`the share link carries ${option}`, `the share link carries ${option}`,
); );
assert.ok((await ui()).coverage.count > 0, 'Mapillary still draws'); 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( 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 }) => { async ({ skip }) => {
if (!googleKey) return skip('no Google key'); if (!googleKey) return skip('no Google key');
// A Mapillary line under the pointer wins the click: aim at bare // Google's terms: Street View only beside Google's own map.
// ground with only Street View on. if (!(await stacks.photoreal())) return skip('no Google 3D');
await page.evaluate(() => const original = await stacks.active();
window.__godsEyeView.dataManager.layers const closePhoto = () =>
.get('street-level') page.evaluate(() =>
.module.closeViewer(), window.__godsEyeView.dataManager.layers
); .get('street-level')
await clickPanelControl(page, chip); .module.closeViewer(),
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),
); );
const canvas = v.scene.cartesianToCanvasCoordinates(ground); const setOn = async (selector, id, on) => {
const box = v.scene.canvas.getBoundingClientRect(); const now = (await ui()).providers.find((p) => p.id === id)?.on;
return canvas && { x: box.left + canvas.x, y: box.top + canvas.y }; if (now === on) return;
}, street); await clickPanelControl(page, selector);
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(
await uiUntil( await uiUntil(
(u, before) => (u, want) =>
Number.isFinite(u.street.bearing) && u.providers.find((p) => p.id === want.id)?.on === want.on,
Math.abs(u.street.bearing - before) > 5, { id, on },
opened.bearing ?? 0, );
{ timeout: 10_000 }, };
), await closePhoto();
'the pose bearing follows the drag', await stacks.set('photoreal');
); // A Mapillary line under the pointer wins the click: aim at bare
await clickPanelControl(page, '#sl-viewer-close'); // ground with only Street View on (lit first: the last lit chip
await uiUntil((u) => u.street.open === false); // would switch the layer off).
await clickPanelControl(page, chip); await setOn(googleChip, 'google', true);
await clickPanelControl(page, googleChip); await setOn(chip, 'mapillary', false);
await uiUntil( try {
(u) => const street = STREET_VIEW_STREET;
u.providers.find((p) => p.id === 'google')?.on === false && await page.evaluate((at) => {
u.providers.find((p) => p.id === 'mapillary')?.on === true, 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( 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); const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M; 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 * as Cesium from 'cesium';
import { isPickedWorldPosition } from './scenePick.js'; import { isPickedWorldPosition } from './scenePick.js';
const describe = (position) => (position const describe = (position) =>
? `(${position.x}, ${position.y}, ${position.z})` position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
: String(position));
test('a degenerate depth pick is rejected before it reaches Cesium', () => { test('a degenerate depth pick is rejected before it reaches Cesium', () => {
// These are the shapes `scene.pickPosition()` can produce when the depth read // 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), new Cesium.Cartesian3(0.2, -0.3, 0.1),
]; ];
for (const position of degenerate) { 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), new Cesium.Cartesian3(0, 0, 5_999_999),
]; ];
for (const position of belowTheFloor) { 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. // Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
const carto = Cesium.Cartographic.fromCartesian(position); const carto = Cesium.Cartographic.fromCartesian(position);
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`); 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 = [ 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), new Cesium.Cartesian3(1e155, 0, 0),
]; ];
for (const position of aboveTheCeiling) { 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( assert.throws(
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)), () => 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. // 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(
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true); 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', () => { 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), Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
]; ];
for (const position of real) { 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 // The floor has real margin under the smallest legitimate magnitude, so no
// plausible pick sits anywhere near the boundary. // plausible pick sits anywhere near the boundary.
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935)); const polarSurface = Cesium.Cartesian3.magnitude(
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`); 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', () => { 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 // 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. // the guard needs a magnitude band, not just a finiteness check.
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1); 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); assert.equal(isPickedWorldPosition(nearCenter), false);
// …and every position the guard accepts must convert to the right place. // …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.longitude) + 97.7431) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 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) { function pickGround(screenPosition) {
const scene = _viewer?.scene; const cartesian = services.scenePick.pickGroundPosition(
let cartesian = null; _viewer,
if ( screenPosition,
scene?.pickPositionSupported && );
typeof scene.pickPosition === 'function' if (!cartesian) return null;
) {
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 carto = Cesium.Cartographic.fromCartesian(cartesian); const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null; if (!carto) return null;
return { return {
+37 -19
View File
@@ -1,31 +1,49 @@
import * as Cesium from 'cesium';
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js'; 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 * A click on the map itself (nothing picked, or Google 3D tile content) at
* providers that have no coverage to click (`groundClick`, Google Street * street zoom opens the nearest image of the active, available providers
* View). `openNearest(point, {providerIds})` is the layer's lookup. * that have no coverage to click (`groundClick`, Google Street View). The
* @returns {(screenPosition: {x: number, y: number}) => Promise<boolean>|false} * 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 }) { export function createGroundClick({ state, openNearest, isAvailable }) {
return function openAtGround(screenPosition) { return function openAtGround(screenPosition, picked) {
const isWorldPosition = state.services.scenePick?.isPickedWorldPosition; const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !isWorldPosition) return false; if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (!scenePick.isSurfacePick(picked) || !state.groundClickReady)
return false;
const providerIds = [...state.providers.values()] const providerIds = [...state.providers.values()]
.filter( .filter(
(entry) => (entry) =>
entry.on && entry.def.groundClick && entry.status?.configured, entry.on &&
entry.def.groundClick &&
entry.status?.configured &&
isAvailable(entry),
) )
.map((entry) => entry.def.id); .map((entry) => entry.def.id);
if (!providerIds.length) return false; if (!providerIds.length) return false;
// From higher up a click is a misclick, not a street. const position = scenePick.pickGroundPosition(state.viewer, screenPosition);
const height = cameraHeightAboveGround(state.viewer, { const carto = position && Cesium.Cartographic.fromCartesian(position);
groundAt: parts.groundCaster?.groundAt, if (!carto) return false;
}); return openNearest(
if (!(height <= GROUND_CLICK_MAX_HEIGHT_M)) return false; {
const point = groundPointAt(state.viewer, screenPosition, { lon: Cesium.Math.toDegrees(carto.longitude),
isWorldPosition, lat: Cesium.Math.toDegrees(carto.latitude),
}); },
return point ? openNearest(point, { providerIds }) : false; { providerIds, frame: false },
);
}; };
} }
+93 -50
View File
@@ -1,84 +1,127 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createGroundClick } from './groundClick.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { isPickedWorldPosition } from '../../data/scenePick.js'; import * as scenePick from '../../data/scenePick.js';
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10); 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({ function harness({
providers = [ providers = [
{ id: 'mapillary', groundClick: false }, { id: 'mapillary', groundClick: false },
{ id: 'google', groundClick: true }, { id: 'google', groundClick: true },
], ],
height = 400, ready = true,
enabled = true, enabled = true,
picked = GROUND, picked = GROUND,
ellipsoid = undefined,
} = {}) { } = {}) {
const opened = []; const opened = [];
const state = { const state = {
enabled, enabled,
services: { scenePick: { isPickedWorldPosition } }, groundClickReady: ready,
services: { scenePick },
providers: new Map( providers: new Map(
providers.map(({ id, on = true, groundClick, configured = true }) => [ providers.map(
id, ({
{ id,
on, on = true,
def: { id, groundClick }, groundClick,
status: { configured }, configured = true,
}, available = true,
]), }) => [
id,
{ on, def: { id, groundClick }, status: { configured }, available },
],
),
), ),
viewer: { viewer: {
scene: { scene: { pickPositionSupported: true, pickPosition: () => picked },
pickPositionSupported: true, camera: { pickEllipsoid: () => ellipsoid },
pickPosition: () => picked,
globe: { getHeight: () => 10 },
},
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
10 + height,
),
pickEllipsoid: () => undefined,
},
}, },
}; };
const openAtGround = createGroundClick({ const openAtGround = createGroundClick({
state, state,
parts: {},
openNearest: (point, options) => { openNearest: (point, options) => {
opened.push({ point, options }); opened.push({ point, options });
return Promise.resolve(true); 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 near = (point) =>
const { opened, openAtGround } = harness(); Math.abs(point.lon - -121.4944) < 1e-9 &&
openAtGround({ x: 10, y: 20 }); Math.abs(point.lat - 38.5816) < 1e-9;
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'] });
});
test('nothing opens from high up, over the sky, with the provider off or unconfigured, or the layer off', () => { test('a click on the map opens the click-to-open providers there, without moving the camera', () => {
for (const options of [ for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) {
{ height: 5000 }, const { opened, openAtGround } = harness();
{ picked: null }, openAtGround({ x: 10, y: 20 }, picked);
{ picked: new Cesium.Cartesian3(500, 0, 0) }, assert.equal(opened.length, 1, JSON.stringify(picked));
{ providers: [{ id: 'google', groundClick: true, on: false }] }, assert.ok(near(opened[0].point));
{ providers: [{ id: 'google', groundClick: true, configured: false }] }, assert.deepEqual(opened[0].options, {
{ providers: [{ id: 'mapillary', groundClick: false }] }, providerIds: ['google'],
{ enabled: false }, frame: false,
]) { });
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }), false, JSON.stringify(options));
assert.equal(opened.length, 0);
} }
}); });
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 { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js'; import { composeUIState, summarizeCoverage } from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.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 { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js'; import { createMeshSampler } from './meshSampler.js';
@@ -69,8 +69,8 @@ export function createStreetLevelLayer({
parts.selection = createSelection(context); parts.selection = createSelection(context);
parts.openAtGround = createGroundClick({ parts.openAtGround = createGroundClick({
state, state,
parts,
openNearest: (point, options) => layer.openNearest(point, options), openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
}); });
let notifyQueued = false; let notifyQueued = false;
@@ -146,6 +146,33 @@ export function createStreetLevelLayer({
notify(); 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 * Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands. * 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. */ /** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() { function syncSurface() {
syncGroundClickReady();
const available = Boolean(parts.groundCaster) && state.enabled; const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID; const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, { const mode = nextSurfaceMode(state.surface, {
@@ -247,9 +275,20 @@ export function createStreetLevelLayer({
nearestLookup = null; 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(); 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) { function setProviderEnabled(providerId, on) {
@@ -276,9 +315,18 @@ export function createStreetLevelLayer({
notify(); 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() { function providerSnapshots() {
return [...state.providers.values()].map((entry) => { return [...state.providers.values()].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,
@@ -291,9 +339,11 @@ export function createStreetLevelLayer({
requiresKeyId: entry.def.requiresKeyId || null, requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true, loading: stats.loading === true,
count: stats.count || 0, count: stats.count || 0,
hint: stats.hint || '', 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. */
unavailable: available ? null : unavailableReason(entry),
}; };
}); });
} }
@@ -336,7 +386,13 @@ export function createStreetLevelLayer({
source: definitionsFrozen.map((def) => def.name).join(' · '), source: definitionsFrozen.map((def) => def.name).join(' · '),
updateInterval: 0, updateInterval: 0,
statsRefreshInterval: 1000, 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. */ /** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id), providerIds: definitionsFrozen.map((def) => def.id),
@@ -437,9 +493,11 @@ export function createStreetLevelLayer({
unsubscribeMapStack = unsubscribeMapStack =
mapStack?.subscribe?.(() => { mapStack?.subscribe?.(() => {
syncFollowAvailability(); syncFollowAvailability();
syncProviderAvailability();
syncSurface(); syncSurface();
}) || null; }) || null;
syncFollowAvailability(); syncFollowAvailability();
syncProviderAvailability();
syncSurface(); syncSurface();
}, },
@@ -482,7 +540,7 @@ export function createStreetLevelLayer({
* 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, by default the view centre.
*/ */
async openNearest(point, { providerIds = null } = {}) { async openNearest(point, { providerIds = null, frame = true } = {}) {
const view = point || viewCentre(state.viewer); const view = point || 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;
@@ -494,9 +552,10 @@ export function createStreetLevelLayer({
state.street.error = null; state.street.error = null;
notify(); notify();
let lastError = null; let lastError = null;
const entries = activeEntries().filter( const asked = (entry) =>
(entry) => !providerIds || providerIds.includes(entry.def.id), (!providerIds || providerIds.includes(entry.def.id)) &&
); providerAvailable(entry);
const entries = activeEntries().filter(asked);
for (const entry of entries) { for (const entry of entries) {
let imageId = null; let imageId = null;
try { try {
@@ -516,13 +575,11 @@ export function createStreetLevelLayer({
// This provider was switched off while it looked: try the next. // This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue; if (!imageId || !entry.on) continue;
nearestLookup = null; nearestLookup = null;
return openImage(entry.def.id, imageId); return openImage(entry.def.id, imageId, { frame });
} }
if (nearestLookup === lookup) nearestLookup = null; if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report. // Switched off while it looked: nothing to open, nothing to report.
const stillOn = activeEntries().some( const stillOn = activeEntries().some(asked);
(entry) => !providerIds || providerIds.includes(entry.def.id),
);
if (state.enabled && stillOn) if (state.enabled && stillOn)
state.street.error = state.street.error =
lastError?.message || 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(provider.calls.activate, 1);
assert.equal(viewer.credits.length, 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 { passesImageryFilter } from '../../filter.js';
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js'; import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js'; import { createGoogleViewer } from './viewer.js';
import { import {
@@ -8,13 +9,14 @@ import {
GOOGLE_LABEL, GOOGLE_LABEL,
GOOGLE_NAME, GOOGLE_NAME,
GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_ID,
FLAT_FILTER_HINT,
GROUND_CLICK_HINT, GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE, KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M, NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS, NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE, NO_ANSWER_MESSAGE,
PICK_PREFIX, PICK_PREFIX,
imageDateMs, imageMonthEndMs,
} from './policy.js'; } from './policy.js';
/** /**
@@ -69,6 +71,8 @@ export function createGoogleProvider({
colors: COLORS, colors: COLORS,
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }), credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
groundClick: true, groundClick: true,
// Google's terms forbid Street View beside non-Google maps.
requiresMapStack: FOLLOW_MAP_STACK_ID,
// Every panorama is billed to the Maps key: the user lights the chip. // Every panorama is billed to the Maps key: the user lights the chip.
defaultOn: false, defaultOn: false,
@@ -76,12 +80,21 @@ export function createGoogleProvider({
let keyRequired = !getApiKey(); let keyRequired = !getApiKey();
let active = false; let active = false;
let service = null; 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({ const viewer = createGoogleViewer({
loader, loader,
getService,
render: context.services?.render, render: context.services?.render,
}); });
const stopAuthWatch = loader.onAuthFailure(() => { 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(); context.notify();
}); });
@@ -120,7 +133,11 @@ export function createGoogleProvider({
kind: null, kind: null,
loading: false, loading: false,
hint: 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, error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired, keyRequired,
keyRejected, keyRejected,
@@ -140,13 +157,14 @@ export function createGoogleProvider({
if (filter.pano === 'flat') return null; if (filter.pano === 'flat') return null;
const lookup = (async () => { const lookup = (async () => {
const lib = await loader.importLibrary('streetView'); const lib = await loader.importLibrary('streetView');
service ||= new lib.StreetViewService(); await getService();
const { GOOGLE, OUTDOOR } = lib.StreetViewSource; const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
try { try {
const { data } = await service.getPanorama({ const { data } = await service.getPanorama({
location: { lat, lng: lon }, location: { lat, lng: lon },
radius: NEAREST_RADIUS_M, radius: NEAREST_RADIUS_M,
sources: GOOGLE ? [GOOGLE, OUTDOOR] : [OUTDOOR], // Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, OUTDOOR],
preference: lib.StreetViewPreference.NEAREST, preference: lib.StreetViewPreference.NEAREST,
}); });
return data; return data;
@@ -163,10 +181,12 @@ export function createGoogleProvider({
const panoId = data?.location?.pano; const panoId = data?.location?.pano;
if (!panoId) return null; if (!panoId) return null;
const passes = passesImageryFilter( const passes = passesImageryFilter(
{ isPano: true, capturedAt: imageDateMs(data.imageDate) ?? 0 }, { isPano: true, capturedAt: imageMonthEndMs(data.imageDate) ?? 0 },
filter, filter,
); );
return passes ? panoId : null; if (!passes) return null;
viewer.remember(panoId, data);
return panoId;
}, },
viewer, viewer,
@@ -19,9 +19,13 @@ export function createMapsLoader({
/** The script load in flight or done; cleared when it fails, so a retry can. */ /** The script load in flight or done; cleared when it fails, so a retry can. */
let loading = null; let loading = null;
let authFailed = false; let authFailed = false;
let authHandlerInstalled = false;
const authListeners = new Set(); const authListeners = new Set();
/** Once per loader: a retried load must not wrap its own handler again. */
function installAuthHandler() { function installAuthHandler() {
if (authHandlerInstalled) return;
authHandlerInstalled = true;
const previous = global.gm_authFailure; const previous = global.gm_authFailure;
global.gm_authFailure = () => { global.gm_authFailure = () => {
authFailed = true; 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(); page.global.gm_authFailure();
assert.deepEqual(heard, ['a']); 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 = export const GOOGLE_CREDIT_HTML =
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>'; '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 = 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. */ /** A nearest-panorama lookup that takes longer has failed. */
export const NEAREST_TIMEOUT_MS = 10_000; 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 = 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. */ /** 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'; 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); 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'). */ /** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) { export function creatorFrom(copyright) {
const text = String(copyright ?? '') const text = String(copyright ?? '')
@@ -12,6 +12,7 @@ import { PROVIDER_COLORS } from '../../policy.js';
import { requiresKeyIdFor, validateProviders } from '../../registry.js'; import { requiresKeyIdFor, validateProviders } from '../../registry.js';
import { createMapillaryProvider } from '../mapillary/index.js'; import { createMapillaryProvider } from '../mapillary/index.js';
import { import {
fakeHost,
fakeMapsLoader, fakeMapsLoader,
fakeStreetViewLibrary, fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs'; } from '../../../../testSupport/googleStreetViewFakes.mjs';
@@ -28,13 +29,27 @@ function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
const def = createGoogleProvider({ getApiKey: () => apiKey, loader }); const def = createGoogleProvider({ getApiKey: () => apiKey, loader });
const reported = []; const reported = [];
let filter = { pano: 'all', sinceMs: null }; let filter = { pano: 'all', sinceMs: null };
let active = false;
const context = { const context = {
services: {}, services: {},
getFilter: () => filter, getFilter: () => filter,
isActive: () => active,
notify() {}, notify() {},
actions: { reportError: (message) => reported.push(message) }, 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 { return {
def, def,
instance, 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'); 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 { instance } = setup();
const controller = new AbortController(); const controller = new AbortController();
controller.abort(); controller.abort();
@@ -190,3 +205,52 @@ test('aborting a lookup in flight gives up at once', async () => {
controller.abort(); controller.abort();
await assert.rejects(lookup, { name: 'AbortError' }); 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 { import {
GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE, KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE,
OPEN_TIMEOUT_MS,
creatorFrom, creatorFrom,
imageDateMs, imageDateMs,
streetViewUrl, streetViewUrl,
} from './policy.js'; } 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. */ /** Google's own controls, minus those the Street Level panel already has. */
const PANORAMA_OPTIONS = Object.freeze({ const PANORAMA_OPTIONS = Object.freeze({
visible: false, visible: false,
@@ -31,21 +30,26 @@ const PANORAMA_OPTIONS = Object.freeze({
* Viewer adapter over Google's StreetViewPanorama. It renders in its own * 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 * 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. * panorama and view change, including steps along Google's own arrows.
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, render?: object}} options * `getService` hands out the StreetViewService the provider shares.
* @returns {import('../../registry.js').ViewerAdapter} * @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 library = null;
let panorama = null; let panorama = null;
let element = null; let element = null;
let host = null; let host = null;
let service = null; let service = null;
let pendingOpen = null; let pendingOpen = null;
/** Settles the open waiting for its panorama, when a close overtakes it. */
let cancelShown = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */ /** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0; let generation = 0;
let subscriptions = []; let subscriptions = [];
/** Panorama id → its {capturedAt, creator} lookup. */ /** Panorama id → its {capturedAt, creator, title}. */
const metadata = new Map(); const described = new Map();
/** Panorama ids whose lookup is in flight. */
const lookingUp = new Set();
const listeners = new Set(); const listeners = new Set();
function requestRender() { 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 * Look up a panorama's date, photographer and address once; the pose
* position_changed arrive together, so the lookup itself is shared. * already out is sent again with them. A failed lookup is asked again on
* the next event.
*/ */
function describe(panoId) { async function describe(panoId) {
if (!metadata.has(panoId)) metadata.set(panoId, lookUp(panoId)); if (described.has(panoId) || lookingUp.has(panoId)) return;
return metadata.get(panoId); lookingUp.add(panoId);
}
async function lookUp(panoId) {
try { try {
service ||= new library.StreetViewService(); service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
const { data } = await service.getPanorama({ pano: panoId }); const { data } = await service.getPanorama({ pano: panoId });
return { described.set(panoId, metaFrom(data));
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
} catch { } catch {
// The pose still goes out, without a date. return;
return { capturedAt: null, creator: null, title: null }; } finally {
lookingUp.delete(panoId);
} }
publishPose();
} }
async function publishPose() { /** Emit the pose on screen now, dated once its lookup has answered. */
const shown = panorama; function publishPose() {
const panoId = shown?.getPano?.(); const panoId = panorama?.getPano?.();
const position = shown?.getPosition?.(); const latLng = panorama?.getPosition?.();
if (!panoId || !position || pendingOpen === null) return; if (!panoId || !latLng || pendingOpen === null) return;
const meta = await describe(panoId); const meta = described.get(panoId);
// Closed, unmounted or moved on while the date was looked up. if (!meta) describe(panoId);
if (pendingOpen === null || shown !== panorama) return;
if (panorama.getPano() !== panoId) return;
const pov = panorama.getPov() || {}; const pov = panorama.getPov() || {};
const latLng = panorama.getPosition() || position;
emit({ emit({
providerId: GOOGLE_PROVIDER_ID, providerId: GOOGLE_PROVIDER_ID,
imageId: panoId, imageId: panoId,
@@ -110,10 +117,10 @@ export function createGoogleViewer({ loader, render } = {}) {
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0, tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
altitude: null, altitude: null,
isPano: true, isPano: true,
capturedAt: meta.capturedAt, capturedAt: meta?.capturedAt ?? null,
capturedAtPrecision: 'month', capturedAtPrecision: 'month',
creator: meta.creator, creator: meta?.creator ?? null,
title: meta.title, title: meta?.title ?? null,
sequenceId: null, sequenceId: null,
externalUrl: streetViewUrl(panoId, { externalUrl: streetViewUrl(panoId, {
heading: pov.heading || 0, heading: pov.heading || 0,
@@ -136,8 +143,10 @@ export function createGoogleViewer({ loader, render } = {}) {
target.append(element); target.append(element);
host = target; host = target;
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS); panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
subscriptions = ['pano_changed', 'position_changed', 'pov_changed'].map( // Not pano_changed: it can fire before the new panorama's data, with the
(name) => panorama.addListener(name, () => publishPose()), // old position. A step along an arrow moves the position.
subscriptions = ['position_changed', 'pov_changed'].map((name) =>
panorama.addListener(name, () => publishPose()),
); );
return panorama; return panorama;
} }
@@ -157,41 +166,42 @@ export function createGoogleViewer({ loader, render } = {}) {
} }
/** /**
* Resolves once `panoId` shows; rejects when Google has no imagery for it, * Resolves once `panoId` shows: an OK status, or (when the status was
* or refuses the key (which it reports apart from the panorama's status). * 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) { function shown(instance, panoId) {
if (instance.getPano() === panoId && instance.getStatus() === 'OK') if (instance.getPano() === panoId && instance.getStatus() === 'OK')
return Promise.resolve(); return Promise.resolve();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(
done(); () => finish(reject, new Error(NO_ANSWER_MESSAGE)),
reject(new Error('Street View did not answer')); OPEN_TIMEOUT_MS,
}, OPEN_TIMEOUT_MS); );
const stopAuthWatch = loader.onAuthFailure(() => { const stopAuthWatch = loader.onAuthFailure(() =>
done(); finish(reject, new Error(KEY_REJECTED_MESSAGE)),
reject(new Error(KEY_REJECTED_MESSAGE)); );
}); cancelShown = () => finish(resolve);
const subscription = instance.addListener('status_changed', () => { const check = () => {
if (pendingOpen !== panoId) { if (pendingOpen !== panoId) return finish(resolve);
done();
resolve();
return;
}
// Google may answer with a newer id for the same place: any OK counts.
const status = instance.getStatus(); const status = instance.getStatus();
if (status === 'OK') { if (status === 'OK') finish(resolve);
done(); else if (status)
resolve(); finish(
} else if (status && status !== 'OK') { reject,
done(); new Error('Street View has no imagery for this place'),
reject(new Error('Street View has no imagery for this place')); );
} };
}); const subscriptions = ['status_changed', 'position_changed'].map((name) =>
function done() { instance.addListener(name, check),
);
function finish(settle, value) {
cancelShown = null;
clearTimeout(timer); clearTimeout(timer);
subscription.remove(); for (const subscription of subscriptions) subscription.remove();
stopAuthWatch(); stopAuthWatch();
settle(value);
} }
}); });
} }
@@ -215,11 +225,12 @@ export function createGoogleViewer({ loader, render } = {}) {
instance.setVisible(true); instance.setVisible(true);
await ready; await ready;
if (pendingOpen !== panoId) return; if (pendingOpen !== panoId) return;
await publishPose(); publishPose();
}, },
close() { close() {
pendingOpen = null; pendingOpen = null;
cancelShown?.();
try { try {
panorama?.setVisible(false); panorama?.setVisible(false);
} catch { } catch {
@@ -229,6 +240,7 @@ export function createGoogleViewer({ loader, render } = {}) {
unmount() { unmount() {
pendingOpen = null; pendingOpen = null;
cancelShown?.();
generation++; generation++;
destroyPanorama(); destroyPanorama();
}, },
@@ -255,5 +267,10 @@ export function createGoogleViewer({ loader, render } = {}) {
listeners.add(listener); listeners.add(listener);
return () => listeners.delete(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; panorama.pano = id;
queueMicrotask(() => loader.refuseKey()); queueMicrotask(() => loader.refuseKey());
}; };
await assert.rejects(viewer.open('pano-a'), /enable the Maps JavaScript API/); await assert.rejects(viewer.open('pano-a'), /Maps JavaScript API is enabled/);
await assert.rejects(viewer.open('pano-b'), /enable the Maps JavaScript API/); await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/);
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind'); 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 {{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 {{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 {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 {boolean} [defaultOn] Switched on until the user says otherwise (default true); match the share-link option default.
* @property {(context: ProviderContext) => ProviderInstance} create * @property {(context: ProviderContext) => ProviderInstance} create
* *
* @typedef {object} ProviderContext Handed to `create()` once by the core. * @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 {() => {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 {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth. * @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) ? picking.resolvePickId(picked)
: picked?.id; : picked?.id;
const route = parts.router.resolve(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 (!route?.instance) {
if (!id) parts.openAtGround?.(click.position); parts.openAtGround?.(click.position, picked);
return; return;
} }
const entry = state.providers.get(route.providerId); const entry = state.providers.get(route.providerId);
+10 -5
View File
@@ -67,7 +67,8 @@ function harness({ selected = false } = {}) {
parts: { parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null }, router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected, hasSelectedSequence: () => sequences.selected,
openAtGround: (position) => groundClicks.push(position), openAtGround: (position, picked) =>
groundClicks.push({ position, picked }),
clearSequences() { clearSequences() {
sequences.cleared++; sequences.cleared++;
sequences.selected = false; 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(); const h = harness();
try { try {
h.click(5); 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.scene.under = 'flight:abc';
h.click(6); 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 { } finally {
h.restore(); h.restore();
} }
+3
View File
@@ -56,6 +56,9 @@ export function createState({ services }) {
...freshStreet(), ...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'. */ /** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped', surface: 'draped',
-31
View File
@@ -295,34 +295,3 @@ export function createHorizonCull({ getViewer, items, onChange }) {
return { update, stop }; 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(); warm.unmount();
} }
const adapter = entry.instance.viewer; const adapter = entry.instance.viewer;
state.street.renderModes = typeof adapter.setRenderMode === 'function';
const promise = (async () => { const promise = (async () => {
await adapter.mount(state.street.host); await adapter.mount(state.street.host);
if (mounting?.promise !== promise) { if (mounting?.promise !== promise) {
@@ -139,6 +138,8 @@ export function createViewerHost({ state, parts }) {
try { try {
const adapter = await mount(entry); const adapter = await mount(entry);
if (!current()) return false; 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)); await adapter.open(String(imageId));
if (!current()) return false; if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket); if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
@@ -201,9 +202,11 @@ export function createViewerHost({ state, parts }) {
} catch { } catch {
/* the real open reports errors */ /* the real open reports errors */
} }
if (active?.id === entry.def.id) continue; if (active?.id === entry.def.id || mounting?.id === entry.def.id)
// Switched off while it loaded: release it now rather than keep it. continue;
if (!state.enabled || !entry.on) adapter.unmount(); // 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); 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], ['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 * 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}}; * loader that hands it out. Panoramas are {id: {lat, lng, imageDate,
* `nearest(location, radius)` answers location lookups with an id or null. * 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) { function latLng(lat, lng) {
@@ -11,6 +15,7 @@ function latLng(lat, lng) {
export function fakeStreetViewLibrary({ export function fakeStreetViewLibrary({
panoramas = {}, panoramas = {},
nearest = () => null, nearest = () => null,
substitutes = {},
} = {}) { } = {}) {
const built = []; const built = [];
const lookups = []; const lookups = [];
@@ -22,6 +27,7 @@ export function fakeStreetViewLibrary({
this.visible = options?.visible ?? true; this.visible = options?.visible ?? true;
this.pano = null; this.pano = null;
this.status = null; this.status = null;
this.position = null;
this.pov = { heading: 0, pitch: 0 }; this.pov = { heading: 0, pitch: 0 };
this.listeners = new Map(); this.listeners = new Map();
built.push(this); built.push(this);
@@ -43,18 +49,27 @@ export function fakeStreetViewLibrary({
return count; return count;
} }
/** Like Google: the panorama loads asynchronously, then reports. */ /** Like Google: the id is set at once; the data loads asynchronously. */
setPano(id) { setPano(id) {
this.pano = id; this.pano = id;
this.fire('pano_changed');
queueMicrotask(() => { queueMicrotask(() => {
if (this.pano !== id) return; if (this.pano !== id) return;
const known = panoramas[id]; const shownId = substitutes[id] ?? id;
this.status = known ? 'OK' : 'ZERO_RESULTS'; const known = panoramas[shownId];
if (known) { const status = known ? 'OK' : 'ZERO_RESULTS';
if (shownId !== id) {
this.pano = shownId;
this.fire('pano_changed'); this.fire('pano_changed');
}
if (known) {
this.position = latLng(known.lat, known.lng);
this.fire('position_changed'); 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() { getPosition() {
const known = panoramas[this.pano]; return this.position;
return known ? latLng(known.lat, known.lng) : null;
} }
getPov() { 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; let failed = false;
const listeners = new Set(); const listeners = new Set();
const imports = []; const imports = [];
let release = null;
const gate = deferred
? new Promise((resolve) => {
release = resolve;
})
: null;
return { return {
imports, imports,
async importLibrary(name) { async importLibrary(name) {
imports.push(name); imports.push(name);
await gate;
return library; return library;
}, },
release: () => release?.(),
authFailed: () => failed, authFailed: () => failed,
onAuthFailure(listener) { onAuthFailure(listener) {
listeners.add(listener); listeners.add(listener);
+5
View File
@@ -37,6 +37,11 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.googleapis.com', 'https://fonts.googleapis.com',
'https://fonts.gstatic.com', 'https://fonts.gstatic.com',
'https://graph.mapillary.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. // Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
'https://*.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.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/); 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) 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,
@@ -89,11 +91,13 @@ function presentProviders(state) {
disabled: false, disabled: false,
state: keyRequired state: keyRequired
? 'error' ? 'error'
: on && provider.loading : on && provider.unavailable
? 'loading' ? 'warn'
: on : on && provider.loading
? 'active' ? 'loading'
: 'idle', : on
? 'active'
: 'idle',
busy: on && provider.loading === true, 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); 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 ──────────────────────────────────────────────────────────────── */ /* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error { .sl-error {
font-family: var(--font-sans); font-family: var(--font-sans);