mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
95fa816232
commit
22b0009138
42 files changed
+1879
-66
No files matched your search
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
GOOGLE_PROVIDER_ID,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
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,
|
||||
// The address goes in the panel caption: Google's card covers half a
|
||||
// docked panorama.
|
||||
addressControl: false,
|
||||
linksControl: true,
|
||||
panControl: true,
|
||||
zoomControl: true,
|
||||
clickToGo: true,
|
||||
showRoadLabels: true,
|
||||
// The panel has EXPAND and ×; Google's fullscreen would leave the modal.
|
||||
fullscreenControl: false,
|
||||
enableCloseButton: false,
|
||||
motionTracking: false,
|
||||
motionTrackingControl: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
export function createGoogleViewer({ loader, render } = {}) {
|
||||
let library = null;
|
||||
let panorama = null;
|
||||
let element = null;
|
||||
let host = null;
|
||||
let service = null;
|
||||
let pendingOpen = 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();
|
||||
const listeners = new Set();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('google-street-view');
|
||||
}
|
||||
|
||||
async function ensureLibrary() {
|
||||
library ||= await loader.importLibrary('streetView');
|
||||
return library;
|
||||
}
|
||||
|
||||
function emit(pose) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Date and photographer of a panorama, looked up once: pano_changed and
|
||||
* position_changed arrive together, so the lookup itself is shared.
|
||||
*/
|
||||
function describe(panoId) {
|
||||
if (!metadata.has(panoId)) metadata.set(panoId, lookUp(panoId));
|
||||
return metadata.get(panoId);
|
||||
}
|
||||
|
||||
async function lookUp(panoId) {
|
||||
try {
|
||||
service ||= new library.StreetViewService();
|
||||
const { data } = await service.getPanorama({ pano: panoId });
|
||||
return {
|
||||
capturedAt: imageDateMs(data?.imageDate),
|
||||
creator: creatorFrom(data?.copyright),
|
||||
title: data?.location?.description || null,
|
||||
};
|
||||
} catch {
|
||||
// The pose still goes out, without a date.
|
||||
return { capturedAt: null, creator: null, title: null };
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const pov = panorama.getPov() || {};
|
||||
const latLng = panorama.getPosition() || position;
|
||||
emit({
|
||||
providerId: GOOGLE_PROVIDER_ID,
|
||||
imageId: panoId,
|
||||
position: { lon: latLng.lng(), lat: latLng.lat() },
|
||||
bearing: Number.isFinite(pov.heading) ? pov.heading : null,
|
||||
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: true,
|
||||
capturedAt: meta.capturedAt,
|
||||
capturedAtPrecision: 'month',
|
||||
creator: meta.creator,
|
||||
title: meta.title,
|
||||
sequenceId: null,
|
||||
externalUrl: streetViewUrl(panoId, {
|
||||
heading: pov.heading || 0,
|
||||
pitch: pov.pitch || 0,
|
||||
}),
|
||||
});
|
||||
requestRender();
|
||||
}
|
||||
|
||||
async function ensurePanorama(target) {
|
||||
if (panorama && host === target) return panorama;
|
||||
const built = generation;
|
||||
const { StreetViewPanorama } = await ensureLibrary();
|
||||
if (built !== generation) throw new Error('Street View was unmounted');
|
||||
if (panorama && host === target) return panorama;
|
||||
destroyPanorama();
|
||||
element = target.ownerDocument.createElement('div');
|
||||
element.className = 'sl-google-panorama';
|
||||
element.style.cssText = 'position:absolute;inset:0;';
|
||||
target.append(element);
|
||||
host = target;
|
||||
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
|
||||
subscriptions = ['pano_changed', 'position_changed', 'pov_changed'].map(
|
||||
(name) => panorama.addListener(name, () => publishPose()),
|
||||
);
|
||||
return panorama;
|
||||
}
|
||||
|
||||
function destroyPanorama() {
|
||||
for (const subscription of subscriptions) subscription.remove();
|
||||
subscriptions = [];
|
||||
try {
|
||||
panorama?.setVisible(false);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
element?.remove();
|
||||
panorama = null;
|
||||
element = null;
|
||||
host = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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 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() {
|
||||
clearTimeout(timer);
|
||||
subscription.remove();
|
||||
stopAuthWatch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async mount(target) {
|
||||
if (!target) throw new Error('Street Level viewer has no host element');
|
||||
await ensurePanorama(target);
|
||||
},
|
||||
|
||||
/** Resolves once the panorama is on screen and its first pose was emitted. */
|
||||
async open(imageId) {
|
||||
const panoId = String(imageId);
|
||||
if (!host) throw new Error('Street View is not mounted');
|
||||
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
|
||||
pendingOpen = panoId;
|
||||
const instance = await ensurePanorama(host);
|
||||
if (pendingOpen !== panoId) return;
|
||||
const ready = shown(instance, panoId);
|
||||
instance.setPano(panoId);
|
||||
instance.setVisible(true);
|
||||
await ready;
|
||||
if (pendingOpen !== panoId) return;
|
||||
await publishPose();
|
||||
},
|
||||
|
||||
close() {
|
||||
pendingOpen = null;
|
||||
try {
|
||||
panorama?.setVisible(false);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
},
|
||||
|
||||
unmount() {
|
||||
pendingOpen = null;
|
||||
generation++;
|
||||
destroyPanorama();
|
||||
},
|
||||
|
||||
resize() {
|
||||
if (!panorama) return;
|
||||
try {
|
||||
globalThis.google?.maps?.event?.trigger(panorama, 'resize');
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
},
|
||||
|
||||
/** Load the library ahead of the first panorama; a panorama is billed, so none is built. */
|
||||
async prewarm() {
|
||||
try {
|
||||
await ensureLibrary();
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
}
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user