Merge branch 'main' into feat/bikeshare-detroit-mogo

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-13 17:32:54 +01:00
commit c2f6a54822
58 files changed
+3941 -175

No files matched your search

+5 -26
View File
@@ -617,32 +617,11 @@ export function createDirectionsLayer({ services, source }) {
}
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 {
+9
View File
@@ -65,6 +65,15 @@ export function imageConeGlyph({
});
}
/** 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);
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
});
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
+56
View File
@@ -0,0 +1,56 @@
import * as Cesium from 'cesium';
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.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 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}
*/
export function createGroundClick({
state,
openNearest,
isAvailable,
groundAt = null,
}) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (
!scenePick.isSurfacePick(picked) ||
!groundClickHeightOk(state.viewer, groundAt)
)
return false;
const providerIds = [...state.providers.values()]
.filter(
(entry) =>
entry.on &&
entry.def.groundClick &&
entry.status?.configured &&
isAvailable(entry),
)
.map((entry) => entry.def.id);
if (!providerIds.length) return 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 },
);
};
}
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
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, 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,
enabled = true,
picked = GROUND,
ellipsoid = undefined,
} = {}) {
const opened = [];
const state = {
enabled,
services: { scenePick },
providers: new Map(
providers.map(
({
id,
on = true,
groundClick,
configured = true,
available = true,
}) => [
id,
{ on, def: { id, groundClick }, status: { configured }, available },
],
),
),
viewer: {
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: {
pickEllipsoid: () => ellipsoid,
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
height,
),
},
},
};
const openAtGround = createGroundClick({
state,
openNearest: (point, options) => {
opened.push({ point, options });
return Promise.resolve(true);
},
isAvailable: (entry) => entry.available,
});
return { opened, openAtGround };
}
const near = (point) =>
Math.abs(point.lon - -121.4944) < 1e-9 &&
Math.abs(point.lat - 38.5816) < 1e-9;
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: { height: 5000 }, 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);
});
+211 -31
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
@@ -5,17 +6,23 @@ import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import {
composeUIState,
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import {
AIM_AT_GROUND_MESSAGE,
FOLLOW_MAP_STACK_ID,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
@@ -66,6 +73,12 @@ export function createStreetLevelLayer({
entry.instance.clearSequence?.();
};
parts.selection = createSelection(context);
parts.openAtGround = createGroundClick({
state,
openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
groundAt: parts.groundCaster?.groundAt ?? null,
});
let notifyQueued = false;
function notify() {
@@ -115,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: true,
on: def === startsOn,
status: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
@@ -140,6 +155,39 @@ export function createStreetLevelLayer({
notify();
}
/** 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;
}
/** 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))
layer.closeViewer();
for (const entry of state.providers.values()) {
if (!entry.def.requiresMapStack) continue;
if (state.enabled && entry.on && providerAvailable(entry))
parts.credits.show(state.viewer, entry.def);
else {
parts.credits.hide(state.viewer, entry.def);
parts.viewerHost.unmount(entry.def.id);
}
}
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.
@@ -162,6 +210,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, {
@@ -222,9 +271,13 @@ export function createStreetLevelLayer({
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
if (!entry.status) refreshStatus(entry);
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
if (!providerAvailable(entry)) return;
parts.credits.show(state.viewer, entry.def);
// The map may have changed by then.
whenIdle(() => {
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
}, 1500);
}
function deactivate(entry) {
@@ -241,25 +294,80 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
function openImage(providerId, imageId) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
/** 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;
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight)
return undefined;
const position = pick(state.viewer, {
x: canvas.clientWidth / 2,
y: canvas.clientHeight / 2,
});
const carto = position && Cesium.Cartographic.fromCartesian(position);
return carto
? {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
}
: null;
}
/** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) {
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);
}
return parts.viewerHost.open(providerId, imageId, { frame });
}
function unavailableReason(entry) {
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;
@@ -270,8 +378,19 @@ export function createStreetLevelLayer({
notify();
}
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return `zoom in to a street to open ${entry.def.name}`;
return stats.hint || '';
}
/** Providers usable on this map stack; the others are not shown at all. */
const listedEntries = () =>
[...state.providers.values()].filter((entry) => providerAvailable(entry));
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
return listedEntries().map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
@@ -285,9 +404,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,
groundClick: entry.def.groundClick === true,
filters: [...filtersOf(entry.def)],
};
});
}
@@ -313,24 +434,43 @@ export function createStreetLevelLayer({
function getUIState() {
const { host, ...street } = state.street;
return composeUIState({
const ui = composeUIState({
enabled: state.enabled,
groundClickReady: state.groundClickReady,
filter: state.filter,
providers: providerSnapshots(),
street,
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 = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: definitionsFrozen.map((def) => def.name).join(' · '),
/** The sources usable on this map (Street View only on Google 3D). */
get source() {
return listedEntries()
.map((entry) => entry.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),
getSourceAvailability() {
@@ -404,16 +544,19 @@ export function createStreetLevelLayer({
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
const snapshots = providerSnapshots();
const coverage = summarizeCoverage(snapshots);
let loadingLabel = '';
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
source: layer.source,
count: coverage.count,
loading: coverage.loading,
keyRequired: coverage.keyRequired,
keyAlternatives: keyAlternatives(snapshots),
error: coverage.keyRequired
? coverage.keyRejected
? coverage.error || keyLabel
@@ -441,9 +584,11 @@ export function createStreetLevelLayer({
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
},
@@ -460,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;
},
@@ -476,15 +625,39 @@ export function createStreetLevelLayer({
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
whenIdle(
() =>
parts.viewerHost.prewarm(
activeEntries().filter((entry) => providerAvailable(entry)),
),
1500,
);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/** Open the nearest image any active provider has around a point. */
async openNearest(point) {
const view = point || viewCentre(state.viewer);
/**
* 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,
{ providerIds = null, frame = true, aim = 'view' } = {},
) {
let view = point;
if (!view && aim === 'screen-centre') {
view = screenCentreGround();
if (view === null) {
abortNearest();
state.street.loading = false;
state.street.error = AIM_AT_GROUND_MESSAGE;
notify();
return false;
}
}
view ||= viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
abortNearest();
@@ -495,7 +668,11 @@ export function createStreetLevelLayer({
state.street.error = null;
notify();
let lastError = null;
for (const entry of activeEntries()) {
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 {
imageId = await entry.instance.nearestImage(
@@ -511,17 +688,20 @@ 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);
return openImage(entry.def.id, imageId, { frame });
}
if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report.
if (state.enabled && activeEntries().length)
const stillOn = entries.some(
(entry) => entry.on && providerAvailable(entry),
);
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
`No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : 'the view centre'}`;
state.street.loading = false;
notify();
return false;
+322
View File
@@ -483,6 +483,328 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
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, mapillaryKeyless = false } = {},
) {
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';
if (mapillaryKeyless) {
mapillary.stats.keyRequired = true;
const create = mapillary.create;
mapillary.create = (context) => ({
...create(context),
status: async () => ({ configured: false }),
});
}
const loader = fakeMapsLoader(fake.library);
const google = createGoogleProvider({
getApiKey: () => 'browser-key',
loader,
});
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack);
layer.attachViewerHost(fakeHost());
return { layer, viewer, stack, fake, loader, 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, '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) => {
const { layer, viewer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
await settle();
const ui = () => layer.getUIState();
const listed = () => ui().providers.map((p) => p.id);
const credited = () =>
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
assert.ok(!ui().legend.some((item) => item.key === 'google'));
assert.equal(credited(), false, 'no Google credit on another map');
assert.equal(layer.source, 'Mapillary');
assert.equal(layer.getStats().source, 'Mapillary');
assert.equal(await layer.openImage('google', 'pano-a'), false);
assert.match(ui().street.error, /needs the Google 3D map/);
assert.equal(layer.getParams().google, true, 'the choice is kept');
stack.switchTo('photoreal');
assert.deepEqual(listed(), ['mapillary', 'google']);
assert.equal(credited(), true);
assert.equal(layer.source, 'Mapillary · Google Street View');
assert.equal(await layer.openImage('google', 'pano-a'), true);
assert.equal(ui().street.providerId, 'google');
stack.switchTo('bing');
await settle();
assert.equal(ui().street.open, false, 'closed with the map');
assert.equal(credited(), false);
assert.deepEqual(listed(), ['mapillary']);
});
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
const { layer } = await withStreetView(t);
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
});
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');
});
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
const { layer, loader } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.deepEqual(loader.imports, [], 'no prewarm, no lookup');
});
test('the button’s “centre of the view” is the ground under the middle of the screen', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await layer.openNearest(undefined, { aim: 'screen-centre' });
assert.equal(lookups.length, 1);
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
});
test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
// Every ground pick misses: the middle of the screen is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.camera.pickEllipsoid = () => undefined;
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
assert.equal(
await layer.openNearest(undefined, { aim: 'screen-centre' }),
false,
);
assert.deepEqual(lookups, [], 'no lookup at the view centre instead');
const { street } = layer.getUIState();
assert.match(street.error, /Aim the middle of the view at a street/);
assert.equal(street.loading, false);
});
test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => {
const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true });
await settle();
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.deepEqual(stats.keyAlternatives, [
{ id: 'google', label: 'GOOGLE STREET VIEW', requiresKeyId: 'google-maps' },
]);
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
stack.switchTo('esri-imagery');
assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D');
// Lit, Street View draws: the layer is no longer key-gated.
stack.switchTo('photoreal');
layer.setProviderEnabled('google', true);
assert.equal(layer.getStats().keyRequired, false);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
+35 -11
View File
@@ -1,11 +1,27 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph } from './glyphs.js';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
function markerLook(style) {
if (style?.kind === 'view-cone') {
const color = style.color || COLORS.position;
return {
key: `view-cone:${color}`,
image: viewConeMarkerGlyph({ color }),
size: 48,
};
}
return {
key: 'pin',
image: positionMarkerGlyph({ color: COLORS.position }),
size: 40,
};
}
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
@@ -42,7 +58,8 @@ export function createMarker({ state, parts }) {
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position) set(position, last.bearing);
if (ready && last?.position === position)
set(position, last.bearing, last.style);
});
return null;
}
@@ -53,14 +70,15 @@ export function createMarker({ state, parts }) {
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain') set(last.position, last.bearing);
if (last && state.surface === 'terrain')
set(last.position, last.bearing, last.style);
});
/** Move (or create) the marker; a null position removes it. */
function set(position, bearing) {
/** 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;
last = position ? { position, bearing } : null;
last = position ? { position, bearing, style } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
@@ -78,14 +96,20 @@ export function createMarker({ state, parts }) {
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const look = markerLook(style);
if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard);
state.marker.billboard = null;
}
if (!state.marker.billboard) {
state.marker.look = look.key;
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
position: cartesian,
image: positionMarkerGlyph({ color: COLORS.position }),
imageId: 'sl-position',
width: 40,
height: 40,
image: look.image,
imageId: `sl-position:${look.key}`,
width: look.size,
height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
@@ -117,7 +141,7 @@ export function createMarker({ state, parts }) {
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing);
if (last) set(last.position, last.bearing, last.style);
}
return {
+26
View File
@@ -40,6 +40,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
collection: {
show: true,
add: (options) => ({ ...options }),
remove() {},
removeAll() {},
},
billboard: null,
@@ -187,3 +188,28 @@ test('draped mode clamps the marker to the globe whatever the ground says', () =
);
assert.equal(ground.prepares.length, 0);
});
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
const { state, marker } = setup();
const removed = [];
state.marker.collection.remove = (billboard) => removed.push(billboard);
const streetView = { kind: 'view-cone', color: '#4285f4' };
marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView);
const cone = state.marker.billboard;
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4');
assert.equal(
cone.rotation,
-Cesium.Math.toRadians(45),
'it turns with the view',
);
// Dragging the panorama keeps the look and the billboard.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
assert.equal(state.marker.billboard, cone);
// Re-placed for a surface change: still the cone.
marker.setSurface();
assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
assert.deepEqual(removed, [cone]);
});
+7
View File
@@ -7,6 +7,7 @@ export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
google: '#4285f4',
});
/** Colours every provider shares: the selection highlight and the marker. */
@@ -35,3 +36,9 @@ 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;
/** Above this, a ground click is a misclick, not a street. */
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
export const AIM_AT_GROUND_MESSAGE =
'Aim the middle of the view at a street to look for imagery there';
@@ -0,0 +1,184 @@
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js';
import {
COLORS,
GOOGLE_CREDIT_HTML,
GOOGLE_KEY_ID,
GOOGLE_LABEL,
GOOGLE_NAME,
GOOGLE_PROVIDER_ID,
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
} from './policy.js';
/** `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(
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
ms,
);
const onAbort = () => finish(reject, signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
const stopAuthWatch = loader.onAuthFailure(() =>
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
);
promise.then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
function finish(settle, value) {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
stopAuthWatch();
settle(value);
}
});
}
/**
* Google Street View 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({
getApiKey,
loader = createMapsLoader({ getApiKey }),
}) {
if (typeof getApiKey !== 'function')
throw new TypeError('Google Street View needs getApiKey');
return Object.freeze({
id: GOOGLE_PROVIDER_ID,
name: GOOGLE_NAME,
label: GOOGLE_LABEL,
requiresKeyId: GOOGLE_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
groundClick: true,
// Google's terms forbid Street View beside non-Google maps.
requiresMapStack: FOLLOW_MAP_STACK_ID,
// Every panorama is billed to the Maps key.
defaultOn: false,
// All 360°, and no coverage for a date filter to thin.
filters: [],
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
create(context) {
let keyRequired = !getApiKey();
let active = false;
let service = null;
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(() => {
// Switched off: its chip says so.
if (context.isActive?.())
context.actions.reportError(KEY_REJECTED_MESSAGE);
context.notify();
});
return {
async status() {
keyRequired = !getApiKey();
context.notify();
return { configured: !keyRequired };
},
init() {},
activate() {
active = true;
},
deactivate() {
active = false;
},
destroy() {
active = false;
stopAuthWatch();
},
refreshCoverage() {},
/** No filter applies to Street View (see `filters`). */
setFilter() {},
coverageStats() {
const keyRejected = loader.authFailed();
return {
count: 0,
zoom: null,
kind: null,
loading: false,
hint:
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired,
keyRejected,
};
},
handlePick: () => false,
/**
* Nearest Google-collected outdoor panorama, or null. The imagery
* filters do not apply (see `filters`).
*/
async nearestImage({ lat, lon }, { signal } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
signal?.throwIfAborted();
// Set once the caller has its answer (aborted, timed out, refused):
// a lookup still waiting on Google's script then asks nothing.
let settled = false;
const lookup = (async () => {
const lib = await loader.importLibrary('streetView');
await getService();
if (settled) return null;
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
// Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, OUTDOOR],
preference: lib.StreetViewPreference.NEAREST,
});
return data;
} catch (error) {
if (error?.code === 'ZERO_RESULTS') return null;
throw error;
}
})();
const data = await settleWithin(lookup, {
ms: NEAREST_TIMEOUT_MS,
signal,
loader,
}).finally(() => {
settled = true;
});
const panoId = data?.location?.pano;
if (!panoId) return null;
viewer.remember(panoId, data);
return panoId;
},
viewer,
};
},
});
}
@@ -0,0 +1,85 @@
/**
* 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, shared by every loader on it. */
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
/**
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
*/
export function createMapsLoader({
getApiKey,
document = globalThis.document,
global = globalThis,
}) {
let authFailed = false;
let authHandlerInstalled = false;
const authListeners = new Set();
/** Once per loader: a retry must not wrap its own handler. */
function installAuthHandler() {
if (authHandlerInstalled) return;
authHandlerInstalled = true;
const previous = global.gm_authFailure;
global.gm_authFailure = () => {
authFailed = true;
for (const listener of [...authListeners]) {
try {
listener();
} catch {
/* a listener's error is its own */
}
}
if (typeof previous === 'function') previous();
};
}
function load() {
const key = getApiKey?.();
if (!key)
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
installAuthHandler();
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
if (global.google?.maps?.importLibrary)
return Promise.resolve(global.google.maps);
const loading = new Promise((resolve, reject) => {
global[READY_CALLBACK] = () => {
delete global[READY_CALLBACK];
resolve(global.google.maps);
};
const script = document.createElement('script');
script.async = true;
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
key,
v: 'weekly',
loading: 'async',
callback: READY_CALLBACK,
})}`;
script.onerror = () => {
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
script.remove();
delete global[READY_CALLBACK];
reject(new Error('The Google Maps JavaScript API could not load'));
};
document.head.append(script);
});
global[PAGE_LOAD] = loading;
return loading;
}
return {
async importLibrary(name) {
const maps = await load();
return maps.importLibrary(name);
},
authFailed: () => authFailed,
onAuthFailure(listener) {
authListeners.add(listener);
return () => authListeners.delete(listener);
},
};
}
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapsLoader } from './mapsLoader.js';
/** A document whose appended scripts the test loads or fails. */
function fakePage() {
const scripts = [];
const document = {
createElement: () => {
const script = {
remove() {
scripts.splice(scripts.indexOf(script), 1);
},
};
return script;
},
head: { append: (script) => scripts.push(script) },
};
const global = {};
const library = { name: 'streetView' };
/** Google's script arrived: it defines google.maps and calls back. */
const loadScript = () => {
global.google = {
maps: { importLibrary: async (name) => ({ ...library, name }) },
};
const callback = new URL(scripts.at(-1).src).searchParams.get('callback');
global[callback]();
};
return { document, global, scripts, loadScript };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('the API loads once, with the key, asynchronously, and hands out libraries', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => 'k-1', ...page });
const first = loader.importLibrary('streetView');
const second = loader.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1, 'one script for concurrent imports');
const url = new URL(page.scripts[0].src);
assert.equal(
url.origin + url.pathname,
'https://maps.googleapis.com/maps/api/js',
);
assert.equal(url.searchParams.get('key'), 'k-1');
assert.equal(url.searchParams.get('loading'), 'async');
assert.equal(page.scripts[0].async, true);
page.loadScript();
assert.equal((await first).name, 'streetView');
assert.equal((await second).name, 'streetView');
const callback = url.searchParams.get('callback');
assert.equal(page.global[callback], undefined, 'the callback is cleaned up');
});
test('two loaders on one page share one script and both get the API', async () => {
const page = fakePage();
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
const one = first.importLibrary('streetView');
const two = second.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1, 'one script for the page');
page.loadScript();
assert.equal((await one).name, 'streetView');
assert.equal((await two).name, 'streetView');
assert.equal((await first.importLibrary('streetView')).name, 'streetView');
});
test('after a failed script, either loader on the page can retry it', async () => {
const page = fakePage();
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
const failed = first.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed, /could not load/);
const retry = second.importLibrary('streetView');
const again = first.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1);
page.loadScript();
assert.equal((await retry).name, 'streetView');
assert.equal((await again).name, 'streetView');
});
test('without a key nothing loads', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => null, ...page });
await assert.rejects(loader.importLibrary('streetView'), /not set/);
assert.equal(page.scripts.length, 0);
});
test('a script that fails to load can be retried', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const failed = loader.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed, /could not load/);
assert.equal(page.scripts.length, 0, 'the failed script is removed');
const retry = loader.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1);
page.loadScript();
assert.equal((await retry).name, 'streetView');
});
test("Google's refusal of the key reaches every listener and the page's own handler", async () => {
const page = fakePage();
let before = 0;
page.global.gm_authFailure = () => before++;
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const heard = [];
const stop = loader.onAuthFailure(() => heard.push('a'));
loader.importLibrary('streetView');
await settle();
assert.equal(loader.authFailed(), false);
page.global.gm_authFailure();
assert.equal(loader.authFailed(), true);
assert.deepEqual(heard, ['a']);
assert.equal(before, 1, 'a handler installed earlier still runs');
stop();
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);
});
@@ -0,0 +1,65 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
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';
export const PICK_PREFIX = Object.freeze({ root: 'gsv:' });
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.google,
selected: SHARED_COLORS.selected,
});
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). */
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';
/** Past these, a nearest lookup, an open or a details lookup has failed. */
export const NEAREST_TIMEOUT_MS = 10_000;
export const OPEN_TIMEOUT_MS = 20_000;
export const DETAILS_TIMEOUT_MS = 10_000;
/** Pause after a failed details lookup before a view event can retry. */
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
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';
export const GROUND_CLICK_HINT = 'click a street to open Street View';
/** Deep link to a panorama on Google Maps (Maps URLs). */
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
const params = new URLSearchParams({
api: '1',
map_action: 'pano',
pano: String(panoId),
heading: String(Math.round(heading)),
pitch: String(Math.round(pitch)),
});
return `https://www.google.com/maps/@?${params}`;
}
/** Epoch ms for a Street View image date ('YYYY-MM'), or null. */
export function imageDateMs(imageDate) {
const match = /^(\d{4})-(\d{2})/.exec(String(imageDate ?? ''));
if (!match) return null;
const month = Number(match[2]);
if (month < 1 || month > 12) return null;
return Date.UTC(Number(match[1]), month - 1, 1);
}
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) {
const text = String(copyright ?? '')
.replace(/^©\s*(\d{4}\s+)?/, '')
.replace(/^From the Owner,\s*Photo by:\s*/i, '')
.trim();
return text || null;
}
@@ -0,0 +1,293 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleProvider } from './index.js';
import {
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
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';
const PANORAMAS = {
fresh: { lat: 38.5816, lng: -121.4944, imageDate: '2025-06' },
old: { lat: 38.6, lng: -121.5, imageDate: '2009-03' },
};
function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS, nearest });
const loader = fakeMapsLoader(fake.library);
let apiKey = key;
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 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,
fake,
loader,
reported,
setFilter: (next) => (filter = next),
setKey: (next) => (apiKey = next),
};
}
test('the provider registers beside Mapillary, off by default, gated on the browser Maps key', () => {
const { def } = setup();
assert.equal(def.id, 'google');
assert.equal(def.label, 'GOOGLE STREET VIEW');
assert.deepEqual(def.filters, [], 'no imagery filter applies');
assert.deepEqual(def.marker, { kind: 'view-cone', color: '#4285f4' });
assert.equal(def.requiresKeyId, 'google-maps');
assert.equal(def.defaultOn, false, 'every panorama is billed');
assert.equal(def.groundClick, true);
assert.equal(def.colors.coverage, PROVIDER_COLORS.google);
assert.match(def.credit.html, /Street View imagery © .*Google/);
const mapillary = createMapillaryProvider({
source: {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
},
});
const both = validateProviders([mapillary, def]);
assert.equal(requiresKeyIdFor(both), null, 'each chip gates on its own key');
assert.throws(() => createGoogleProvider({}), /getApiKey/);
});
test('status follows the browser Maps key', async () => {
const { instance, setKey } = setup({ key: '' });
assert.deepEqual(await instance.status(), { configured: false });
assert.equal(instance.coverageStats().keyRequired, true);
setKey('browser-key');
assert.deepEqual(await instance.status(), { configured: true });
assert.equal(instance.coverageStats().keyRequired, false);
});
test('the nearest lookup asks Google for its own outdoor imagery within 50 m', async () => {
const { instance, fake } = setup();
assert.equal(
await instance.nearestImage({ lat: 38.58, lon: -121.49 }),
'fresh',
);
const [request] = fake.lookups;
assert.deepEqual(request.location, { lat: 38.58, lng: -121.49 });
assert.equal(request.radius, 50);
assert.deepEqual(request.sources, ['google', 'outdoor']);
assert.equal(request.preference, 'nearest');
});
test('no panorama nearby is null; the imagery filters (Mapillary\u2019s) do not apply', async () => {
const none = setup({ nearest: () => null });
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
const { instance, setFilter } = setup({ nearest: () => 'old' }); // 2009
for (const filter of [
{ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) },
{ pano: 'flat', sinceMs: null },
{ pano: 'pano', sinceMs: Date.UTC(2025, 0, 1) },
]) {
setFilter(filter);
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
}
assert.equal(instance.coverageStats().hint, '', 'off: no hint');
instance.activate();
setFilter({ pano: 'flat', sinceMs: null });
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
});
test('an aborted lookup gives up before asking Google', async () => {
const { instance } = setup();
const controller = new AbortController();
controller.abort();
await assert.rejects(
instance.nearestImage({ lat: 0, lon: 0 }, { signal: controller.signal }),
{ name: 'AbortError' },
);
});
test('a key Google refuses reads KEY REJECTED, shows why, and stops lookups', async () => {
const { instance, loader, reported } = setup();
instance.activate();
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
loader.refuseKey();
const stats = instance.coverageStats();
assert.equal(stats.keyRejected, true);
assert.equal(stats.error, KEY_REJECTED_MESSAGE);
assert.equal(stats.hint, '', 'no "click a street" hint over a dead key');
assert.deepEqual(reported, [KEY_REJECTED_MESSAGE]);
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
message: KEY_REJECTED_MESSAGE,
});
instance.destroy();
assert.equal(loader.listenerCount(), 0, 'destroy stops listening');
});
test('the hint shows only while the provider is on', () => {
const { instance } = setup();
assert.equal(instance.coverageStats().hint, '');
instance.activate();
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
instance.deactivate();
assert.equal(instance.coverageStats().hint, '');
});
test('image dates, photographers and deep links read the way Google writes them', () => {
assert.equal(imageDateMs('2024-05'), Date.UTC(2024, 4, 1));
assert.equal(imageDateMs('2024-13'), null);
assert.equal(imageDateMs(undefined), null);
assert.equal(creatorFrom('© 2024 Google'), 'Google');
assert.equal(creatorFrom('From the Owner, Photo by: Ada'), 'Ada');
assert.equal(creatorFrom(''), null);
assert.equal(
streetViewUrl('a b', { heading: 90.4, pitch: -9.6 }),
'https://www.google.com/maps/@?api=1&map_action=pano&pano=a+b&heading=90&pitch=-10',
);
});
test('a lookup Google never answers fails after the timeout, and a refused key fails it at once', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
const { instance, fake, loader } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {});
const hung = instance.nearestImage({ lat: 0, lon: 0 });
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
await assert.rejects(hung, { message: NO_ANSWER_MESSAGE });
const refused = instance.nearestImage({ lat: 0, lon: 0 });
await Promise.resolve();
loader.refuseKey();
await assert.rejects(refused, { message: KEY_REJECTED_MESSAGE });
assert.equal(
loader.listenerCount(),
1,
'only the provider’s own watch is left',
);
});
test('aborting a lookup in flight gives up at once', async () => {
const { instance, fake } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {});
const controller = new AbortController();
const lookup = instance.nearestImage(
{ lat: 0, lon: 0 },
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(lookup, { name: 'AbortError' });
});
test('a lookup cancelled while Google\u2019s script loads asks Google nothing', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
const fake = fakeStreetViewLibrary({
panoramas: PANORAMAS,
nearest: () => 'fresh',
});
const loader = fakeMapsLoader(fake.library, { deferred: true });
const instance = createGoogleProvider({
getApiKey: () => 'browser-key',
loader,
}).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { reportError() {} },
});
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
// Aborted, then timed out, while the script still loads.
const controller = new AbortController();
const aborted = instance.nearestImage(
{ lat: 0, lon: 0 },
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(aborted, { name: 'AbortError' });
const expired = instance.nearestImage({ lat: 0, lon: 0 });
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
await assert.rejects(expired, /did not answer/);
// The script arrives: a current lookup still gets its answer.
const current = instance.nearestImage({ lat: 0, lon: 0 });
loader.release();
await flush();
assert.equal(await current, 'fresh');
assert.equal(fake.lookups.length, 1, 'only the current lookup asked Google');
});
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('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',
);
});
@@ -0,0 +1,286 @@
import {
DETAILS_RETRY_COOLDOWN_MS,
DETAILS_TIMEOUT_MS,
GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE,
OPEN_TIMEOUT_MS,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
const PANORAMA_OPTIONS = Object.freeze({
visible: false,
// 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 ×.
fullscreenControl: false,
enableCloseButton: false,
motionTracking: false,
motionTrackingControl: false,
});
/**
* 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}}
*/
export function createGoogleViewer({ loader, getService, render } = {}) {
let library = null;
let panorama = null;
let element = null;
let host = null;
let service = null;
let pendingOpen = null;
let cancelShown = null;
/** Bumped by `unmount`: a construction it overtook builds nothing. */
let generation = 0;
let subscriptions = [];
const described = new Map();
/** Panorama id → its lookup in flight; a late answer to a dropped one is ignored. */
const lookingUp = new Map();
const retryAfter = new Map();
const listeners = new Set();
function requestRender() {
render?.governorRequestRender?.('google-street-view');
}
async function ensureLibrary() {
library ||= await loader.importLibrary('streetView');
return library;
}
function emit(pose) {
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
}
}
function metaFrom(data) {
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
}
/**
* Look up a panorama's date, photographer and address once, 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;
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
const lookup = { timer: null };
lookingUp.set(panoId, lookup);
const current = () => lookingUp.get(panoId) === lookup;
try {
const { data } = await new Promise((resolve, reject) => {
lookup.timer = setTimeout(
() => reject(new Error(NO_ANSWER_MESSAGE)),
DETAILS_TIMEOUT_MS,
);
(async () => {
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
return service.getPanorama({ pano: panoId });
})().then(resolve, reject);
});
if (!current()) return;
described.set(panoId, metaFrom(data));
retryAfter.delete(panoId);
} catch {
if (current())
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
return;
} finally {
clearTimeout(lookup.timer);
if (current()) lookingUp.delete(panoId);
}
publishPose();
}
function releaseLookups() {
for (const lookup of lookingUp.values()) clearTimeout(lookup.timer);
lookingUp.clear();
}
function publishPose() {
const panoId = panorama?.getPano?.();
const latLng = panorama?.getPosition?.();
if (!panoId || !latLng || pendingOpen === null) return;
const meta = described.get(panoId);
if (!meta) describe(panoId);
const pov = panorama.getPov() || {};
emit({
providerId: GOOGLE_PROVIDER_ID,
imageId: panoId,
position: { lon: latLng.lng(), lat: latLng.lat() },
bearing: Number.isFinite(pov.heading) ? pov.heading : null,
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
altitude: null,
isPano: true,
capturedAt: meta?.capturedAt ?? null,
capturedAtPrecision: 'month',
creator: meta?.creator ?? null,
title: meta?.title ?? null,
sequenceId: null,
externalUrl: streetViewUrl(panoId, {
heading: pov.heading || 0,
pitch: pov.pitch || 0,
}),
});
requestRender();
}
async function ensurePanorama(target) {
if (panorama && host === target) return panorama;
const built = generation;
const { StreetViewPanorama } = await ensureLibrary();
if (built !== generation) throw new Error('Street View was unmounted');
if (panorama && host === target) return panorama;
destroyPanorama();
element = target.ownerDocument.createElement('div');
element.className = 'sl-google-panorama';
element.style.cssText = 'position:absolute;inset:0;';
target.append(element);
host = target;
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
// Not pano_changed: it can fire with the old panorama's position.
subscriptions = ['position_changed', 'pov_changed'].map((name) =>
panorama.addListener(name, () => publishPose()),
);
return panorama;
}
function destroyPanorama() {
for (const subscription of subscriptions) subscription.remove();
subscriptions = [];
try {
panorama?.setVisible(false);
} catch {
/* already gone */
}
element?.remove();
panorama = null;
element = null;
host = null;
}
/**
* Resolves once `panoId` shows (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')
return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
OPEN_TIMEOUT_MS,
);
const stopAuthWatch = loader.onAuthFailure(() =>
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
);
cancelShown = () => finish(resolve);
// 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();
if (status === 'OK') finish(resolve);
else if (status)
finish(
reject,
new Error('Street View has no imagery for this place'),
);
};
const onPosition = () => {
if (pendingOpen !== panoId || instance.getStatus() === 'OK')
finish(resolve);
};
const subscriptions = [
instance.addListener('status_changed', onStatus),
instance.addListener('position_changed', onPosition),
];
function finish(settle, value) {
cancelShown = null;
clearTimeout(timer);
for (const subscription of subscriptions) subscription.remove();
stopAuthWatch();
settle(value);
}
});
}
return {
async mount(target) {
if (!target) throw new Error('Street Level viewer has no host element');
await ensurePanorama(target);
},
async open(imageId) {
const panoId = String(imageId);
if (!host) throw new Error('Street View is not mounted');
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
pendingOpen = panoId;
const instance = await ensurePanorama(host);
if (pendingOpen !== panoId) return;
const ready = shown(instance, panoId);
instance.setPano(panoId);
instance.setVisible(true);
await ready;
if (pendingOpen !== panoId) return;
publishPose();
},
close() {
pendingOpen = null;
cancelShown?.();
try {
panorama?.setVisible(false);
} catch {
/* torn down */
}
},
unmount() {
pendingOpen = null;
cancelShown?.();
releaseLookups();
generation++;
destroyPanorama();
},
resize() {
if (!panorama) return;
try {
globalThis.google?.maps?.event?.trigger(panorama, 'resize');
} catch {
/* no-op */
}
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Seed the details cache from a nearest lookup's answer. */
remember(panoId, data) {
if (panoId && data) described.set(String(panoId), metaFrom(data));
},
};
}
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleViewer } from './viewer.js';
import { DETAILS_RETRY_COOLDOWN_MS, DETAILS_TIMEOUT_MS } from './policy.js';
import {
fakeHost,
fakeMapsLoader,
fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs';
const PANORAMAS = {
'pano-a': {
lat: 38.5816,
lng: -121.4944,
imageDate: '2024-05',
copyright: '© 2024 Google',
description: '10th St',
},
'pano-b': {
lat: 38.5817,
lng: -121.4944,
imageDate: '2019-08',
copyright: 'From the Owner, Photo by: Ada',
},
};
function setup() {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library);
const viewer = createGoogleViewer({ loader });
const poses = [];
viewer.onPose((pose) => poses.push(pose));
const host = fakeHost();
return { fake, loader, viewer, poses, host };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('opening a panorama shows it and emits a provider-neutral pose', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
assert.equal(host.children.length, 1, 'renders in its own element');
const [panorama] = fake.built;
assert.equal(panorama.options.fullscreenControl, false);
assert.equal(panorama.options.addressControl, false, 'the caption has it');
assert.equal(panorama.options.enableCloseButton, false);
assert.equal(panorama.visible, false, 'nothing shows before an open');
await viewer.open('pano-a');
assert.equal(panorama.visible, true);
await settle(); // the pose is dated once its lookup answers
const pose = poses.at(-1);
assert.deepEqual(
{
providerId: pose.providerId,
imageId: pose.imageId,
position: pose.position,
isPano: pose.isPano,
capturedAt: pose.capturedAt,
capturedAtPrecision: pose.capturedAtPrecision,
creator: pose.creator,
title: pose.title,
},
{
providerId: 'google',
imageId: 'pano-a',
position: { lon: -121.4944, lat: 38.5816 },
isPano: true,
capturedAt: Date.UTC(2024, 4, 1),
capturedAtPrecision: 'month',
creator: 'Google',
title: '10th St',
},
);
assert.match(pose.externalUrl, /map_action=pano&pano=pano-a/);
});
test('dragging the view and stepping along an arrow both emit poses; the date is looked up once per panorama', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
panorama.setPov({ heading: 90, pitch: -10 });
await settle();
assert.deepEqual(
[poses.at(-1).bearing, poses.at(-1).tilt],
[90, -10],
'the view follows the drag',
);
// Google's own arrow: the panorama changes without an open.
panorama.setPano('pano-b');
await settle();
await settle();
assert.equal(poses.at(-1).imageId, 'pano-b');
assert.equal(poses.at(-1).creator, 'Ada');
const dateLookups = fake.lookups.filter((request) => request.pano);
assert.deepEqual(
dateLookups.map((request) => request.pano),
['pano-a', 'pano-b'],
);
});
test('a panorama Google has no imagery for fails the open', async () => {
const { viewer, host } = setup();
await viewer.mount(host);
await assert.rejects(viewer.open('gone'), /no imagery/);
});
test('a closed panorama is hidden and its late view changes emit nothing', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
viewer.close();
assert.equal(panorama.visible, false);
const count = poses.length;
panorama.setPov({ heading: 180, pitch: 0 });
await settle();
assert.equal(poses.length, count);
});
test('an open overtaken by a newer one only shows the newer panorama', async () => {
const { viewer, poses, host } = setup();
await viewer.mount(host);
const first = viewer.open('pano-a');
const second = viewer.open('pano-b');
await Promise.all([first, second]);
assert.deepEqual(
[...new Set(poses.map((pose) => pose.imageId))].at(-1),
'pano-b',
);
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
});
test('unmounting removes the element and every listener', async () => {
const { fake, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
assert.ok(panorama.listenerCount() > 0);
viewer.unmount();
assert.equal(host.children.length, 0);
assert.equal(panorama.listenerCount(), 0);
await assert.rejects(viewer.open('pano-a'), /not mounted/);
});
test('there is no prewarm: Google\u2019s script loads on first use only, never on another map', () => {
const { viewer, loader } = setup();
assert.equal(viewer.prewarm, undefined);
assert.deepEqual(loader.imports, []);
});
test('a key Google refuses fails the open at once, not after the timeout', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
// Google refuses the key while the panorama loads; it never reports OK.
panorama.setPano = (id) => {
panorama.pano = id;
queueMicrotask(() => loader.refuseKey());
};
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');
viewer.unmount();
});
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('failed details lookups cool down through repeated drags and retry after the pause', async (t) => {
let now = 1_000;
t.mock.method(Date, 'now', () => now);
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
let failures = 2;
let calls = 0;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
calls++;
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');
assert.equal(calls, 1);
for (let attempt = 0; attempt < 2; attempt++) {
for (let heading = 0; heading < 20; heading++) {
fake.built[0].setPov({ heading, pitch: 0 });
await settle();
}
assert.equal(calls, attempt + 1, 'drags do not retry during the pause');
assert.equal(poses.at(-1).bearing, 19, 'pose updates still follow drags');
now += DETAILS_RETRY_COOLDOWN_MS - 1;
fake.built[0].setPov({ heading: 20, pitch: 0 });
await settle();
assert.equal(calls, attempt + 1, 'still paused just before the deadline');
now++;
fake.built[0].setPov({ heading: 21, pitch: 0 });
await settle();
assert.equal(calls, attempt + 2, 'one retry at the deadline');
}
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
now += DETAILS_RETRY_COOLDOWN_MS;
fake.built[0].setPov({ heading: 10, pitch: 0 });
await settle();
assert.equal(calls, 3, 'successful details stay cached');
});
test('a failed panorama does not delay details for another panorama', async (t) => {
t.mock.method(Date, 'now', () => 1_000);
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
if (request.pano === 'pano-a') return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
fake.built[0].setPano('pano-b');
await settle();
assert.deepEqual(requests, ['pano-a', 'pano-b']);
assert.equal(poses.at(-1).creator, 'Ada');
fake.built[0].setPano('pano-a');
await settle();
assert.deepEqual(
requests,
['pano-a', 'pano-b'],
'returning respects the pause',
);
});
test('a retry after a failed open is not failed by the old panorama\u2019s status', async () => {
const { viewer, poses, host } = setup();
await viewer.mount(host);
await assert.rejects(viewer.open('gone'), /no imagery/);
// The new panorama moves first; its OK status follows.
await viewer.open('pano-a');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('a details lookup that never answers times out, cools down and retries', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000 });
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
let hang = true;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
return hang ? new Promise(() => {}) : getPanorama.call(this, request);
};
const opening = viewer.open('pano-a');
await flush();
await opening;
assert.deepEqual(requests, ['pano-a']);
fake.built[0].setPov({ heading: 5, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a'], 'one lookup in flight at a time');
t.mock.timers.tick(DETAILS_TIMEOUT_MS);
await flush();
hang = false;
fake.built[0].setPov({ heading: 6, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a'], 'the cooldown follows a timeout');
t.mock.timers.tick(DETAILS_RETRY_COOLDOWN_MS);
fake.built[0].setPov({ heading: 7, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a', 'pano-a']);
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
assert.equal(poses.at(-1).bearing, 7);
});
test('a remount looks up again a panorama whose lookup hung', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
let answerHung;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
if (requests.length === 1)
return new Promise((resolve) => {
answerHung = () => resolve(getPanorama.call(this, request));
});
return getPanorama.call(this, request);
};
await viewer.open('pano-b');
viewer.unmount();
await viewer.mount(host);
await viewer.open('pano-b');
await settle();
assert.deepEqual(requests, ['pano-b', 'pano-b']);
assert.equal(poses.at(-1).creator, 'Ada');
// The hung answer arriving now is for a viewer that is gone: dropped.
const count = poses.length;
answerHung();
await settle();
assert.equal(poses.length, count);
});
+34 -1
View File
@@ -1,5 +1,11 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
export function filtersOf(provider) {
return provider?.filters ?? IMAGERY_FILTERS;
}
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -11,10 +17,15 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @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: 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.
* @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.
@@ -65,6 +76,8 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day').
* @property {string|null} [title] A place name for the caption, e.g. the address.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on the provider's site.
@@ -124,6 +137,26 @@ export function validateProviders(providers) {
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
if (
provider.marker !== undefined &&
!(
provider.marker?.kind === 'view-cone' &&
typeof provider.marker.color === 'string'
)
)
throw new TypeError(
`Street Level provider ${label}: marker must be {kind: 'view-cone', color}`,
);
if (
provider.filters !== undefined &&
!(
Array.isArray(provider.filters) &&
provider.filters.every((key) => IMAGERY_FILTERS.includes(key))
)
)
throw new TypeError(
`Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`,
);
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
+18 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
@@ -101,3 +101,20 @@ test('requiresKeyIdFor is the shared key, or null once providers differ', () =>
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
test('a provider may narrow the filters it honours and pick a view-cone marker', () => {
assert.deepEqual(filtersOf(def()), ['pano', 'since'], 'both by default');
const narrowed = def({
filters: [],
marker: { kind: 'view-cone', color: '#4285f4' },
});
assert.deepEqual(filtersOf(validateProviders([narrowed])[0]), []);
assert.throws(
() => validateProviders([def({ filters: ['colour'] })]),
/filters must list pano or since/,
);
assert.throws(
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
/marker must be/,
);
});
+5 -1
View File
@@ -21,7 +21,11 @@ export function createSelection({ state, parts }) {
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
if (!route?.instance) return;
// Not ours: the ground handler decides whether it is the map itself.
if (!route?.instance) {
parts.openAtGround?.(click.position, picked);
return;
}
const entry = state.providers.get(route.providerId);
if (!entry?.on) return;
route.instance.handlePick(route.id);
+26
View File
@@ -34,6 +34,7 @@ function harness({ selected = false } = {}) {
removeListener(type, listener, options);
};
const sequences = { selected, cleared: 0 };
const groundClicks = [];
const picks = [];
/** What the pointer is over: a line this layer owns, or nothing. */
const scene = { under: null };
@@ -66,6 +67,8 @@ function harness({ selected = false } = {}) {
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
openAtGround: (position, picked) =>
groundClicks.push({ position, picked }),
clearSequences() {
sequences.cleared++;
sequences.selected = false;
@@ -76,6 +79,9 @@ function harness({ selected = false } = {}) {
const onMove = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
const onClick = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
return {
state,
sequences,
@@ -97,6 +103,8 @@ function harness({ selected = false } = {}) {
camera,
picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }),
click: (x) => onClick({ position: { x, y: 10 } }),
groundClicks,
frame() {
for (const task of frames.splice(0)) task();
},
@@ -256,3 +264,21 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
h.restore();
}
});
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
h.click(5);
h.scene.under = 'flight:abc';
h.click(6);
assert.deepEqual(
h.groundClicks.map(({ position, picked }) => [position.x, picked?.id]),
[
[5, undefined],
[6, 'flight:abc'],
],
);
} finally {
h.restore();
}
});
+10 -1
View File
@@ -18,6 +18,12 @@ export function freshStreet() {
altitude: null,
isPano: false,
capturedAt: null,
/** 'day', or 'month' for providers that date images by month (Google). */
capturedAtPrecision: 'day',
/** A place name for the caption (Google's address), or null. */
title: null,
/** Whether the open viewer has FIT/FILL render modes. */
renderModes: true,
sequenceId: null,
creator: null,
externalUrl: null,
@@ -50,10 +56,13 @@ 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',
marker: { collection: null, billboard: null },
marker: { collection: null, billboard: null, look: null },
clickHandler: null,
};
}
+41 -8
View File
@@ -15,6 +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]
* @property {Array<'pano'|'since'>} [filters]
*/
/**
@@ -30,7 +32,7 @@ export function summarizeCoverage(providers) {
hint:
providers.length === 0
? 'No street-level imagery providers configured'
: active.find((p) => p.hint)?.hint || '',
: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
error: active.find((p) => p.error)?.error || null,
keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true),
@@ -41,13 +43,39 @@ export function summarizeCoverage(providers) {
};
}
/**
* Switched-off providers that would draw with a key already set, offered
* while the switched-on ones lack theirs.
* @param {Array<ProviderSnapshot>} providers
* @returns {Array<{id: string, label: string, requiresKeyId: string|null}>}
*/
export function keyAlternatives(providers) {
if (!summarizeCoverage(providers).keyRequired) return [];
return providers
.filter((p) => !p.on && p.configured === true && p.keyRequired !== true)
.map(({ id, label, requiresKeyId }) => ({ id, label, requiresKeyId }));
}
/**
* The filter controls a switched-on provider honours (both when none is on).
* @param {Array<ProviderSnapshot>} providers
*/
export function filterControls(providers) {
const active = providers.filter((p) => p.on);
const honoured = (key) =>
active.length === 0 ||
active.some((p) => (p.filters ?? ['pano', 'since']).includes(key));
return { pano: honoured('pano'), since: honoured('since') };
}
/**
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source, then the shared selection colour.
* 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({
enabled,
groundClickReady = false,
filter,
providers,
street,
@@ -57,18 +85,23 @@ export function composeUIState({
const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers);
const legend = active.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (active.length)
const legend = active
.filter((provider) => provider.groundClick !== true)
.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (legend.length)
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
return {
enabled,
groundClickReady: groundClickReady === true,
keyRequired,
keyAlternatives: keyAlternatives(providers),
keyRejected,
filter: { ...filter },
filterControls: filterControls(providers),
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
+86
View File
@@ -85,6 +85,49 @@ test('the legend is one swatch per active source, in its colour, then Selected',
);
});
test('Street View draws no lines: no legend swatch, and no Selected on its own', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
color: '#4285f4',
count: 0,
groundClick: true,
filters: [],
});
assert.deepEqual(
composeUIState({ ...base, providers: [streetView] }).legend,
[],
);
assert.deepEqual(
composeUIState({
...base,
providers: [provider(), streetView],
}).legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('the filter controls show only for a lit provider that honours them', () => {
const streetView = provider({ id: 'google', groundClick: true, filters: [] });
const controls = (providers) =>
composeUIState({ ...base, providers }).filterControls;
assert.deepEqual(controls([streetView]), { pano: false, since: false });
assert.deepEqual(controls([provider(), streetView]), {
pano: true,
since: true,
});
assert.deepEqual(
controls([provider({ on: false }), streetView]),
{ pano: false, since: false },
'Mapillary switched off',
);
assert.deepEqual(
controls([provider({ on: false }), { ...streetView, on: false }]),
{ pano: true, since: true },
'nothing lit: both, ready for the first chip',
);
});
test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({
...base,
@@ -149,3 +192,46 @@ test('the surface mode defaults to draped and passes through', () => {
'terrain',
);
});
test('every switched-on provider’s hint is kept, once each', async () => {
const { summarizeCoverage } = await import('./uiState.js');
const { providerSnapshot } =
await import('../../testSupport/streetLevelFakes.mjs');
const summary = summarizeCoverage([
providerSnapshot({ hint: 'zoom in for sequences' }),
providerSnapshot({
id: 'google',
hint: 'click a street to open Street View',
}),
providerSnapshot({ id: 'off', on: false, hint: 'not shown' }),
providerSnapshot({ id: 'dup', hint: 'zoom in for sequences' }),
]);
assert.equal(
summary.hint,
'zoom in for sequences · click a street to open Street View',
);
});
test('a key-gated layer offers the switched-off sources that would draw with a key already set', async () => {
const { keyAlternatives } = await import('./uiState.js');
const mapillary = providerSnapshot({ keyRequired: true, configured: false });
const streetView = providerSnapshot({
id: 'google',
label: 'STREET VIEW',
on: false,
configured: true,
requiresKeyId: 'google-maps',
});
assert.deepEqual(keyAlternatives([mapillary, streetView]), [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
]);
// Nothing to offer: Mapillary has its key, or Street View has none either.
assert.deepEqual(keyAlternatives([providerSnapshot(), streetView]), []);
assert.deepEqual(
keyAlternatives([
mapillary,
{ ...streetView, configured: false, keyRequired: true },
]),
[],
);
});
+22 -4
View File
@@ -33,11 +33,18 @@ export function createViewerHost({ state, parts }) {
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
isPano: pose.isPano === true,
capturedAt: pose.capturedAt ?? null,
capturedAtPrecision:
pose.capturedAtPrecision === 'month' ? 'month' : 'day',
title: pose.title || null,
sequenceId: pose.sequenceId ?? null,
creator: pose.creator || null,
externalUrl: pose.externalUrl || null,
});
parts.marker.set(state.street.position, state.street.bearing);
parts.marker.set(
state.street.position,
state.street.bearing,
providerEntry(pose.providerId)?.def.marker,
);
parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
@@ -68,6 +75,12 @@ export function createViewerHost({ state, parts }) {
active.adapter.unmount();
active = null;
}
// 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);
warm.unmount();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
await adapter.mount(state.street.host);
@@ -128,6 +141,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);
@@ -183,14 +198,17 @@ export function createViewerHost({ state, parts }) {
for (const entry of entries) {
const adapter = entry.instance.viewer;
if (!adapter.prewarm || !entry.on) continue;
// Another provider's viewer is showing in the host: do not build under it.
if (active && active.id !== entry.def.id) continue;
try {
await adapter.prewarm(host);
} 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, or another viewer took the host meanwhile.
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
}
@@ -576,3 +576,86 @@ test('a photo asks for the camera when it starts opening and frames with that ti
assert.equal(await host.open('mapillary', 'b'), true);
assert.deepEqual([framing.begun, framing.started], [1, 1]);
});
/** The harness with a second provider, `google`, whose adapter has no render modes. */
function twoProviders() {
const mapillary = fakeAdapter();
mapillary.prewarm = async () => {};
mapillary.setRenderMode = () => {};
const google = fakeAdapter();
google.prewarm = async () => {};
const h = harness(mapillary);
h.state.providers.set('google', {
def: { id: 'google', name: 'Google Street View', label: 'STREET VIEW' },
instance: { viewer: google },
on: true,
});
h.state.providers.get('mapillary').on = true;
return { ...h, mapillary, google };
}
test("opening one provider's image releases another's prewarmed viewer in the shared host", async () => {
const { state, host, mapillary, google } = twoProviders();
await host.prewarm([state.providers.get('mapillary')]);
assert.equal(mapillary.calls.unmount, 0, 'warm');
await host.open('google', 'g-1');
assert.equal(mapillary.calls.unmount, 1, 'not left under the panorama');
assert.equal(google.calls.unmount, 0);
// While Google shows, Mapillary is not built again under it.
let built = 0;
mapillary.prewarm = async () => built++;
await host.prewarm([...state.providers.values()]);
assert.equal(built, 0);
});
test('the render modes follow the viewer that is open', async () => {
const { state, host } = twoProviders();
await host.open('mapillary', 'm-1');
assert.equal(state.street.renderModes, true);
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false, 'Google always fills');
});
test('a pose can date its image by month and name its place', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
google.emitPose('g-1', {
providerId: 'google',
capturedAtPrecision: 'month',
title: '1014 10th St',
capturedAt: Date.UTC(2023, 0, 1),
});
assert.equal(state.street.capturedAtPrecision, 'month');
assert.equal(state.street.title, '1014 10th St');
host.close();
assert.deepEqual(
[state.street.capturedAtPrecision, state.street.title],
['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);
});