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

+169
View File
@@ -0,0 +1,169 @@
/**
* 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.
*/
function latLng(lat, lng) {
return { lat: () => lat, lng: () => lng };
}
export function fakeStreetViewLibrary({
panoramas = {},
nearest = () => null,
} = {}) {
const built = [];
const lookups = [];
class StreetViewPanorama {
constructor(element, options) {
this.element = element;
this.options = options;
this.visible = options?.visible ?? true;
this.pano = null;
this.status = null;
this.pov = { heading: 0, pitch: 0 };
this.listeners = new Map();
built.push(this);
}
addListener(name, listener) {
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
this.listeners.get(name).add(listener);
return { remove: () => this.listeners.get(name)?.delete(listener) };
}
fire(name) {
for (const listener of [...(this.listeners.get(name) || [])]) listener();
}
listenerCount() {
let count = 0;
for (const set of this.listeners.values()) count += set.size;
return count;
}
/** Like Google: the panorama loads asynchronously, then reports. */
setPano(id) {
this.pano = id;
queueMicrotask(() => {
if (this.pano !== id) return;
const known = panoramas[id];
this.status = known ? 'OK' : 'ZERO_RESULTS';
if (known) {
this.fire('pano_changed');
this.fire('position_changed');
}
this.fire('status_changed');
});
}
getPano() {
return this.pano;
}
getStatus() {
return this.status;
}
getPosition() {
const known = panoramas[this.pano];
return known ? latLng(known.lat, known.lng) : null;
}
getPov() {
return { ...this.pov };
}
/** The user drags the view. */
setPov(pov) {
this.pov = { ...pov };
this.fire('pov_changed');
}
setVisible(visible) {
this.visible = visible;
}
}
class StreetViewService {
async getPanorama(request) {
lookups.push(request);
const id = request.pano ?? nearest(request.location, request.radius);
const known = id && panoramas[id];
if (!known)
throw Object.assign(new Error('ZERO_RESULTS'), {
code: 'ZERO_RESULTS',
});
return {
data: {
location: {
pano: id,
latLng: latLng(known.lat, known.lng),
description: known.description,
},
imageDate: known.imageDate,
copyright: known.copyright,
},
};
}
}
return {
library: {
StreetViewPanorama,
StreetViewService,
StreetViewSource: { GOOGLE: 'google', OUTDOOR: 'outdoor' },
StreetViewPreference: { NEAREST: 'nearest', BEST: 'best' },
},
built,
lookups,
};
}
/** A loader over `library`; `refuseKey()` plays Google's gm_authFailure. */
export function fakeMapsLoader(library) {
let failed = false;
const listeners = new Set();
const imports = [];
return {
imports,
async importLibrary(name) {
imports.push(name);
return library;
},
authFailed: () => failed,
onAuthFailure(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
refuseKey() {
failed = true;
for (const listener of [...listeners]) listener();
},
listenerCount: () => listeners.size,
};
}
/** A host element with just what the viewer adapter touches. */
export function fakeHost() {
const children = [];
const host = {
children,
ownerDocument: {
createElement: () => {
const element = {
style: {},
className: '',
remove() {
const index = children.indexOf(element);
if (index !== -1) children.splice(index, 1);
},
};
return element;
},
},
append: (element) => children.push(element),
};
return host;
}