mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14: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,176 @@
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
GOOGLE_CREDIT_HTML,
|
||||
GOOGLE_KEY_ID,
|
||||
GOOGLE_LABEL,
|
||||
GOOGLE_NAME,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
GROUND_CLICK_HINT,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
NEAREST_RADIUS_M,
|
||||
NEAREST_TIMEOUT_MS,
|
||||
NO_ANSWER_MESSAGE,
|
||||
PICK_PREFIX,
|
||||
imageDateMs,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* `promise`, unless `ms` pass first (NO_ANSWER_MESSAGE), `signal` aborts, or
|
||||
* Google refuses the key: a lookup Google never answers must not keep the
|
||||
* panel loading.
|
||||
*/
|
||||
function settleWithin(promise, { ms, signal, loader }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
|
||||
ms,
|
||||
);
|
||||
const onAbort = () => finish(reject, signal.reason);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const stopAuthWatch = loader.onAuthFailure(() =>
|
||||
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
|
||||
);
|
||||
promise.then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
function finish(settle, value) {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
stopAuthWatch();
|
||||
settle(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Street View Street Level provider: Google's own panorama viewer and
|
||||
* nearest-panorama lookups. Google offers no coverage layer for other maps,
|
||||
* so it draws none; a click on a street opens the panorama there.
|
||||
* `getApiKey` returns the browser Maps key; `loader` is ./mapsLoader.js or a stand-in.
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createGoogleProvider({
|
||||
getApiKey,
|
||||
loader = createMapsLoader({ getApiKey }),
|
||||
}) {
|
||||
if (typeof getApiKey !== 'function')
|
||||
throw new TypeError('Google Street View needs getApiKey');
|
||||
|
||||
return Object.freeze({
|
||||
id: GOOGLE_PROVIDER_ID,
|
||||
name: GOOGLE_NAME,
|
||||
label: GOOGLE_LABEL,
|
||||
requiresKeyId: GOOGLE_KEY_ID,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
|
||||
groundClick: true,
|
||||
// Every panorama is billed to the Maps key: the user lights the chip.
|
||||
defaultOn: false,
|
||||
|
||||
create(context) {
|
||||
let keyRequired = !getApiKey();
|
||||
let active = false;
|
||||
let service = null;
|
||||
const viewer = createGoogleViewer({
|
||||
loader,
|
||||
render: context.services?.render,
|
||||
});
|
||||
const stopAuthWatch = loader.onAuthFailure(() => {
|
||||
context.actions.reportError(KEY_REJECTED_MESSAGE);
|
||||
context.notify();
|
||||
});
|
||||
|
||||
return {
|
||||
async status() {
|
||||
keyRequired = !getApiKey();
|
||||
context.notify();
|
||||
return { configured: !keyRequired };
|
||||
},
|
||||
|
||||
init() {},
|
||||
|
||||
activate() {
|
||||
active = true;
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
active = false;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
active = false;
|
||||
stopAuthWatch();
|
||||
},
|
||||
|
||||
refreshCoverage() {},
|
||||
|
||||
/** Nothing drawn to redraw: lookups read the filter when they run. */
|
||||
setFilter() {},
|
||||
|
||||
coverageStats() {
|
||||
const keyRejected = loader.authFailed();
|
||||
return {
|
||||
count: 0,
|
||||
zoom: null,
|
||||
kind: null,
|
||||
loading: false,
|
||||
hint:
|
||||
active && !keyRequired && !keyRejected ? GROUND_CLICK_HINT : '',
|
||||
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
|
||||
keyRequired,
|
||||
keyRejected,
|
||||
};
|
||||
},
|
||||
|
||||
handlePick: () => false,
|
||||
|
||||
/**
|
||||
* Nearest Google-collected outdoor panorama that passes the imagery
|
||||
* filter, or null. Every Street View panorama is 360°.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
|
||||
signal?.throwIfAborted();
|
||||
const filter = context.getFilter();
|
||||
if (filter.pano === 'flat') return null;
|
||||
const lookup = (async () => {
|
||||
const lib = await loader.importLibrary('streetView');
|
||||
service ||= new lib.StreetViewService();
|
||||
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
|
||||
try {
|
||||
const { data } = await service.getPanorama({
|
||||
location: { lat, lng: lon },
|
||||
radius: NEAREST_RADIUS_M,
|
||||
sources: GOOGLE ? [GOOGLE, OUTDOOR] : [OUTDOOR],
|
||||
preference: lib.StreetViewPreference.NEAREST,
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ZERO_RESULTS') return null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
const data = await settleWithin(lookup, {
|
||||
ms: NEAREST_TIMEOUT_MS,
|
||||
signal,
|
||||
loader,
|
||||
});
|
||||
const panoId = data?.location?.pano;
|
||||
if (!panoId) return null;
|
||||
const passes = passesImageryFilter(
|
||||
{ isPano: true, capturedAt: imageDateMs(data.imageDate) ?? 0 },
|
||||
filter,
|
||||
);
|
||||
return passes ? panoId : null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user