feat(street-level): Google Street View as the second imagery provider

Google Street View registers beside Mapillary as provider `google`. It runs
on the Maps JavaScript API with the browser GOOGLE_MAPS_API_KEY, loaded on
first use (mapsLoader.js), and uses Google's own StreetViewPanorama as its
viewer adapter, emitting provider-neutral poses (address and month in the
caption, a link to Google Maps, steps along Google's arrows, FOLLOW on
Google 3D). Its chip starts off because every panorama is billed to the
key; the share option is `g`.

Google offers no coverage layer for other maps, so the provider draws
none: with it on, a click on bare ground at street zoom opens its nearest
Google-collected outdoor panorama (groundClick.js). A key Google refuses
reads KEY REJECTED and fails a pending open at once; a lookup Google never
answers fails after 10 s.

Core: providers may declare `groundClick` and `defaultOn`; poses may carry
`capturedAtPrecision` and `title`; the coverage line keeps every
provider's hint; opening one provider's image releases another's
prewarmed viewer; FIT/FILL hides for viewers without render modes. CSP
script-src admits https://maps.googleapis.com. The browser gate gains two
Street View steps (the keyed one skips as 'no Google key' in CI).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-08 02:32:06 +01:00
1 parent 95fa816232
commit 22b0009138
42 files changed
+1879 -66

No files matched your search

+10
View File
@@ -893,6 +893,16 @@
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.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",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
+17 -1
View File
@@ -1188,7 +1188,8 @@
"exports": [
"./layers/street-level",
"./layers/street-level/mapillary",
"./layers/street-level/mapillary/source"
"./layers/street-level/mapillary/source",
"./layers/street-level/google"
],
"modules": [
"src/layers/streetLevel/index.js",
@@ -1216,6 +1217,11 @@
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs"
],
@@ -2164,6 +2170,11 @@
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2686,6 +2697,11 @@
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
+182 -11
View File
@@ -49,18 +49,27 @@ export const VIEWPORTS = Object.freeze([
]);
/** Provider chips the panel must show, in order. */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']);
/**
* Skips no setup can avoid: CI forks have no Google key, and live runs cannot
* stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy
* token runs the keyed flow, so a keyless strict run means that wiring broke.
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
* nor Street View), and live runs cannot stage fixture-only cases. `no
* Mapillary key` is not accepted: CI's dummy token runs the keyed flow, so a
* keyless strict run means that wiring broke. Nor is `Google key refused`: a
* key that is set must work.
*/
export const STRICT_ALLOWED_SKIPS = Object.freeze([
'no Google 3D',
'no Google key',
'fixtures only',
]);
/** A Sacramento street with Google Street View (10th St, by the Capitol). */
export const STREET_VIEW_STREET = Object.freeze({
lon: -121.49372,
lat: 38.57986,
});
export function strictViolations(skips) {
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
}
@@ -644,13 +653,16 @@ async function main() {
].map((chip) => chip.dataset.chipId),
);
assert.deepEqual(chips, EXPECTED_PROVIDERS);
// One swatch per source plus "Selected"; each chip wears its colour.
assert.equal(
await page.evaluate(
() => document.querySelectorAll('#sl-legend li').length,
),
EXPECTED_PROVIDERS.length + 1,
);
// One swatch per switched-on source plus "Selected" (Street View
// starts off); each chip wears its colour.
const legend = await page.evaluate(() => ({
swatches: document.querySelectorAll('#sl-legend li').length,
on: window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState()
.providers.filter((provider) => provider.on).length,
}));
assert.equal(legend.swatches, legend.on + 1);
assert.equal(
await page.$eval('[data-chip-id="mapillary"]', (chip) =>
chip.style.getPropertyValue('--chip-color'),
@@ -1715,6 +1727,165 @@ async function main() {
);
},
);
const googleChip = '#sl-provider-chips [data-chip-id="google"]';
const googleKey = await page.evaluate(() =>
Boolean(window.__GOOGLE_MAPS_API_KEY__),
);
await step(
'Street View starts off, lights from its chip, and travels in the share link',
async () => {
const read = () =>
page.$eval(
'#sl-provider-chips [data-chip-id="google"]',
(node) => ({
active: node.classList.contains('active'),
className: node.className,
title: node.title,
color: node.style.getPropertyValue('--chip-color'),
}),
);
const off = await read();
assert.equal(
off.active,
false,
'off until lit: panoramas are billed',
);
assert.equal(off.color, '#4285f4');
await clickPanelControl(page, googleChip);
await uiUntil(
(u) => u.providers.find((p) => p.id === 'google')?.on === true,
);
const lit = await read();
if (googleKey) assert.match(lit.className, /chip-active/);
else {
assert.match(lit.className, /chip-error/, 'no key: an error chip');
assert.match(lit.title, /GOOGLE_MAPS_API_KEY/);
}
const option = `${STREET_LEVEL_TOKEN}.g.1`;
assert.ok(
await settle(
page,
(token) =>
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
.split('_')
.includes(token),
option,
),
`the share link carries ${option}`,
);
assert.ok((await ui()).coverage.count > 0, 'Mapillary still draws');
},
);
await step(
'a click on a street opens Google Street View, 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),
);
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(
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,
);
},
);
await step(
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
async () => {