mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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]>
277 lines
8.4 KiB
JavaScript
277 lines
8.4 KiB
JavaScript
import {
|
||
GOOGLE_PROVIDER_ID,
|
||
KEY_REJECTED_MESSAGE,
|
||
NO_ANSWER_MESSAGE,
|
||
OPEN_TIMEOUT_MS,
|
||
creatorFrom,
|
||
imageDateMs,
|
||
streetViewUrl,
|
||
} from './policy.js';
|
||
|
||
/** 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.
|
||
* `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, 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, title}. */
|
||
const described = new Map();
|
||
/** Panorama ids whose lookup is in flight. */
|
||
const lookingUp = new Set();
|
||
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 */
|
||
}
|
||
}
|
||
}
|
||
|
||
function metaFrom(data) {
|
||
return {
|
||
capturedAt: imageDateMs(data?.imageDate),
|
||
creator: creatorFrom(data?.copyright),
|
||
title: data?.location?.description || null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
async function describe(panoId) {
|
||
if (described.has(panoId) || lookingUp.has(panoId)) return;
|
||
lookingUp.add(panoId);
|
||
try {
|
||
service ||= getService
|
||
? await getService()
|
||
: new (await ensureLibrary()).StreetViewService();
|
||
const { data } = await service.getPanorama({ pano: panoId });
|
||
described.set(panoId, metaFrom(data));
|
||
} catch {
|
||
return;
|
||
} finally {
|
||
lookingUp.delete(panoId);
|
||
}
|
||
publishPose();
|
||
}
|
||
|
||
/** 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() || {};
|
||
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 ?? null,
|
||
capturedAtPrecision: 'month',
|
||
creator: meta?.creator ?? null,
|
||
title: meta?.title ?? null,
|
||
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);
|
||
// 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;
|
||
}
|
||
|
||
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: 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(
|
||
() => 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') 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);
|
||
for (const subscription of subscriptions) subscription.remove();
|
||
stopAuthWatch();
|
||
settle(value);
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
publishPose();
|
||
},
|
||
|
||
close() {
|
||
pendingOpen = null;
|
||
cancelShown?.();
|
||
try {
|
||
panorama?.setVisible(false);
|
||
} catch {
|
||
/* torn down */
|
||
}
|
||
},
|
||
|
||
unmount() {
|
||
pendingOpen = null;
|
||
cancelShown?.();
|
||
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);
|
||
},
|
||
|
||
/** 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));
|
||
},
|
||
};
|
||
}
|