mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(street-level): one imagery source at a time; IC8 review fixes
- One provider is lit at a time: lighting a chip darkens the others, and a chip click records every registered provider's switch in one update so the share link keeps the swap (also off Google 3D, where Street View is not listed). The layer starts with the first default-on provider (Mapillary, free); params that light several keep the one already on, so reapplying them never flips (litByParams). The now-unreachable "several lit" chip paths are gone. - Leaving Google 3D during a Street View lookup ends it without an error or a stuck loading state (openImage's early return resets it; the lookup loop rechecks availability). - A source swap during a lookup no longer reports "no imagery". - Street View lit off Google 3D says the panel needs the Google 3D map. - Comments trimmed to the why; keySetupCore.test.mjs back to upstream's formatting plus the one new test (was a 551-line reformat). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
64d939f3d2
commit
b1efd22f02
24 files changed
+388
-625
No files matched your search
@@ -65,10 +65,7 @@ export function imageConeGlyph({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Street View-style marker: a soft field-of-view cone in `color` from a
|
||||
* white-ringed dot, as Google Maps shows where its panorama looks.
|
||||
*/
|
||||
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
|
||||
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
|
||||
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
|
||||
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
|
||||
|
||||
@@ -11,11 +11,9 @@ export function groundClickHeightOk(viewer, groundAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on the map itself (nothing picked, or Google 3D tile content) at
|
||||
* street zoom opens the nearest image of the active, available providers
|
||||
* that have no coverage to click (`groundClick`, Google Street View). The
|
||||
* camera stays put: the user chose the spot.
|
||||
* The height is measured at the click, not read from the cached flag.
|
||||
* A click on the map itself at street zoom opens the nearest image of the
|
||||
* active `groundClick` providers, without moving the camera. The height is
|
||||
* measured at the click, not read from the cached flag.
|
||||
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
|
||||
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
|
||||
*/
|
||||
|
||||
@@ -128,11 +128,13 @@ export function createStreetLevelLayer({
|
||||
});
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on.
|
||||
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
|
||||
for (const def of definitionsFrozen) {
|
||||
const entry = {
|
||||
def,
|
||||
instance: null,
|
||||
on: def.defaultOn !== false,
|
||||
on: def === startsOn,
|
||||
status: null,
|
||||
/** The error this provider last reported, until it withdraws it. */
|
||||
reportedError: null,
|
||||
@@ -153,19 +155,13 @@ export function createStreetLevelLayer({
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider that names a map stack (Google Street View: Google's terms
|
||||
* forbid its imagery beside non-Google maps) is usable only on that stack.
|
||||
*/
|
||||
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
|
||||
function providerAvailable(entry) {
|
||||
const required = entry.def.requiresMapStack;
|
||||
return !required || mapStack?.getActiveId?.() === required;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider appears only on its map stack: leaving it closes the image it
|
||||
* shows, releases its viewer and takes its credit off the globe.
|
||||
*/
|
||||
/** Leaving a provider's map stack closes its image, viewer and credit. */
|
||||
function syncProviderAvailability() {
|
||||
const owner = state.providers.get(state.street.providerId);
|
||||
if (state.street.open && owner && !providerAvailable(owner))
|
||||
@@ -276,10 +272,9 @@ export function createStreetLevelLayer({
|
||||
if (!state.viewer) return;
|
||||
entry.instance.activate(state.viewer);
|
||||
if (!entry.status) refreshStatus(entry);
|
||||
// Off its map stack a provider shows nothing: no credit, no viewer load.
|
||||
if (!providerAvailable(entry)) return;
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
// Checked again when it runs: the map may have changed meanwhile.
|
||||
// The map may have changed by then.
|
||||
whenIdle(() => {
|
||||
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
|
||||
}, 1500);
|
||||
@@ -299,10 +294,7 @@ export function createStreetLevelLayer({
|
||||
nearestLookup = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground under the middle of the screen; null when the middle is sky;
|
||||
* undefined when nothing can aim (no scenePick, or a canvas with no size).
|
||||
*/
|
||||
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
|
||||
function screenCentreGround() {
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const pick = state.services.scenePick?.pickGroundPosition;
|
||||
@@ -326,6 +318,7 @@ export function createStreetLevelLayer({
|
||||
abortNearest();
|
||||
const entry = state.providers.get(providerId);
|
||||
if (entry && !providerAvailable(entry)) {
|
||||
state.street.loading = false;
|
||||
state.street.error = unavailableReason(entry);
|
||||
notify();
|
||||
return Promise.resolve(false);
|
||||
@@ -337,20 +330,44 @@ export function createStreetLevelLayer({
|
||||
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
|
||||
}
|
||||
|
||||
/** One imagery source at a time: lighting a provider darkens the others. */
|
||||
function setProviderEnabled(providerId, on) {
|
||||
const entry = state.providers.get(providerId);
|
||||
if (!entry) return false;
|
||||
const next = on !== false;
|
||||
if (entry.on === next) return true;
|
||||
entry.on = next;
|
||||
if (state.enabled) {
|
||||
if (next) activate(entry);
|
||||
else deactivate(entry);
|
||||
}
|
||||
if (next)
|
||||
for (const other of state.providers.values())
|
||||
if (other !== entry && other.on) switchProvider(other, false);
|
||||
switchProvider(entry, next);
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
function switchProvider(entry, on) {
|
||||
if (entry.on === on) return;
|
||||
entry.on = on;
|
||||
if (!state.enabled) return;
|
||||
if (on) activate(entry);
|
||||
else deactivate(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one provider params leave lit: of those they switch on, the one
|
||||
* already on (reapplying never flips), else the first; with none, the lit
|
||||
* one unless they switch it off.
|
||||
*/
|
||||
function litByParams(switches) {
|
||||
const asked = [...state.providers.keys()].filter(
|
||||
(id) => switches.get(id) === true,
|
||||
);
|
||||
const current = [...state.providers]
|
||||
.filter(([, entry]) => entry.on)
|
||||
.map(([id]) => id);
|
||||
if (asked.length)
|
||||
return asked.find((id) => current.includes(id)) ?? asked[0];
|
||||
return current.find((id) => switches.get(id) !== false) ?? null;
|
||||
}
|
||||
|
||||
function setCoverageFilter(next) {
|
||||
const filter = normalizeFilter(next, state.filter);
|
||||
if (sameFilter(filter, state.filter)) return;
|
||||
@@ -390,7 +407,6 @@ export function createStreetLevelLayer({
|
||||
hint: hintFor(entry, stats),
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
/** No coverage to click: it opens where the user points. */
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
};
|
||||
@@ -418,7 +434,7 @@ export function createStreetLevelLayer({
|
||||
|
||||
function getUIState() {
|
||||
const { host, ...street } = state.street;
|
||||
return composeUIState({
|
||||
const ui = composeUIState({
|
||||
enabled: state.enabled,
|
||||
groundClickReady: state.groundClickReady,
|
||||
filter: state.filter,
|
||||
@@ -427,6 +443,13 @@ export function createStreetLevelLayer({
|
||||
sequence: sequenceSnapshot(),
|
||||
surface: state.surface,
|
||||
});
|
||||
// The lit source cannot run on this map: say so, not a blank panel.
|
||||
const stranded = [...state.providers.values()].find(
|
||||
(entry) => entry.on && !providerAvailable(entry),
|
||||
);
|
||||
if (stranded && !ui.providers.some((p) => p.on))
|
||||
ui.coverage.hint = unavailableReason(stranded);
|
||||
return ui;
|
||||
}
|
||||
|
||||
const layer = {
|
||||
@@ -533,7 +556,6 @@ export function createStreetLevelLayer({
|
||||
count: coverage.count,
|
||||
loading: coverage.loading,
|
||||
keyRequired: coverage.keyRequired,
|
||||
/** Switched-off sources that would draw with a key already set. */
|
||||
keyAlternatives: keyAlternatives(snapshots),
|
||||
error: coverage.keyRequired
|
||||
? coverage.keyRejected
|
||||
@@ -583,7 +605,11 @@ export function createStreetLevelLayer({
|
||||
providerIds: state.providers.keys(),
|
||||
filter: state.filter,
|
||||
});
|
||||
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
|
||||
const lit = litByParams(decoded.providers);
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
if (lit) switchProvider(state.providers.get(lit), true);
|
||||
notify();
|
||||
setCoverageFilter(decoded.filter);
|
||||
return true;
|
||||
},
|
||||
@@ -612,10 +638,9 @@ export function createStreetLevelLayer({
|
||||
setCoverageFilter,
|
||||
openImage,
|
||||
/**
|
||||
* Open the nearest image any active provider (or only `providerIds`)
|
||||
* has around a point; without one, around the view centre, or with
|
||||
* `aim: 'screen-centre'` the ground under the middle of the screen. An
|
||||
* aim at the sky opens nothing rather than somewhere else.
|
||||
* Open the nearest image of the active providers (or `providerIds`) near
|
||||
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
|
||||
* under the screen centre (sky: nothing).
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
@@ -663,14 +688,16 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
if (signal.aborted) return false;
|
||||
if (!state.enabled) break;
|
||||
// This provider was switched off while it looked: try the next.
|
||||
if (!imageId || !entry.on) continue;
|
||||
// Switched off, or its map left, while it looked: try the next.
|
||||
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
|
||||
nearestLookup = null;
|
||||
return openImage(entry.def.id, imageId, { frame });
|
||||
}
|
||||
if (nearestLookup === lookup) nearestLookup = null;
|
||||
// Switched off while it looked: nothing to open, nothing to report.
|
||||
const stillOn = activeEntries().some(asked);
|
||||
const stillOn = entries.some(
|
||||
(entry) => entry.on && providerAvailable(entry),
|
||||
);
|
||||
if (state.enabled && stillOn)
|
||||
state.street.error =
|
||||
lastError?.message ||
|
||||
|
||||
@@ -532,7 +532,105 @@ test('Street View starts off, and the layer row names the key of the providers t
|
||||
assert.equal(google().on, false);
|
||||
assert.equal(layer.requiresKeyId, 'mapillary');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.equal(layer.requiresKeyId, null, 'two keys: each chip names its own');
|
||||
assert.equal(layer.requiresKeyId, 'google-maps', 'Street View alone');
|
||||
});
|
||||
|
||||
test('the layer starts with one source lit: the first that defaults on', async (t) => {
|
||||
const { layer } = await enabledLayer(t, [
|
||||
fakeProvider(),
|
||||
fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((p) => p.on)
|
||||
.map((p) => p.id),
|
||||
['mapillary'],
|
||||
);
|
||||
});
|
||||
|
||||
test('leaving Google 3D during a Street View lookup ends it without an error or a stuck spinner', async (t) => {
|
||||
const { layer, stack, fake } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
let answer;
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
const opening = layer.openNearest({ lat: 38.5816, lon: -121.4944 });
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.loading, true);
|
||||
stack.switchTo('bing');
|
||||
answer({ data: { location: { pano: 'pano-a' }, imageDate: '2024-05' } });
|
||||
assert.equal(await opening, false);
|
||||
const { street } = layer.getUIState();
|
||||
assert.equal(street.loading, false, 'OPEN STREET VIEW is usable again');
|
||||
assert.equal(street.error, null);
|
||||
assert.equal(street.open, false);
|
||||
});
|
||||
|
||||
test('a source swap during a lookup reports no false "no imagery"', async (t) => {
|
||||
let answer;
|
||||
const mapillary = fakeProvider({
|
||||
nearestImage: () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
});
|
||||
const other = fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' });
|
||||
const { layer } = await enabledLayer(t, [mapillary, other]);
|
||||
const opening = layer.openNearest({ lat: 1, lon: 2 });
|
||||
layer.setProviderEnabled('panoramax', true);
|
||||
answer('img-1');
|
||||
assert.equal(await opening, false);
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
assert.equal(layer.getUIState().street.loading, false);
|
||||
});
|
||||
|
||||
test('Street View lit off Google 3D says why the panel is empty', async (t) => {
|
||||
const { layer, stack } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
stack.switchTo('esri-imagery');
|
||||
assert.match(
|
||||
layer.getUIState().coverage.hint,
|
||||
/Google Street View needs the Google 3D map/,
|
||||
);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
assert.doesNotMatch(layer.getUIState().coverage.hint, /Google 3D/);
|
||||
});
|
||||
|
||||
test('one imagery source at a time: lighting one darkens the other, either way', async (t) => {
|
||||
const { layer } = await withStreetView(t);
|
||||
const lit = () =>
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((p) => p.on)
|
||||
.map((p) => p.id);
|
||||
assert.deepEqual(lit(), ['mapillary'], 'Mapillary by default: it is free');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
// Params that light one switch to it; reapplying them changes nothing.
|
||||
layer.setParams({ google: true });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
layer.setParams({ google: true });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
// Params lighting both keep the one already on, however often applied.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'google-1' });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
}
|
||||
layer.setParams({ mapillary: true, google: false });
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
// Filter-only params leave the source alone.
|
||||
layer.setParams({ pano: 'pano' });
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
// Darkening the only lit one leaves none: the panel turns the layer off.
|
||||
layer.setProviderEnabled('mapillary', false);
|
||||
assert.deepEqual(lit(), []);
|
||||
});
|
||||
|
||||
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createHorizonCull } from './view.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:marker';
|
||||
|
||||
/** The billboard look for a marker style: GEV's pin, or a provider's view cone. */
|
||||
function markerLook(style) {
|
||||
if (style?.kind === 'view-cone') {
|
||||
const color = style.color || COLORS.position;
|
||||
@@ -75,11 +74,7 @@ export function createMarker({ state, parts }) {
|
||||
set(last.position, last.bearing, last.style);
|
||||
});
|
||||
|
||||
/**
|
||||
* Move (or create) the marker; a null position removes it. `style` is the
|
||||
* open image's provider marker ({kind: 'view-cone', color}); GEV's pin
|
||||
* without one.
|
||||
*/
|
||||
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
|
||||
function set(position, bearing, style = null) {
|
||||
const collection = state.marker.collection;
|
||||
if (!collection) return;
|
||||
@@ -102,7 +97,6 @@ export function createMarker({ state, parts }) {
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE;
|
||||
const look = markerLook(style);
|
||||
// Another provider's photo: the marker changes its look.
|
||||
if (state.marker.billboard && state.marker.look !== look.key) {
|
||||
collection.remove?.(state.marker.billboard);
|
||||
state.marker.billboard = null;
|
||||
|
||||
@@ -37,12 +37,8 @@ export const FOLLOW_MAP_STACK_ID = 'photoreal';
|
||||
/** Eye height above the sampled ground when the globe camera follows the viewer. */
|
||||
export const FOLLOW_EYE_HEIGHT_M = 2.4;
|
||||
|
||||
/**
|
||||
* A ground click opens the nearest image of a provider without coverage lines
|
||||
* (Google Street View) only from this close: from higher up it is a misclick.
|
||||
*/
|
||||
/** Above this, a ground click is a misclick, not a street. */
|
||||
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||
|
||||
/** OPEN STREET VIEW aimed at the sky: it opens what the user aims at, or nothing. */
|
||||
export const AIM_AT_GROUND_MESSAGE =
|
||||
'Aim the middle of the view at a street to look for imagery there';
|
||||
@@ -16,11 +16,7 @@ import {
|
||||
PICK_PREFIX,
|
||||
} 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.
|
||||
*/
|
||||
/** `promise`, unless it times out, `signal` aborts or Google refuses the key. */
|
||||
function settleWithin(promise, { ms, signal, loader }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
@@ -46,10 +42,8 @@ function settleWithin(promise, { ms, signal, loader }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Google Street View provider: Google's panorama viewer and nearest-panorama
|
||||
* lookups. It draws no coverage; a click on a street opens the panorama.
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createGoogleProvider({
|
||||
@@ -70,19 +64,16 @@ export function createGoogleProvider({
|
||||
groundClick: true,
|
||||
// Google's terms forbid Street View beside non-Google maps.
|
||||
requiresMapStack: FOLLOW_MAP_STACK_ID,
|
||||
// Every panorama is billed to the Maps key: the user lights the chip.
|
||||
// Every panorama is billed to the Maps key.
|
||||
defaultOn: false,
|
||||
// Every panorama is 360°, and with no coverage drawn there is nothing
|
||||
// for a date filter to thin: the user picks the place by clicking it.
|
||||
// All 360°, and no coverage for a date filter to thin.
|
||||
filters: [],
|
||||
// Google Maps' own look for where a panorama stands and looks.
|
||||
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
|
||||
|
||||
create(context) {
|
||||
let keyRequired = !getApiKey();
|
||||
let active = false;
|
||||
let service = null;
|
||||
/** The one StreetViewService the lookups and the viewer share. */
|
||||
async function getService() {
|
||||
const lib = await loader.importLibrary('streetView');
|
||||
service ||= new lib.StreetViewService();
|
||||
@@ -94,7 +85,7 @@ export function createGoogleProvider({
|
||||
render: context.services?.render,
|
||||
});
|
||||
const stopAuthWatch = loader.onAuthFailure(() => {
|
||||
// Switched off since: its chip says so; no error over another photo.
|
||||
// Switched off: its chip says so.
|
||||
if (context.isActive?.())
|
||||
context.actions.reportError(KEY_REJECTED_MESSAGE);
|
||||
context.notify();
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
/**
|
||||
* Loads the Google Maps JavaScript API once per page, on first use, and hands
|
||||
* out its libraries. Google calls `gm_authFailure` when it refuses the key
|
||||
* (an unknown key, a referrer it does not allow, or the Maps JavaScript API
|
||||
* not enabled for it); that reaches every `onAuthFailure` listener.
|
||||
* Loads the Google Maps JavaScript API once per page, on first use. Google's
|
||||
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
|
||||
*/
|
||||
|
||||
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
|
||||
const READY_CALLBACK = '__gevGoogleMapsReady';
|
||||
/**
|
||||
* The page's one script load, kept on the page: every loader on it (two
|
||||
* application instances, say) waits on the same script and callback.
|
||||
*/
|
||||
/** The page's one script load, shared by every loader on it. */
|
||||
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
|
||||
|
||||
/**
|
||||
@@ -25,7 +20,7 @@ export function createMapsLoader({
|
||||
let authHandlerInstalled = false;
|
||||
const authListeners = new Set();
|
||||
|
||||
/** Once per loader: a retried load must not wrap its own handler again. */
|
||||
/** Once per loader: a retry must not wrap its own handler. */
|
||||
function installAuthHandler() {
|
||||
if (authHandlerInstalled) return;
|
||||
authHandlerInstalled = true;
|
||||
@@ -51,7 +46,6 @@ export function createMapsLoader({
|
||||
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
|
||||
if (global.google?.maps?.importLibrary)
|
||||
return Promise.resolve(global.google.maps);
|
||||
// Cleared when the script fails, so a retry (from any loader) can.
|
||||
const loading = new Promise((resolve, reject) => {
|
||||
global[READY_CALLBACK] = () => {
|
||||
delete global[READY_CALLBACK];
|
||||
@@ -78,12 +72,10 @@ export function createMapsLoader({
|
||||
}
|
||||
|
||||
return {
|
||||
/** A Maps library ('streetView', …), loading the API first. */
|
||||
async importLibrary(name) {
|
||||
const maps = await load();
|
||||
return maps.importLibrary(name);
|
||||
},
|
||||
/** Whether Google has refused the key on this page. */
|
||||
authFailed: () => authFailed,
|
||||
onAuthFailure(listener) {
|
||||
authListeners.add(listener);
|
||||
|
||||
@@ -2,14 +2,12 @@ import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
/** Identity of the Google Street View street-level provider. */
|
||||
export const GOOGLE_PROVIDER_ID = 'google';
|
||||
export const GOOGLE_NAME = 'Google Street View';
|
||||
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
|
||||
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
|
||||
export const GOOGLE_KEY_ID = 'google-maps';
|
||||
|
||||
/** Every primitive id this provider creates starts with it. */
|
||||
export const PICK_PREFIX = Object.freeze({ root: 'gsv:' });
|
||||
|
||||
export const COLORS = Object.freeze({
|
||||
@@ -20,27 +18,20 @@ export const COLORS = Object.freeze({
|
||||
export const GOOGLE_CREDIT_HTML =
|
||||
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
|
||||
|
||||
/**
|
||||
* Shown when Google refuses the key (gm_authFailure): the Maps JavaScript API
|
||||
* is not enabled for it, this site is not an allowed referrer, or billing is off.
|
||||
*/
|
||||
/** Shown when Google refuses the key (gm_authFailure). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
|
||||
|
||||
/** A nearest-panorama lookup that takes longer has failed. */
|
||||
/** Past these, a nearest lookup, an open or a details lookup has failed. */
|
||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
||||
/** A panorama that has not shown by then has failed to open. */
|
||||
export const OPEN_TIMEOUT_MS = 20_000;
|
||||
/** A panorama-details lookup that takes longer has failed (and can retry). */
|
||||
export const DETAILS_TIMEOUT_MS = 10_000;
|
||||
/** Minimum pause after a failed panorama-details lookup before a view can retry. */
|
||||
/** Pause after a failed details lookup before a view event can retry. */
|
||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||
|
||||
/** Google answered nothing: the network, or a key Google ignores. */
|
||||
export const NO_ANSWER_MESSAGE =
|
||||
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
|
||||
|
||||
/** Shown while the provider is on, in place of coverage lines it does not have. */
|
||||
export const GROUND_CLICK_HINT = 'click a street to open Street View';
|
||||
|
||||
/** Deep link to a panorama on Google Maps (Maps URLs). */
|
||||
|
||||
@@ -10,18 +10,16 @@ import {
|
||||
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.
|
||||
// The caption has the address; Google's card covers 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.
|
||||
// The panel has EXPAND and ×.
|
||||
fullscreenControl: false,
|
||||
enableCloseButton: false,
|
||||
motionTracking: false,
|
||||
@@ -29,10 +27,8 @@ const PANORAMA_OPTIONS = Object.freeze({
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Viewer adapter over Google's StreetViewPanorama: emits a pose for every
|
||||
* panorama and view change, including steps along Google's arrows.
|
||||
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, getService?: () => Promise<object>, render?: object}} options
|
||||
* @returns {import('../../registry.js').ViewerAdapter & {remember: Function}}
|
||||
*/
|
||||
@@ -43,19 +39,13 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
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. */
|
||||
/** Bumped by `unmount`: a construction it overtook builds nothing. */
|
||||
let generation = 0;
|
||||
let subscriptions = [];
|
||||
/** Panorama id → its {capturedAt, creator, title}. */
|
||||
const described = new Map();
|
||||
/**
|
||||
* Panorama id → its lookup in flight ({timer}). A late answer to a lookup
|
||||
* no longer here (timed out, or unmounted) is dropped.
|
||||
*/
|
||||
/** Panorama id → its lookup in flight; a late answer to a dropped one is ignored. */
|
||||
const lookingUp = new Map();
|
||||
/** Panorama id → earliest retry time after a failed details lookup. */
|
||||
const retryAfter = new Map();
|
||||
const listeners = new Set();
|
||||
|
||||
@@ -87,9 +77,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a panorama's date, photographer and address once; the pose
|
||||
* already out is sent again with them. A lookup that fails or takes longer
|
||||
* than DETAILS_TIMEOUT_MS lets view events retry once the cooldown expires.
|
||||
* Look up a panorama's date, photographer and address once, then re-emit
|
||||
* the pose. A failed or timed-out lookup can retry after the cooldown.
|
||||
*/
|
||||
async function describe(panoId) {
|
||||
if (described.has(panoId) || lookingUp.has(panoId)) return;
|
||||
@@ -110,7 +99,6 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
return service.getPanorama({ pano: panoId });
|
||||
})().then(resolve, reject);
|
||||
});
|
||||
// Released by an unmount: the viewer it was for is gone.
|
||||
if (!current()) return;
|
||||
described.set(panoId, metaFrom(data));
|
||||
retryAfter.delete(panoId);
|
||||
@@ -125,13 +113,11 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
publishPose();
|
||||
}
|
||||
|
||||
/** Drop every lookup in flight: a remount looks again. */
|
||||
function releaseLookups() {
|
||||
for (const lookup of lookingUp.values()) clearTimeout(lookup.timer);
|
||||
lookingUp.clear();
|
||||
}
|
||||
|
||||
/** Emit the pose on screen now, dated once its lookup has answered. */
|
||||
function publishPose() {
|
||||
const panoId = panorama?.getPano?.();
|
||||
const latLng = panorama?.getPosition?.();
|
||||
@@ -173,8 +159,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
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.
|
||||
// Not pano_changed: it can fire with the old panorama's position.
|
||||
subscriptions = ['position_changed', 'pov_changed'].map((name) =>
|
||||
panorama.addListener(name, () => publishPose()),
|
||||
);
|
||||
@@ -196,10 +181,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolves once `panoId` shows (OK status, or a new position when the
|
||||
* status stays OK); rejects on no imagery, a refused key or no answer.
|
||||
*/
|
||||
function shown(instance, panoId) {
|
||||
if (instance.getPano() === panoId && instance.getStatus() === 'OK')
|
||||
@@ -213,8 +196,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
|
||||
);
|
||||
cancelShown = () => finish(resolve);
|
||||
// Only a status event can fail the open: at a position event the
|
||||
// status may still be the previous panorama's failure.
|
||||
// At a position event the status may still be the last panorama's failure.
|
||||
const onStatus = () => {
|
||||
if (pendingOpen !== panoId) return finish(resolve);
|
||||
const status = instance.getStatus();
|
||||
@@ -249,7 +231,6 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
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');
|
||||
@@ -297,7 +278,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
/** Keep a lookup's answer, so the panorama it found needs no second one. */
|
||||
/** Seed the details cache from a nearest lookup's answer. */
|
||||
remember(panoId, data) {
|
||||
if (panoId && data) described.set(String(panoId), metaFrom(data));
|
||||
},
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||
|
||||
/** The imagery filters a provider can honour. */
|
||||
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
|
||||
|
||||
/** The filters `provider` honours: both, unless it says otherwise. */
|
||||
export function filtersOf(provider) {
|
||||
return provider?.filters ?? IMAGERY_FILTERS;
|
||||
}
|
||||
@@ -19,11 +17,11 @@ export function filtersOf(provider) {
|
||||
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
|
||||
* @property {{html: string}} credit On-globe attribution while the provider is active.
|
||||
* @property {boolean} [groundClick] No coverage to click: a click on the ground at street zoom opens its nearest image.
|
||||
* @property {string} [requiresMapStack] Usable only on this map stack (Google Street View: 'photoreal', per Google's terms).
|
||||
* @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); a share link without its switch keeps it.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] On-globe marker for its open image (default GEV's pin): Street View's blue field-of-view cone.
|
||||
* @property {Array<'pano'|'since'>} [filters] The imagery filters it honours (default both); the panel hides a filter no lit provider honours.
|
||||
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
|
||||
* @property {string} [requiresMapStack] Usable only on this map stack.
|
||||
* @property {boolean} [defaultOn] Default true.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
|
||||
@@ -15,8 +15,8 @@ import { COLORS } from './policy.js';
|
||||
* @property {string} hint
|
||||
* @property {string|null} error
|
||||
* @property {string} color The source's one colour.
|
||||
* @property {boolean} [groundClick] Draws no coverage: opens where the user clicks.
|
||||
* @property {Array<'pano'|'since'>} [filters] Imagery filters it honours.
|
||||
* @property {boolean} [groundClick]
|
||||
* @property {Array<'pano'|'since'>} [filters]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -45,8 +45,7 @@ export function summarizeCoverage(providers) {
|
||||
|
||||
/**
|
||||
* Switched-off providers that would draw with a key already set, offered
|
||||
* while the switched-on ones lack theirs (Street View beside a Mapillary
|
||||
* layer with no token).
|
||||
* while the switched-on ones lack theirs.
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
* @returns {Array<{id: string, label: string, requiresKeyId: string|null}>}
|
||||
*/
|
||||
@@ -58,8 +57,7 @@ export function keyAlternatives(providers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which filter controls mean anything: those a switched-on provider honours.
|
||||
* With none switched on, both show (they apply once one is).
|
||||
* The filter controls a switched-on provider honours (both when none is on).
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
*/
|
||||
export function filterControls(providers) {
|
||||
@@ -72,8 +70,7 @@ export function filterControls(providers) {
|
||||
|
||||
/**
|
||||
* The panel snapshot from core state and provider snapshots. The legend has
|
||||
* one swatch per active source that draws coverage lines, then the shared
|
||||
* selection colour; a source that only opens where the user clicks has none.
|
||||
* one swatch per active source that draws coverage lines, then Selected.
|
||||
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||
*/
|
||||
export function composeUIState({
|
||||
@@ -99,7 +96,6 @@ export function composeUIState({
|
||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||
return {
|
||||
enabled,
|
||||
/** The camera is at street zoom: a ground-click provider can open here. */
|
||||
groundClickReady: groundClickReady === true,
|
||||
keyRequired,
|
||||
keyAlternatives: keyAlternatives(providers),
|
||||
|
||||
@@ -75,8 +75,7 @@ export function createViewerHost({ state, parts }) {
|
||||
active.adapter.unmount();
|
||||
active = null;
|
||||
}
|
||||
// Another provider's prewarmed viewer shares the host: it would sit
|
||||
// under this one, holding its WebGL context.
|
||||
// A prewarmed viewer would sit under this one, holding its WebGL context.
|
||||
for (const [id, warm] of warmed) {
|
||||
if (id === entry.def.id) continue;
|
||||
warmed.delete(id);
|
||||
@@ -208,8 +207,7 @@ export function createViewerHost({ state, parts }) {
|
||||
}
|
||||
if (active?.id === entry.def.id || mounting?.id === entry.def.id)
|
||||
continue;
|
||||
// Switched off while it loaded, or another provider's viewer took the
|
||||
// host meanwhile: release it now rather than keep it under that one.
|
||||
// Switched off, or another viewer took the host meanwhile.
|
||||
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
|
||||
else warmed.set(entry.def.id, adapter);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user