fix(street-level): IC8 review of the Street View provider

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]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-08 02:59:51 +01:00
1 parent 22b0009138
commit 7bfcf177ac
35 files changed
+1108 -388

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationStreetLevel } from './streetLevel.js';
import { createDefaultLayerState } from '../../data/layerState.js';
test('each provider starts as its share-link option does, so a fresh page and a restored one agree', () => {
const layer = createApplicationStreetLevel({
surface: null,
sources: {
mapillary: {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
},
},
});
const options = createDefaultLayerState().options['street-level'];
const providers = layer.getUIState().providers;
assert.deepEqual(
providers.map((p) => p.id),
layer.providerIds,
);
for (const provider of providers)
assert.equal(provider.on, options[provider.id], provider.id);
assert.equal(options.google, false, 'Street View is billed: off');
});
+50
View File
@@ -47,3 +47,53 @@ export function isPickedWorldPosition(position) {
const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
}
/**
* The world position under a screen point: the rendered surface (terrain,
* Google 3D) where the depth buffer has it, else the ellipsoid. Null over the
* sky or for a pick that names no place.
*
* @param {object} viewer A Cesium viewer.
* @param {{x: number, y: number}} screenPosition
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
*/
export function pickGroundPosition(viewer, screenPosition) {
const scene = viewer?.scene;
let position = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
position = scene.pickPosition(screenPosition);
} catch {
position = null;
}
}
if (
!isPickedWorldPosition(position) &&
typeof viewer?.camera?.pickEllipsoid === 'function'
) {
try {
position = viewer.camera.pickEllipsoid(screenPosition);
} catch {
position = null;
}
}
return isPickedWorldPosition(position) ? position : null;
}
/**
* Whether a `scene.pick()` result is the map itself: nothing, or Google 3D
* tile content, which picks without an id.
*
* @param {object|null|undefined} picked
* @returns {boolean}
*/
export function isSurfacePick(picked) {
if (!picked) return true;
if (picked.id !== undefined) return false;
return (
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
);
}
+68 -13
View File
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from './scenePick.js';
const describe = (position) => (position
? `(${position.x}, ${position.y}, ${position.z})`
: String(position));
const describe = (position) =>
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
// These are the shapes `scene.pickPosition()` can produce when the depth read
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
new Cesium.Cartesian3(0.2, -0.3, 0.1),
];
for (const position of degenerate) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
});
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(0, 0, 5_999_999),
];
for (const position of belowTheFloor) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
const carto = Cesium.Cartographic.fromCartesian(position);
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
assert.ok(
carto.height < -300_000,
'and puts the "target" hundreds of km underground',
);
}
const aboveTheCeiling = [
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(1e155, 0, 0),
];
for (const position of aboveTheCeiling) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
assert.throws(
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
);
// Both edges of the band are inclusive, and both are real magnitudes.
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
true,
);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
true,
);
});
test('a real picked position is accepted', () => {
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
];
for (const position of real) {
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
true,
`must accept ${describe(position)}`,
);
}
// The floor has real margin under the smallest legitimate magnitude, so no
// plausible pick sits anywhere near the boundary.
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
const polarSurface = Cesium.Cartesian3.magnitude(
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
);
assert.ok(
polarSurface > 6_300_000,
`smallest real magnitude was ${polarSurface}`,
);
});
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
// Earth's core. That is the quietest failure of the three and the reason
// the guard needs a magnitude band, not just a finiteness check.
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
assert.ok(
Cesium.Cartographic.fromCartesian(nearCenter),
'Cesium converts it without complaint',
);
assert.equal(isPickedWorldPosition(nearCenter), false);
// …and every position the guard accepts must convert to the right place.
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
});
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
const { pickGroundPosition } = await import('./scenePick.js');
const ground = { x: 6378137, y: 0, z: 0 };
const sky = { x: 500, y: 0, z: 0 };
const viewer = (picked, ellipsoid) => ({
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid },
});
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
assert.equal(pickGroundPosition(null, {}), null);
});
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
const { isSurfacePick } = await import('./scenePick.js');
assert.equal(isSurfacePick(undefined), true);
assert.equal(isSurfacePick({ content: {} }), true);
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
});
+5 -26
View File
@@ -618,32 +618,11 @@ export function createDirectionsLayer({ services }) {
}
function pickGround(screenPosition) {
const scene = _viewer?.scene;
let cartesian = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (
!services.scenePick.isPickedWorldPosition(cartesian) &&
typeof _viewer?.camera?.pickEllipsoid === 'function'
) {
try {
cartesian = _viewer.camera.pickEllipsoid(
screenPosition,
Cesium.Ellipsoid.WGS84,
);
} catch {
cartesian = null;
}
}
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
const cartesian = services.scenePick.pickGroundPosition(
_viewer,
screenPosition,
);
if (!cartesian) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
+37 -19
View File
@@ -1,31 +1,49 @@
import * as Cesium from 'cesium';
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround, groundPointAt } from './view.js';
import { cameraHeightAboveGround } from './view.js';
/** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer, groundAt) {
if (!viewer?.camera?.positionCartographic) return false;
return (
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
);
}
/**
* A click on bare ground at street zoom opens the nearest image of the active
* providers that have no coverage to click (`groundClick`, Google Street
* View). `openNearest(point, {providerIds})` is the layer's lookup.
* @returns {(screenPosition: {x: number, y: number}) => Promise<boolean>|false}
* 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.
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
*/
export function createGroundClick({ state, parts, openNearest }) {
return function openAtGround(screenPosition) {
const isWorldPosition = state.services.scenePick?.isPickedWorldPosition;
if (!state.enabled || !state.viewer || !isWorldPosition) return false;
export function createGroundClick({ state, openNearest, isAvailable }) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (!scenePick.isSurfacePick(picked) || !state.groundClickReady)
return false;
const providerIds = [...state.providers.values()]
.filter(
(entry) =>
entry.on && entry.def.groundClick && entry.status?.configured,
entry.on &&
entry.def.groundClick &&
entry.status?.configured &&
isAvailable(entry),
)
.map((entry) => entry.def.id);
if (!providerIds.length) return false;
// From higher up a click is a misclick, not a street.
const height = cameraHeightAboveGround(state.viewer, {
groundAt: parts.groundCaster?.groundAt,
});
if (!(height <= GROUND_CLICK_MAX_HEIGHT_M)) return false;
const point = groundPointAt(state.viewer, screenPosition, {
isWorldPosition,
});
return point ? openNearest(point, { providerIds }) : false;
const position = scenePick.pickGroundPosition(state.viewer, screenPosition);
const carto = position && Cesium.Cartographic.fromCartesian(position);
if (!carto) return false;
return openNearest(
{
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
},
{ providerIds, frame: false },
);
};
}
+93 -50
View File
@@ -1,84 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGroundClick } from './groundClick.js';
import { isPickedWorldPosition } from '../../data/scenePick.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import * as scenePick from '../../data/scenePick.js';
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10);
const TILESET = { isCesium3DTileset: true };
/** A layer state with providers `{id, on, groundClick, configured}` and a camera `height` m up. */
/**
* A layer state with providers `{id, on, groundClick, configured, available}`;
* `picked` is where the depth buffer lands, `ellipsoid` the fallback hit.
*/
function harness({
providers = [
{ id: 'mapillary', groundClick: false },
{ id: 'google', groundClick: true },
],
height = 400,
ready = true,
enabled = true,
picked = GROUND,
ellipsoid = undefined,
} = {}) {
const opened = [];
const state = {
enabled,
services: { scenePick: { isPickedWorldPosition } },
groundClickReady: ready,
services: { scenePick },
providers: new Map(
providers.map(({ id, on = true, groundClick, configured = true }) => [
id,
{
on,
def: { id, groundClick },
status: { configured },
},
]),
providers.map(
({
id,
on = true,
groundClick,
configured = true,
available = true,
}) => [
id,
{ on, def: { id, groundClick }, status: { configured }, available },
],
),
),
viewer: {
scene: {
pickPositionSupported: true,
pickPosition: () => picked,
globe: { getHeight: () => 10 },
},
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
10 + height,
),
pickEllipsoid: () => undefined,
},
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid },
},
};
const openAtGround = createGroundClick({
state,
parts: {},
openNearest: (point, options) => {
opened.push({ point, options });
return Promise.resolve(true);
},
isAvailable: (entry) => entry.available,
});
return { state, opened, openAtGround };
return { opened, openAtGround };
}
test('a ground click at street zoom opens the nearest image of the click-to-open providers there', () => {
const { opened, openAtGround } = harness();
openAtGround({ x: 10, y: 20 });
assert.equal(opened.length, 1);
const [{ point, options }] = opened;
assert.ok(Math.abs(point.lon - -121.4944) < 1e-9);
assert.ok(Math.abs(point.lat - 38.5816) < 1e-9);
assert.deepEqual(options, { providerIds: ['google'] });
});
const near = (point) =>
Math.abs(point.lon - -121.4944) < 1e-9 &&
Math.abs(point.lat - 38.5816) < 1e-9;
test('nothing opens from high up, over the sky, with the provider off or unconfigured, or the layer off', () => {
for (const options of [
{ height: 5000 },
{ picked: null },
{ picked: new Cesium.Cartesian3(500, 0, 0) },
{ providers: [{ id: 'google', groundClick: true, on: false }] },
{ providers: [{ id: 'google', groundClick: true, configured: false }] },
{ providers: [{ id: 'mapillary', groundClick: false }] },
{ enabled: false },
]) {
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }), false, JSON.stringify(options));
assert.equal(opened.length, 0);
test('a click on the map opens the click-to-open providers there, without moving the camera', () => {
for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) {
const { opened, openAtGround } = harness();
openAtGround({ x: 10, y: 20 }, picked);
assert.equal(opened.length, 1, JSON.stringify(picked));
assert.ok(near(opened[0].point));
assert.deepEqual(opened[0].options, {
providerIds: ['google'],
frame: false,
});
}
});
test('where the depth buffer names no place, the ellipsoid answers', () => {
const { opened, openAtGround } = harness({
picked: new Cesium.Cartesian3(500, 0, 0),
ellipsoid: GROUND,
});
openAtGround({ x: 1, y: 1 });
assert.ok(near(opened[0].point));
});
test('nothing opens over another layer’s object, the sky, from high up, off its map stack, or with the provider off', () => {
const cases = [
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
{ options: { ready: false }, label: 'high up' },
{
options: {
providers: [{ id: 'google', groundClick: true, available: false }],
},
label: 'not Google 3D',
},
{
options: { providers: [{ id: 'google', groundClick: true, on: false }] },
label: 'off',
},
{
options: {
providers: [{ id: 'google', groundClick: true, configured: false }],
},
label: 'no key',
},
{ options: { enabled: false }, label: 'layer off' },
];
for (const { picked, options = {}, label } of cases) {
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }, picked), false, label);
assert.equal(opened.length, 0, label);
}
});
test('street zoom is measured above the ground under the camera', () => {
const viewer = (height) => ({
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height),
},
scene: { globe: { getHeight: () => 1500 } },
});
// 1,500 m up a plateau, 2,000 m above it: still a street.
assert.equal(groundClickHeightOk(viewer(3500)), true);
assert.equal(groundClickHeightOk(viewer(5000)), false);
assert.equal(groundClickHeightOk(null), false);
});
+71 -14
View File
@@ -10,7 +10,7 @@ import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick } from './groundClick.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
@@ -69,8 +69,8 @@ export function createStreetLevelLayer({
parts.selection = createSelection(context);
parts.openAtGround = createGroundClick({
state,
parts,
openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
});
let notifyQueued = false;
@@ -146,6 +146,33 @@ 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.
*/
function providerAvailable(entry) {
const required = entry.def.requiresMapStack;
return !required || mapStack?.getActiveId?.() === required;
}
/** Leaving a provider's map stack closes the image it shows. */
function syncProviderAvailability() {
const owner = state.providers.get(state.street.providerId);
if (state.street.open && owner && !providerAvailable(owner))
layer.closeViewer();
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
function syncGroundClickReady() {
const ready =
state.enabled &&
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
@@ -168,6 +195,7 @@ export function createStreetLevelLayer({
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
syncGroundClickReady();
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
@@ -247,9 +275,20 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
function openImage(providerId, imageId) {
/** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
const entry = state.providers.get(providerId);
if (entry && !providerAvailable(entry)) {
state.street.error = unavailableReason(entry);
notify();
return Promise.resolve(false);
}
return parts.viewerHost.open(providerId, imageId, { frame });
}
function unavailableReason(entry) {
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
}
function setProviderEnabled(providerId, on) {
@@ -276,9 +315,18 @@ export function createStreetLevelLayer({
notify();
}
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (!providerAvailable(entry)) return '';
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return `zoom in to a street to open ${entry.def.name}`;
return stats.hint || '';
}
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
const stats = entry.instance.coverageStats();
const available = providerAvailable(entry);
return {
id: entry.def.id,
name: entry.def.name,
@@ -291,9 +339,11 @@ export function createStreetLevelLayer({
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
hint: hintFor(entry, stats),
error: stats.error || null,
color: entry.def.colors.coverage,
/** Why the provider cannot be used right now, or null. */
unavailable: available ? null : unavailableReason(entry),
};
});
}
@@ -336,7 +386,13 @@ export function createStreetLevelLayer({
source: definitionsFrozen.map((def) => def.name).join(' · '),
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
/** The key the switched-on providers share (or null), for the layer row. */
get requiresKeyId() {
const on = [...state.providers.values()].filter((entry) => entry.on);
return requiresKeyIdFor(
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
);
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
@@ -437,9 +493,11 @@ export function createStreetLevelLayer({
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
},
@@ -482,7 +540,7 @@ export function createStreetLevelLayer({
* Open the nearest image any active provider (or only `providerIds`)
* has around a point, by default the view centre.
*/
async openNearest(point, { providerIds = null } = {}) {
async openNearest(point, { providerIds = null, frame = true } = {}) {
const view = point || viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
@@ -494,9 +552,10 @@ export function createStreetLevelLayer({
state.street.error = null;
notify();
let lastError = null;
const entries = activeEntries().filter(
(entry) => !providerIds || providerIds.includes(entry.def.id),
);
const asked = (entry) =>
(!providerIds || providerIds.includes(entry.def.id)) &&
providerAvailable(entry);
const entries = activeEntries().filter(asked);
for (const entry of entries) {
let imageId = null;
try {
@@ -516,13 +575,11 @@ export function createStreetLevelLayer({
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
nearestLookup = null;
return openImage(entry.def.id, imageId);
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(
(entry) => !providerIds || providerIds.includes(entry.def.id),
);
const stillOn = activeEntries().some(asked);
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
+93
View File
@@ -482,3 +482,96 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
assert.equal(provider.calls.activate, 1);
assert.equal(viewer.credits.length, 1);
});
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) {
const { createGoogleProvider } = await import('./providers/google/index.js');
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
await import('../../testSupport/googleStreetViewFakes.mjs');
const fake = fakeStreetViewLibrary({
panoramas: {
'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' },
},
nearest: nearest ?? (() => 'pano-a'),
});
const mapillaryLookups = [];
const mapillary = fakeProvider({
nearestImage: async (point) => {
mapillaryLookups.push(point);
return null;
},
});
mapillary.requiresKeyId = 'mapillary';
const google = createGoogleProvider({
getApiKey: () => 'browser-key',
loader: fakeMapsLoader(fake.library),
});
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack);
layer.attachViewerHost(fakeHost());
return { layer, viewer, stack, fake, mapillaryLookups };
}
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
const { layer } = await withStreetView(t);
const google = () =>
layer.getUIState().providers.find((p) => p.id === 'google');
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');
});
test('Street View opens only on the Google 3D map, and leaving that map closes it', async (t) => {
const { layer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
await settle();
const google = () =>
layer.getUIState().providers.find((p) => p.id === 'google');
assert.match(google().unavailable, /needs the Google 3D map/);
assert.equal(google().hint, '', 'no "click a street" off Google 3D');
assert.equal(await layer.openImage('google', 'pano-a'), false);
assert.match(layer.getUIState().street.error, /needs the Google 3D map/);
stack.switchTo('photoreal');
assert.equal(google().unavailable, null);
assert.equal(await layer.openImage('google', 'pano-a'), true);
assert.equal(layer.getUIState().street.providerId, 'google');
stack.switchTo('bing');
await settle();
assert.equal(layer.getUIState().street.open, false, 'closed with the map');
});
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
const { layer, mapillaryLookups } = await withStreetView(t, {
nearest: () => null,
});
layer.setProviderEnabled('google', true);
await settle();
const opened = await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(opened, false);
assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked');
assert.equal(
layer.getUIState().street.error,
'No street-level imagery within 50 m of that point',
);
});
test('an image opened from a ground click leaves the camera where it is', async (t) => {
const { layer, viewer } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(layer.getUIState().street.imageId, 'pano-a');
assert.equal(viewer.flights.started, 0, 'no framing flight');
});
@@ -1,4 +1,5 @@
import { passesImageryFilter } from '../../filter.js';
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js';
import {
@@ -8,13 +9,14 @@ import {
GOOGLE_LABEL,
GOOGLE_NAME,
GOOGLE_PROVIDER_ID,
FLAT_FILTER_HINT,
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
imageDateMs,
imageMonthEndMs,
} from './policy.js';
/**
@@ -69,6 +71,8 @@ export function createGoogleProvider({
colors: COLORS,
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
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.
defaultOn: false,
@@ -76,12 +80,21 @@ export function createGoogleProvider({
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();
return service;
}
const viewer = createGoogleViewer({
loader,
getService,
render: context.services?.render,
});
const stopAuthWatch = loader.onAuthFailure(() => {
context.actions.reportError(KEY_REJECTED_MESSAGE);
// Switched off since: its chip says so; no error over another photo.
if (context.isActive?.())
context.actions.reportError(KEY_REJECTED_MESSAGE);
context.notify();
});
@@ -120,7 +133,11 @@ export function createGoogleProvider({
kind: null,
loading: false,
hint:
active && !keyRequired && !keyRejected ? GROUND_CLICK_HINT : '',
!active || keyRequired || keyRejected
? ''
: context.getFilter().pano === 'flat'
? FLAT_FILTER_HINT
: GROUND_CLICK_HINT,
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired,
keyRejected,
@@ -140,13 +157,14 @@ export function createGoogleProvider({
if (filter.pano === 'flat') return null;
const lookup = (async () => {
const lib = await loader.importLibrary('streetView');
service ||= new lib.StreetViewService();
await getService();
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
sources: GOOGLE ? [GOOGLE, OUTDOOR] : [OUTDOOR],
// Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, OUTDOOR],
preference: lib.StreetViewPreference.NEAREST,
});
return data;
@@ -163,10 +181,12 @@ export function createGoogleProvider({
const panoId = data?.location?.pano;
if (!panoId) return null;
const passes = passesImageryFilter(
{ isPano: true, capturedAt: imageDateMs(data.imageDate) ?? 0 },
{ isPano: true, capturedAt: imageMonthEndMs(data.imageDate) ?? 0 },
filter,
);
return passes ? panoId : null;
if (!passes) return null;
viewer.remember(panoId, data);
return panoId;
},
viewer,
@@ -19,9 +19,13 @@ export function createMapsLoader({
/** The script load in flight or done; cleared when it fails, so a retry can. */
let loading = null;
let authFailed = false;
let authHandlerInstalled = false;
const authListeners = new Set();
/** Once per loader: a retried load must not wrap its own handler again. */
function installAuthHandler() {
if (authHandlerInstalled) return;
authHandlerInstalled = true;
const previous = global.gm_authFailure;
global.gm_authFailure = () => {
authFailed = true;
@@ -93,3 +93,21 @@ test("Google's refusal of the key reaches every listener and the page's own hand
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
});
test('a retried load still reports a refusal once, and the page handler once', async () => {
const page = fakePage();
let before = 0;
page.global.gm_authFailure = () => before++;
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const heard = [];
loader.onAuthFailure(() => heard.push('a'));
const failed = loader.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed);
loader.importLibrary('streetView');
await settle();
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
assert.equal(before, 1);
});
@@ -20,16 +20,24 @@ 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 for the Maps JavaScript API. */
/**
* 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.
*/
export const KEY_REJECTED_MESSAGE =
'Google rejected GOOGLE_MAPS_API_KEY for Street View — enable the Maps JavaScript API for it';
'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. */
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;
/** Google answered nothing (most often: the Maps JavaScript API is not enabled). */
/** Google answered nothing: the network, or a key Google ignores. */
export const NO_ANSWER_MESSAGE =
'Street View did not answer — check that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
/** Under FLAT, in place of the click hint: every Street View photo is 360°. */
export const FLAT_FILTER_HINT = 'Street View has 360° photos only';
/** 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';
@@ -55,6 +63,17 @@ export function imageDateMs(imageDate) {
return Date.UTC(Number(match[1]), month - 1, 1);
}
/**
* The last millisecond of an image's month: Google dates by month, so a
* "since" window keeps a panorama taken any time in its cut-off month.
*/
export function imageMonthEndMs(imageDate) {
const start = imageDateMs(imageDate);
if (start === null) return null;
const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) - 1;
}
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) {
const text = String(copyright ?? '')
@@ -12,6 +12,7 @@ import { PROVIDER_COLORS } from '../../policy.js';
import { requiresKeyIdFor, validateProviders } from '../../registry.js';
import { createMapillaryProvider } from '../mapillary/index.js';
import {
fakeHost,
fakeMapsLoader,
fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs';
@@ -28,13 +29,27 @@ function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
const def = createGoogleProvider({ getApiKey: () => apiKey, loader });
const reported = [];
let filter = { pano: 'all', sinceMs: null };
let active = false;
const context = {
services: {},
getFilter: () => filter,
isActive: () => active,
notify() {},
actions: { reportError: (message) => reported.push(message) },
};
const instance = def.create(context);
const created = def.create(context);
// The core switches it on and off; mirror that for isActive().
const instance = {
...created,
activate() {
active = true;
created.activate();
},
deactivate() {
active = false;
created.deactivate();
},
};
return {
def,
instance,
@@ -106,7 +121,7 @@ test('no panorama nearby, or one the imagery filter excludes, is null', async ()
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
});
test('an aborted lookup gives up before and after asking Google', async () => {
test('an aborted lookup gives up before asking Google', async () => {
const { instance } = setup();
const controller = new AbortController();
controller.abort();
@@ -190,3 +205,52 @@ test('aborting a lookup in flight gives up at once', async () => {
controller.abort();
await assert.rejects(lookup, { name: 'AbortError' });
});
test('a Google outage is an error, not "no imagery here"', async () => {
const { instance, fake } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
Promise.reject(
Object.assign(new Error('UNKNOWN_ERROR'), {
code: 'UNKNOWN_ERROR',
endpoint: 'STREETVIEW_GET_PANORAMA',
}),
);
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
code: 'UNKNOWN_ERROR',
});
});
test('a refusal while the provider is off reports no error over another photo', () => {
const { instance, loader, reported } = setup();
loader.refuseKey();
assert.deepEqual(reported, []);
assert.equal(
instance.coverageStats().keyRejected,
true,
'its chip still says so',
);
});
test('a since window keeps a panorama taken any time in its cut-off month', async () => {
const { instance, setFilter } = setup({ nearest: () => 'fresh' }); // 2025-06
setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 5, 20) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'fresh');
setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 6, 1) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null);
});
test('the panorama a lookup found is dated without a second lookup', async () => {
const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 });
const host = fakeHost();
const poses = [];
instance.viewer.onPose((pose) => poses.push(pose));
await instance.viewer.mount(host);
await instance.viewer.open('fresh');
assert.equal(poses.at(-1).capturedAt, Date.UTC(2025, 5, 1));
assert.deepEqual(
fake.lookups.filter((request) => request.pano),
[],
'no getPanorama({pano}) for a panorama the lookup already described',
);
});
@@ -1,14 +1,13 @@
import {
GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE,
OPEN_TIMEOUT_MS,
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,
@@ -31,21 +30,26 @@ 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.
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, render?: object}} options
* @returns {import('../../registry.js').ViewerAdapter}
* `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, render } = {}) {
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} lookup. */
const metadata = new Map();
/** 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() {
@@ -67,41 +71,44 @@ export function createGoogleViewer({ loader, render } = {}) {
}
}
function metaFrom(data) {
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
}
/**
* Date and photographer of a panorama, looked up once: pano_changed and
* position_changed arrive together, so the lookup itself is shared.
* 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.
*/
function describe(panoId) {
if (!metadata.has(panoId)) metadata.set(panoId, lookUp(panoId));
return metadata.get(panoId);
}
async function lookUp(panoId) {
async function describe(panoId) {
if (described.has(panoId) || lookingUp.has(panoId)) return;
lookingUp.add(panoId);
try {
service ||= new library.StreetViewService();
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
const { data } = await service.getPanorama({ pano: panoId });
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
described.set(panoId, metaFrom(data));
} catch {
// The pose still goes out, without a date.
return { capturedAt: null, creator: null, title: null };
return;
} finally {
lookingUp.delete(panoId);
}
publishPose();
}
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;
/** 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() || {};
const latLng = panorama.getPosition() || position;
emit({
providerId: GOOGLE_PROVIDER_ID,
imageId: panoId,
@@ -110,10 +117,10 @@ export function createGoogleViewer({ loader, render } = {}) {
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
altitude: null,
isPano: true,
capturedAt: meta.capturedAt,
capturedAt: meta?.capturedAt ?? null,
capturedAtPrecision: 'month',
creator: meta.creator,
title: meta.title,
creator: meta?.creator ?? null,
title: meta?.title ?? null,
sequenceId: null,
externalUrl: streetViewUrl(panoId, {
heading: pov.heading || 0,
@@ -136,8 +143,10 @@ export function createGoogleViewer({ loader, render } = {}) {
target.append(element);
host = target;
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
subscriptions = ['pano_changed', 'position_changed', 'pov_changed'].map(
(name) => panorama.addListener(name, () => publishPose()),
// 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;
}
@@ -157,41 +166,42 @@ export function createGoogleViewer({ loader, render } = {}) {
}
/**
* 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).
* 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(() => {
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 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') {
done();
resolve();
} else if (status && status !== 'OK') {
done();
reject(new Error('Street View has no imagery for this place'));
}
});
function done() {
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);
subscription.remove();
for (const subscription of subscriptions) subscription.remove();
stopAuthWatch();
settle(value);
}
});
}
@@ -215,11 +225,12 @@ export function createGoogleViewer({ loader, render } = {}) {
instance.setVisible(true);
await ready;
if (pendingOpen !== panoId) return;
await publishPose();
publishPose();
},
close() {
pendingOpen = null;
cancelShown?.();
try {
panorama?.setVisible(false);
} catch {
@@ -229,6 +240,7 @@ export function createGoogleViewer({ loader, render } = {}) {
unmount() {
pendingOpen = null;
cancelShown?.();
generation++;
destroyPanorama();
},
@@ -255,5 +267,10 @@ export function createGoogleViewer({ loader, render } = {}) {
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));
},
};
}
@@ -153,7 +153,128 @@ test('a key Google refuses fails the open at once, not after the timeout', async
panorama.pano = id;
queueMicrotask(() => loader.refuseKey());
};
await assert.rejects(viewer.open('pano-a'), /enable the Maps JavaScript API/);
await assert.rejects(viewer.open('pano-b'), /enable the Maps JavaScript API/);
await assert.rejects(viewer.open('pano-a'), /Maps JavaScript API is enabled/);
await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/);
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind');
});
test('a viewer unmounted while the library loads builds no panorama (none is billed)', async () => {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library, { deferred: true });
const viewer = createGoogleViewer({ loader });
const host = fakeHost();
const mounting = viewer.mount(host);
viewer.unmount();
loader.release();
await assert.rejects(mounting, /unmounted/);
assert.equal(fake.built.length, 0);
assert.equal(host.children.length, 0);
});
test('a date that arrives after the photo was closed emits nothing', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
let answer;
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise((resolve) => {
answer = resolve;
});
panorama.setPano('pano-b'); // Google's arrow; its date is still unknown
await settle();
assert.equal(poses.at(-1).imageId, 'pano-b', 'the step shows at once');
assert.equal(
poses.at(-1).capturedAt,
null,
'undated until the lookup answers',
);
viewer.close();
const count = poses.length;
answer({ data: { imageDate: '2019-08' } });
await settle();
assert.equal(poses.length, count);
});
test('an open never waits on the date lookup', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {}); // the metadata never answers
await viewer.open('pano-a');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('closing while a panorama loads settles the open and drops its listeners', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // still loading
};
const opening = viewer.open('pano-a');
await settle();
viewer.close();
await opening;
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('a second open whose status stays OK still resolves, on the new position', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
assert.equal(fake.built[0].getStatus(), 'OK');
await viewer.open('pano-b'); // OK → OK: no status_changed
assert.equal(poses.at(-1).imageId, 'pano-b');
});
test('Google answering with a newer id for the place shows that id', async () => {
const fake = fakeStreetViewLibrary({
panoramas: PANORAMAS,
substitutes: { 'old-id': 'pano-a' },
});
const viewer = createGoogleViewer({ loader: fakeMapsLoader(fake.library) });
const poses = [];
viewer.onPose((pose) => poses.push(pose));
await viewer.mount(fakeHost());
await viewer.open('old-id');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('a panorama that never shows fails after the timeout and leaves no listener', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { OPEN_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // Google never answers
};
const opening = viewer.open('pano-a');
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(OPEN_TIMEOUT_MS);
await assert.rejects(opening, { message: NO_ANSWER_MESSAGE });
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('a date lookup that failed is asked again on the next view change', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
let failures = 1;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
if (failures-- > 0) return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
fake.built[0].setPov({ heading: 10, pitch: 0 });
await settle();
await settle();
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
});
+2 -1
View File
@@ -10,11 +10,12 @@
* @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); match the share-link option default.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it).
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
+2 -2
View File
@@ -21,9 +21,9 @@ export function createSelection({ state, parts }) {
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
// Nothing of ours, nor anyone else's: the ground itself.
// Not ours: the ground handler decides whether it is the map itself.
if (!route?.instance) {
if (!id) parts.openAtGround?.(click.position);
parts.openAtGround?.(click.position, picked);
return;
}
const entry = state.providers.get(route.providerId);
+10 -5
View File
@@ -67,7 +67,8 @@ function harness({ selected = false } = {}) {
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
openAtGround: (position) => groundClicks.push(position),
openAtGround: (position, picked) =>
groundClicks.push({ position, picked }),
clearSequences() {
sequences.cleared++;
sequences.selected = false;
@@ -264,15 +265,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
}
});
test('a click on bare ground goes to the ground handler; a click on anything with an id does not', () => {
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
h.click(5);
assert.deepEqual(h.groundClicks, [{ x: 5, y: 10 }]);
// Another layer's aircraft, or an id nobody registered: not the ground.
h.scene.under = 'flight:abc';
h.click(6);
assert.equal(h.groundClicks.length, 1);
assert.deepEqual(
h.groundClicks.map(({ position, picked }) => [position.x, picked?.id]),
[
[5, undefined],
[6, 'flight:abc'],
],
);
} finally {
h.restore();
}
+3
View File
@@ -56,6 +56,9 @@ export function createState({ services }) {
...freshStreet(),
},
/** Whether the camera is low enough for a ground click to open an image. */
groundClickReady: false,
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
-31
View File
@@ -295,34 +295,3 @@ export function createHorizonCull({ getViewer, items, onChange }) {
return { update, stop };
}
/**
* The ground under a screen position as {lon, lat}: the rendered surface
* (Google 3D, terrain) where it was picked, else the ellipsoid. Null over sky
* or for a pick that names no place (`isWorldPosition` rejects it).
*/
export function groundPointAt(viewer, screenPosition, { isWorldPosition }) {
const scene = viewer?.scene;
let cartesian = null;
if (scene?.pickPositionSupported) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (!isWorldPosition(cartesian)) {
try {
cartesian = viewer?.camera?.pickEllipsoid(screenPosition);
} catch {
cartesian = null;
}
}
if (!isWorldPosition(cartesian)) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
+7 -4
View File
@@ -79,7 +79,6 @@ export function createViewerHost({ state, parts }) {
warm.unmount();
}
const adapter = entry.instance.viewer;
state.street.renderModes = typeof adapter.setRenderMode === 'function';
const promise = (async () => {
await adapter.mount(state.street.host);
if (mounting?.promise !== promise) {
@@ -139,6 +138,8 @@ export function createViewerHost({ state, parts }) {
try {
const adapter = await mount(entry);
if (!current()) return false;
// Per open: close() resets it, and a reopen skips mount().
state.street.renderModes = typeof adapter.setRenderMode === 'function';
await adapter.open(String(imageId));
if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
@@ -201,9 +202,11 @@ export function createViewerHost({ state, parts }) {
} catch {
/* the real open reports errors */
}
if (active?.id === entry.def.id) continue;
// Switched off while it loaded: release it now rather than keep it.
if (!state.enabled || !entry.on) adapter.unmount();
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.
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
}
@@ -633,3 +633,29 @@ test('a pose can date its image by month and name its place', async () => {
['day', null],
);
});
test('a prewarm that lands after another provider took the host is released, not built under it', async () => {
const { state, host, mapillary } = twoProviders();
let release;
mapillary.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const warming = host.prewarm([state.providers.get('mapillary')]);
await host.open('google', 'g-1'); // the user opens Street View meanwhile
release();
await warming;
assert.equal(mapillary.calls.unmount, 1, 'released at once');
// Not kept warm: the next Mapillary open mounts afresh.
await host.open('mapillary', 'm-1');
assert.equal(state.street.providerId, 'mapillary');
});
test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => {
const { state, host } = twoProviders();
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false);
host.close();
await host.open('google', 'g-2');
assert.equal(state.street.renderModes, false);
});
+36 -11
View File
@@ -1,7 +1,11 @@
/**
* 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.
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate,
* copyright, description}}; `nearest(location, radius)` answers location
* lookups with an id or null; `substitutes` maps an id Google answers with a
* newer one. Like Google, `setPano` sets the id (pano_changed) at once, and
* the position and status follow when the data arrives; status_changed fires
* only when the status changes.
*/
function latLng(lat, lng) {
@@ -11,6 +15,7 @@ function latLng(lat, lng) {
export function fakeStreetViewLibrary({
panoramas = {},
nearest = () => null,
substitutes = {},
} = {}) {
const built = [];
const lookups = [];
@@ -22,6 +27,7 @@ export function fakeStreetViewLibrary({
this.visible = options?.visible ?? true;
this.pano = null;
this.status = null;
this.position = null;
this.pov = { heading: 0, pitch: 0 };
this.listeners = new Map();
built.push(this);
@@ -43,18 +49,27 @@ export function fakeStreetViewLibrary({
return count;
}
/** Like Google: the panorama loads asynchronously, then reports. */
/** Like Google: the id is set at once; the data loads asynchronously. */
setPano(id) {
this.pano = id;
this.fire('pano_changed');
queueMicrotask(() => {
if (this.pano !== id) return;
const known = panoramas[id];
this.status = known ? 'OK' : 'ZERO_RESULTS';
if (known) {
const shownId = substitutes[id] ?? id;
const known = panoramas[shownId];
const status = known ? 'OK' : 'ZERO_RESULTS';
if (shownId !== id) {
this.pano = shownId;
this.fire('pano_changed');
}
if (known) {
this.position = latLng(known.lat, known.lng);
this.fire('position_changed');
}
this.fire('status_changed');
if (status !== this.status) {
this.status = status;
this.fire('status_changed');
}
});
}
@@ -67,8 +82,7 @@ export function fakeStreetViewLibrary({
}
getPosition() {
const known = panoramas[this.pano];
return known ? latLng(known.lat, known.lng) : null;
return this.position;
}
getPov() {
@@ -121,17 +135,28 @@ export function fakeStreetViewLibrary({
};
}
/** A loader over `library`; `refuseKey()` plays Google's gm_authFailure. */
export function fakeMapsLoader(library) {
/**
* A loader over `library`; `refuseKey()` plays Google's gm_authFailure. With
* `deferred`, imports wait for `release()`.
*/
export function fakeMapsLoader(library, { deferred = false } = {}) {
let failed = false;
const listeners = new Set();
const imports = [];
let release = null;
const gate = deferred
? new Promise((resolve) => {
release = resolve;
})
: null;
return {
imports,
async importLibrary(name) {
imports.push(name);
await gate;
return library;
},
release: () => release?.(),
authFailed: () => failed,
onAuthFailure(listener) {
listeners.add(listener);
+5
View File
@@ -37,6 +37,11 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
'https://graph.mapillary.com',
// Google Street View's tiles, controls and contributed photos (the Maps
// JavaScript API itself comes from maps.googleapis.com above).
'https://streetviewpixels-pa.googleapis.com',
'https://maps.gstatic.com',
'https://*.ggpht.com',
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
'https://*.xx.fbcdn.net',
]);
+17
View File
@@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async ()
assert.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/);
});
test('the panel lets Street Level reach Google Street View from inside the embed', () => {
const { csp } = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
})._meta.ui;
// The API script and lookups, panorama tiles, control images, contributed photos.
for (const url of [
'https://maps.googleapis.com/maps/api/js?key=k&v=weekly',
'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1',
'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png',
'https://lh3.ggpht.com/p/photo.jpg',
]) {
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
}
});
+9 -5
View File
@@ -80,6 +80,8 @@ function presentProviders(state) {
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
// Usable only on another map stack (Street View off Google 3D).
else if (provider.unavailable) title = provider.unavailable;
return {
id: provider.id,
label: provider.label,
@@ -89,11 +91,13 @@ function presentProviders(state) {
disabled: false,
state: keyRequired
? 'error'
: on && provider.loading
? 'loading'
: on
? 'active'
: 'idle',
: on && provider.unavailable
? 'warn'
: on && provider.loading
? 'loading'
: on
? 'active'
: 'idle',
busy: on && provider.loading === true,
};
});
+6
View File
@@ -183,6 +183,12 @@ button.sl-status:focus-visible {
border-color: rgba(255, 179, 0, 0.55);
}
/* On, but not usable on this map stack (Street View off Google 3D). */
.sl-chips .data-toggle-chip.chip-warn {
opacity: 0.7;
border-style: dashed;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error {
font-family: var(--font-sans);