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