mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # DATA_SOURCES.md # docs/CURRENT-STATE.md # server/providers/cctv/constants.js # server/providers/cctv/sources.js
This commit is contained in:
commit
6f054408da
71 files changed
+5228
-181
No files matched your search
@@ -1,21 +1,33 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as scenePick from '../../data/scenePick.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** The browser Maps key the scene was started with (src/app/scene.js). */
|
||||
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
|
||||
|
||||
/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */
|
||||
function defaultProviders(sources) {
|
||||
if (sources.mapillary == null) return [];
|
||||
return [
|
||||
createMapillaryProvider({ source: sources.mapillary }),
|
||||
createGoogleProvider({ getApiKey: googleApiKey }),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject providers, or use Mapillary only when its source is supplied.
|
||||
* An explicit provider list replaces the default; unused sources.mapillary is
|
||||
* neither constructed nor validated, including when the list is empty.
|
||||
* Inject providers, or use the defaults. An explicit provider list replaces
|
||||
* them; an unused sources.mapillary is neither constructed nor validated,
|
||||
* including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = sources.mapillary == null
|
||||
? []
|
||||
: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
providers = defaultProviders(sources),
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
@@ -23,6 +35,7 @@ export function createApplicationStreetLevel({
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
|
||||
@@ -27,3 +27,25 @@ test('explicit providers replace the default source and an empty list removes it
|
||||
/array/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => {
|
||||
const { createDefaultLayerState } = await import('../../data/layerState.js');
|
||||
const mapillary = {
|
||||
getStatus() {},
|
||||
getTile() {},
|
||||
getSequenceImages() {},
|
||||
nearestImages() {},
|
||||
};
|
||||
const layer = createApplicationStreetLevel({ sources: { mapillary } });
|
||||
assert.deepEqual(layer.providerIds, ['mapillary', 'google']);
|
||||
// A fresh page and a restored default agree: Street View is billed, so off.
|
||||
layer.setParams(createDefaultLayerState().options['street-level']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, true);
|
||||
// A link that lit Street View carries it in the shared switch field.
|
||||
layer.setParams({ providerSwitches: 'google-1' });
|
||||
assert.equal(layer.getParams().google, true);
|
||||
assert.equal(layer.getParams().providerSwitches, 'google-1');
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadNycSourcesFromOpenData,
|
||||
nycCameraIsOnline,
|
||||
nycCameraName,
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_IMAGE_ORIGIN,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyNycCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
|
||||
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
id: MIDTOWN_ID,
|
||||
name: 'Central Park West @ 86 St',
|
||||
latitude: 40.785302,
|
||||
longitude: -73.969353,
|
||||
area: 'Manhattan',
|
||||
isOnline: 'true',
|
||||
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = nycCameraToSource(row());
|
||||
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(source.name, 'Central Park West @ 86 St');
|
||||
assert.equal(source.city, 'Manhattan');
|
||||
assert.equal(source.cityId, 'nyc');
|
||||
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
|
||||
assert.equal(source.lat, 40.785302);
|
||||
assert.equal(source.lon, -73.969353);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'nyc-dot-tmc');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Public NYC DOT Traffic Management Center traffic camera',
|
||||
);
|
||||
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
|
||||
const source = nycCameraToSource(
|
||||
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
|
||||
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: true })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
// Austin: a plausible lat/lon, but not a New York camera.
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
|
||||
assert.equal(nycCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
|
||||
const mixed = nycCameraToSource(
|
||||
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
|
||||
);
|
||||
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(mixed.city, 'New York');
|
||||
assert.equal(
|
||||
mixed.url,
|
||||
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
|
||||
'the registered frame path keeps the catalog UUID spelling',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
|
||||
'FDR Drive @ 23 St',
|
||||
);
|
||||
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
|
||||
assert.equal(nycCameraIsOnline('true'), true);
|
||||
assert.equal(nycCameraIsOnline('false'), false);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
|
||||
const long = nycCameraToSource(
|
||||
row({
|
||||
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BROOKLYN'));
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
name: 'Central Park West @ 86 St (duplicate)',
|
||||
latitude: 40.758,
|
||||
longitude: -73.9855,
|
||||
}),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
|
||||
}),
|
||||
row({ isOnline: 'false', name: 'Offline camera' }),
|
||||
row({ id: 'https://evil.example/x' }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
|
||||
assert.equal(cameras[1].city, 'Brooklyn');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = JSON.stringify([row()]);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_NYC_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
|
||||
else process.env.CCTV_NYC_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_NYC_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
// Far southern Staten Island, listed first so only distance can keep them.
|
||||
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
|
||||
row({
|
||||
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -74.24,
|
||||
area: 'Staten Island',
|
||||
name: `SI ${i}`,
|
||||
}),
|
||||
),
|
||||
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
|
||||
row({
|
||||
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -73.9855,
|
||||
area: 'Manhattan',
|
||||
name: `Midtown ${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.city),
|
||||
[
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Staten Island',
|
||||
'Staten Island',
|
||||
],
|
||||
'Midtown fills first; the Staten Island anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === NYC_CAMERAS_URL) {
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
|
||||
* leaves it unset), restoring the environment afterwards. */
|
||||
const runCatalogWithNycFlag = async (t, flag) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
|
||||
else process.env.CCTV_NYC_ENABLED = flag;
|
||||
return await runCatalogWithMockedUpstreams(t);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
|
||||
assert.ok(
|
||||
requested.includes(NYC_CAMERAS_URL),
|
||||
'the catalog refresh invokes the NYC loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
'NYC cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
});
|
||||
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so an install that
|
||||
// has not opted in must never contact it.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_NYC_ENABLED', undefined],
|
||||
['an empty CCTV_NYC_ENABLED', ''],
|
||||
['CCTV_NYC_ENABLED=0', '0'],
|
||||
['CCTV_NYC_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no NYC request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('nyctmc.org')),
|
||||
false,
|
||||
'the lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
|
||||
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
|
||||
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
|
||||
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
|
||||
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
|
||||
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyNycCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
|
||||
});
|
||||
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NYC DOT asks every application that uses its data to carry this
|
||||
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
|
||||
export const NYC_DOT_DISCLAIMER =
|
||||
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
|
||||
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
|
||||
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -249,6 +256,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>)',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New York City, when enabled): ' +
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
|
||||
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
|
||||
assert.ok(credit, 'NYC DOT camera frames must be credited');
|
||||
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
@@ -2703,13 +2703,19 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
|
||||
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
|
||||
});
|
||||
|
||||
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||
test('Street Level: a link without the provider switches keeps Mapillary on and Street View off', () => {
|
||||
assert.deepEqual(streetLevelOptions(''), {
|
||||
mapillary: true,
|
||||
providerSwitches: '',
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
// Street View rides the shared provider-switch field, off unless lit.
|
||||
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
|
||||
assert.equal(
|
||||
createDefaultLayerState().options['street-level'].providerSwitches,
|
||||
'',
|
||||
);
|
||||
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
|
||||
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
|
||||
assert.equal(
|
||||
|
||||
@@ -47,3 +47,52 @@ export function isPickedWorldPosition(position) {
|
||||
const magnitude = Math.hypot(x, y, z);
|
||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* The world position under a screen point: the rendered surface where the
|
||||
* depth buffer has it, else the ellipsoid; null over the sky.
|
||||
*
|
||||
* @param {object} viewer A Cesium viewer.
|
||||
* @param {{x: number, y: number}} screenPosition
|
||||
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
|
||||
*/
|
||||
export function pickGroundPosition(viewer, screenPosition) {
|
||||
const scene = viewer?.scene;
|
||||
let position = null;
|
||||
if (
|
||||
scene?.pickPositionSupported &&
|
||||
typeof scene.pickPosition === 'function'
|
||||
) {
|
||||
try {
|
||||
position = scene.pickPosition(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(position) &&
|
||||
typeof viewer?.camera?.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
position = viewer.camera.pickEllipsoid(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
return isPickedWorldPosition(position) ? position : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `scene.pick()` result is the map itself: nothing, or 3D tile
|
||||
* content (which has no id).
|
||||
*
|
||||
* @param {object|null|undefined} picked
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSurfacePick(picked) {
|
||||
if (!picked) return true;
|
||||
if (picked.id !== undefined) return false;
|
||||
return (
|
||||
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
|
||||
);
|
||||
}
|
||||
+68
-13
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPickedWorldPosition } from './scenePick.js';
|
||||
|
||||
const describe = (position) => (position
|
||||
? `(${position.x}, ${position.y}, ${position.z})`
|
||||
: String(position));
|
||||
const describe = (position) =>
|
||||
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
|
||||
|
||||
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
// These are the shapes `scene.pickPosition()` can produce when the depth read
|
||||
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
new Cesium.Cartesian3(0.2, -0.3, 0.1),
|
||||
];
|
||||
for (const position of degenerate) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(0, 0, 5_999_999),
|
||||
];
|
||||
for (const position of belowTheFloor) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
|
||||
const carto = Cesium.Cartographic.fromCartesian(position);
|
||||
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
|
||||
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
|
||||
assert.ok(
|
||||
carto.height < -300_000,
|
||||
'and puts the "target" hundreds of km underground',
|
||||
);
|
||||
}
|
||||
|
||||
const aboveTheCeiling = [
|
||||
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(1e155, 0, 0),
|
||||
];
|
||||
for (const position of aboveTheCeiling) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
|
||||
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
);
|
||||
|
||||
// Both edges of the band are inclusive, and both are real magnitudes.
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a real picked position is accepted', () => {
|
||||
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
|
||||
];
|
||||
for (const position of real) {
|
||||
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
true,
|
||||
`must accept ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The floor has real margin under the smallest legitimate magnitude, so no
|
||||
// plausible pick sits anywhere near the boundary.
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
|
||||
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(
|
||||
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
|
||||
);
|
||||
assert.ok(
|
||||
polarSurface > 6_300_000,
|
||||
`smallest real magnitude was ${polarSurface}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
// Earth's core. That is the quietest failure of the three and the reason
|
||||
// the guard needs a magnitude band, not just a finiteness check.
|
||||
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
|
||||
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
|
||||
assert.ok(
|
||||
Cesium.Cartographic.fromCartesian(nearCenter),
|
||||
'Cesium converts it without complaint',
|
||||
);
|
||||
assert.equal(isPickedWorldPosition(nearCenter), false);
|
||||
|
||||
// …and every position the guard accepts must convert to the right place.
|
||||
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
|
||||
});
|
||||
|
||||
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
|
||||
const { pickGroundPosition } = await import('./scenePick.js');
|
||||
const ground = { x: 6378137, y: 0, z: 0 };
|
||||
const sky = { x: 500, y: 0, z: 0 };
|
||||
const viewer = (picked, ellipsoid) => ({
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: { pickEllipsoid: () => ellipsoid },
|
||||
});
|
||||
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
|
||||
assert.equal(pickGroundPosition(null, {}), null);
|
||||
});
|
||||
|
||||
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
|
||||
const { isSurfacePick } = await import('./scenePick.js');
|
||||
assert.equal(isSurfacePick(undefined), true);
|
||||
assert.equal(isSurfacePick({ content: {} }), true);
|
||||
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
|
||||
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
|
||||
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
|
||||
});
|
||||
+23
-1
@@ -36,7 +36,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
unlocks: 'The photorealistic 3D planet + place search',
|
||||
unlocks: 'The photorealistic 3D planet, place search + Street View',
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
tier: 'metered',
|
||||
@@ -352,6 +352,28 @@ export function keySetupRequirement(id) {
|
||||
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key guidance that also names switched-off sources whose key is already set.
|
||||
* @param {string} requirement What `keySetupRequirement` said ('' allowed).
|
||||
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
|
||||
*/
|
||||
export function keySetupWithAlternatives(requirement, alternatives = []) {
|
||||
const options = alternatives.map(({ label, requiresKeyId }) => {
|
||||
const entry = KEY_SETUP_KEYS.find(
|
||||
(candidate) => candidate.id === requiresKeyId,
|
||||
);
|
||||
const name = entry
|
||||
? entry.title
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
: null;
|
||||
return name ? `light ${label} to use your ${name} key` : `light ${label}`;
|
||||
});
|
||||
if (!options.length) return requirement;
|
||||
const offer = `or ${options.join(', or ')}`;
|
||||
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a live provider value belongs to a source outside the store
|
||||
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isKeySetupExternallyManaged,
|
||||
keySetupStatus,
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
knownKeySetupEnvVars,
|
||||
parseWindowsUserSid,
|
||||
upsertDotenvValues,
|
||||
@@ -362,3 +363,16 @@ test('server Google key remains supported without appearing in setup or its miss
|
||||
assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY'));
|
||||
assert.ok(!JSON.stringify(status).includes(secret));
|
||||
});
|
||||
|
||||
test('key guidance can offer another source that works with a key already set', () => {
|
||||
const streetView = { label: 'STREET VIEW', requiresKeyId: 'google-maps' };
|
||||
assert.equal(
|
||||
keySetupWithAlternatives(keySetupRequirement('mapillary'), [streetView]),
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(
|
||||
keySetupWithAlternatives('', [streetView]),
|
||||
'Light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X');
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -50,6 +50,26 @@ export function extractStationsArray(payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
|
||||
function stationName(value) {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (!Array.isArray(value)) return '';
|
||||
|
||||
let fallback = '';
|
||||
for (const translation of value) {
|
||||
if (typeof translation?.text !== 'string') continue;
|
||||
const text = translation.text.trim();
|
||||
if (!text) continue;
|
||||
if (!fallback) fallback = text;
|
||||
if (
|
||||
typeof translation.language === 'string' &&
|
||||
/^en(?:-|$)/i.test(translation.language.trim())
|
||||
)
|
||||
return text;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
@@ -63,7 +83,7 @@ export function parseStationInformation(payload) {
|
||||
const stationMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
// Accept both GBFS 2.x (station_id) and alternate (id) field names
|
||||
// Accept both GBFS (station_id) and alternate (id) field names
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||
@@ -71,7 +91,7 @@ export function parseStationInformation(payload) {
|
||||
|
||||
stationMap.set(stationId, {
|
||||
stationId,
|
||||
name: String(raw?.name || raw?.short_name || '').trim(),
|
||||
name: stationName(raw?.name) || stationName(raw?.short_name),
|
||||
lat,
|
||||
lon,
|
||||
capacity: toNonNegativeInteger(raw?.capacity),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseStationInformation } from './gbfsStations.js';
|
||||
|
||||
const station = (fields = {}) => ({
|
||||
station_id: 's1',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
...fields,
|
||||
});
|
||||
const parseName = (fields, version) =>
|
||||
parseStationInformation({
|
||||
version,
|
||||
data: { stations: [station(fields)] },
|
||||
}).get('s1').name;
|
||||
|
||||
test('GBFS v2 station names remain trimmed strings', () => {
|
||||
assert.equal(
|
||||
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
|
||||
'Republic Square',
|
||||
);
|
||||
});
|
||||
|
||||
test('GBFS v3 station names prefer English over the first translation', () => {
|
||||
assert.equal(
|
||||
parseName(
|
||||
{
|
||||
name: [
|
||||
{ language: 'hu', text: 'Köztársaság tér' },
|
||||
{ language: 'en', text: ' Republic Square ' },
|
||||
],
|
||||
},
|
||||
'3.0',
|
||||
),
|
||||
'Republic Square',
|
||||
);
|
||||
});
|
||||
|
||||
test('English preference accepts case differences and regional language tags', () => {
|
||||
for (const language of ['EN', 'en-GB', 'En-US']) {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [
|
||||
{ language: 'hu', text: 'Hungarian' },
|
||||
{ language, text: 'English' },
|
||||
],
|
||||
}),
|
||||
'English',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('without usable English text the first usable translation wins', () => {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [
|
||||
null,
|
||||
{ language: 'en', text: ' ' },
|
||||
{ language: 'en', text: 42 },
|
||||
{ language: 'hu', text: ' Első ' },
|
||||
{ language: 'fr', text: 'Deuxième' },
|
||||
],
|
||||
}),
|
||||
'Első',
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed language fields cannot abort translation selection', () => {
|
||||
const name = [
|
||||
null,
|
||||
false,
|
||||
'bad',
|
||||
{},
|
||||
{ language: 42, text: 'Fallback' },
|
||||
{ language: { unexpected: true }, text: 'Other' },
|
||||
{ language: 'en', text: 'English' },
|
||||
];
|
||||
assert.equal(parseName({ name }), 'English');
|
||||
assert.equal(
|
||||
parseName({ name: [{ text: ' Usable text without a locale ' }] }),
|
||||
'Usable text without a locale',
|
||||
);
|
||||
});
|
||||
|
||||
test('a usable name takes precedence over an English short name', () => {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [{ language: 'hu', text: 'Full name' }],
|
||||
short_name: [{ language: 'en', text: 'Short name' }],
|
||||
}),
|
||||
'Full name',
|
||||
);
|
||||
});
|
||||
|
||||
test('missing, empty and malformed names fall back to localized short_name', () => {
|
||||
for (const name of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
' ',
|
||||
[],
|
||||
{},
|
||||
0,
|
||||
42,
|
||||
false,
|
||||
[null, {}, { language: 'en', text: [] }, { language: 'hu', text: ' ' }],
|
||||
]) {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name,
|
||||
short_name: [
|
||||
{ language: 'hu', text: 'Rövid' },
|
||||
{ language: 'en', text: ' Short ' },
|
||||
],
|
||||
}),
|
||||
'Short',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('localized names can fall back to legacy short_name strings', () => {
|
||||
assert.equal(parseName({ name: [], short_name: ' Short ' }), 'Short');
|
||||
});
|
||||
|
||||
test('no usable name leaves the station available with an empty name', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
[],
|
||||
{},
|
||||
42,
|
||||
true,
|
||||
[null, { text: false }, { language: 'en', text: ' ' }],
|
||||
]) {
|
||||
assert.equal(parseName({ name: value, short_name: value }), '');
|
||||
}
|
||||
});
|
||||
|
||||
test('name shape is normalized even when a feed omits or misstates its version', () => {
|
||||
const name = [{ language: 'en', text: 'Republic Square' }];
|
||||
for (const version of [undefined, '2.3', '3.0', '3.1', 'unknown']) {
|
||||
assert.equal(parseName({ name }, version), 'Republic Square');
|
||||
assert.equal(parseName({ name: 'Legacy name' }, version), 'Legacy name');
|
||||
}
|
||||
});
|
||||
|
||||
test('bad names do not lose valid stations or their metadata', () => {
|
||||
const information = parseStationInformation({
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
station({ station_id: 'bad', name: [null, {}] }),
|
||||
station({
|
||||
station_id: 'short',
|
||||
name: [],
|
||||
short_name: [{ language: 'en', text: 'Short' }],
|
||||
}),
|
||||
station({
|
||||
station_id: 'good',
|
||||
name: [{ language: 'en', text: 'Republic Square' }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(information.size, 3);
|
||||
assert.equal(information.get('bad').name, '');
|
||||
assert.equal(information.get('short').name, 'Short');
|
||||
assert.deepEqual(information.get('good'), {
|
||||
stationId: 'good',
|
||||
name: 'Republic Square',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
isInstalled: true,
|
||||
isRenting: true,
|
||||
isReturning: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizing a localized name leaves the feed document unchanged', () => {
|
||||
const payload = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
station({
|
||||
name: [
|
||||
{ language: 'hu', text: 'Magyar' },
|
||||
{ language: 'en', text: ' English ' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
const original = structuredClone(payload);
|
||||
parseStationInformation(payload);
|
||||
assert.deepEqual(payload, original);
|
||||
});
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
|
||||
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
|
||||
* Malformed sets are skipped without dropping the valid sets that follow.
|
||||
*/
|
||||
export function parseTleText(text) {
|
||||
const lines = String(text)
|
||||
@@ -9,12 +9,13 @@ export function parseTleText(text) {
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const result = [];
|
||||
for (let i = 0; i < lines.length - 2; i += 3) {
|
||||
for (let i = 0; i < lines.length - 2; i++) {
|
||||
const name = lines[i];
|
||||
const line1 = lines[i + 1];
|
||||
const line2 = lines[i + 2];
|
||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||
result.push({ name, line1, line2 });
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -253,3 +253,14 @@ test('the Cyber theme frames the Street Level panel like its rail peers (P3)', (
|
||||
// A theme header rule must be able to outrank the panel's own.
|
||||
assert.doesNotMatch(panelCss, /#street-level-panel \.panel-header/);
|
||||
});
|
||||
|
||||
test('OPEN STREET VIEW keeps its width beside a long coverage line', () => {
|
||||
// .scene-btn grows and shrinks (flex: 1) and loads after this stylesheet:
|
||||
// only a more specific rule keeps the button from being squeezed.
|
||||
assert.match(css, /\.scene-btn \{[^}]*flex: 1;/);
|
||||
assert.match(
|
||||
panelCss,
|
||||
/\.sl-meta-row \.sl-nearest-btn \{\s*flex: 0 0 auto;\s*\}/,
|
||||
);
|
||||
assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Stand-ins for the Google Maps JavaScript API's Street View library and the
|
||||
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate,
|
||||
* copyright, description}}; `nearest(location, radius)` answers location
|
||||
* lookups with an id or null; `substitutes` maps an id Google answers with a
|
||||
* newer one. Like Google, `setPano` sets the id (pano_changed) at once, and
|
||||
* the position and status follow when the data arrives; status_changed fires
|
||||
* only when the status changes.
|
||||
*/
|
||||
|
||||
function latLng(lat, lng) {
|
||||
return { lat: () => lat, lng: () => lng };
|
||||
}
|
||||
|
||||
export function fakeStreetViewLibrary({
|
||||
panoramas = {},
|
||||
nearest = () => null,
|
||||
substitutes = {},
|
||||
} = {}) {
|
||||
const built = [];
|
||||
const lookups = [];
|
||||
|
||||
class StreetViewPanorama {
|
||||
constructor(element, options) {
|
||||
this.element = element;
|
||||
this.options = options;
|
||||
this.visible = options?.visible ?? true;
|
||||
this.pano = null;
|
||||
this.status = null;
|
||||
this.position = null;
|
||||
this.pov = { heading: 0, pitch: 0 };
|
||||
this.listeners = new Map();
|
||||
built.push(this);
|
||||
}
|
||||
|
||||
addListener(name, listener) {
|
||||
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
||||
this.listeners.get(name).add(listener);
|
||||
return { remove: () => this.listeners.get(name)?.delete(listener) };
|
||||
}
|
||||
|
||||
fire(name) {
|
||||
for (const listener of [...(this.listeners.get(name) || [])]) listener();
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
let count = 0;
|
||||
for (const set of this.listeners.values()) count += set.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Like Google: the id is set at once; the data loads asynchronously. */
|
||||
setPano(id) {
|
||||
this.pano = id;
|
||||
this.fire('pano_changed');
|
||||
queueMicrotask(() => {
|
||||
if (this.pano !== id) return;
|
||||
const shownId = substitutes[id] ?? id;
|
||||
const known = panoramas[shownId];
|
||||
const status = known ? 'OK' : 'ZERO_RESULTS';
|
||||
if (shownId !== id) {
|
||||
this.pano = shownId;
|
||||
this.fire('pano_changed');
|
||||
}
|
||||
if (known) {
|
||||
this.position = latLng(known.lat, known.lng);
|
||||
this.fire('position_changed');
|
||||
}
|
||||
if (status !== this.status) {
|
||||
this.status = status;
|
||||
this.fire('status_changed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPano() {
|
||||
return this.pano;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
getPov() {
|
||||
return { ...this.pov };
|
||||
}
|
||||
|
||||
/** The user drags the view. */
|
||||
setPov(pov) {
|
||||
this.pov = { ...pov };
|
||||
this.fire('pov_changed');
|
||||
}
|
||||
|
||||
setVisible(visible) {
|
||||
this.visible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
class StreetViewService {
|
||||
async getPanorama(request) {
|
||||
lookups.push(request);
|
||||
const id = request.pano ?? nearest(request.location, request.radius);
|
||||
const known = id && panoramas[id];
|
||||
if (!known)
|
||||
throw Object.assign(new Error('ZERO_RESULTS'), {
|
||||
code: 'ZERO_RESULTS',
|
||||
});
|
||||
return {
|
||||
data: {
|
||||
location: {
|
||||
pano: id,
|
||||
latLng: latLng(known.lat, known.lng),
|
||||
description: known.description,
|
||||
},
|
||||
imageDate: known.imageDate,
|
||||
copyright: known.copyright,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
library: {
|
||||
StreetViewPanorama,
|
||||
StreetViewService,
|
||||
StreetViewSource: { GOOGLE: 'google', OUTDOOR: 'outdoor' },
|
||||
StreetViewPreference: { NEAREST: 'nearest', BEST: 'best' },
|
||||
},
|
||||
built,
|
||||
lookups,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A loader over `library`; `refuseKey()` plays Google's gm_authFailure. With
|
||||
* `deferred`, imports wait for `release()`.
|
||||
*/
|
||||
export function fakeMapsLoader(library, { deferred = false } = {}) {
|
||||
let failed = false;
|
||||
const listeners = new Set();
|
||||
const imports = [];
|
||||
let release = null;
|
||||
const gate = deferred
|
||||
? new Promise((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
imports,
|
||||
async importLibrary(name) {
|
||||
imports.push(name);
|
||||
await gate;
|
||||
return library;
|
||||
},
|
||||
release: () => release?.(),
|
||||
authFailed: () => failed,
|
||||
onAuthFailure(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
refuseKey() {
|
||||
failed = true;
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** A host element with just what the viewer adapter touches. */
|
||||
export function fakeHost() {
|
||||
const children = [];
|
||||
const host = {
|
||||
children,
|
||||
ownerDocument: {
|
||||
createElement: () => {
|
||||
const element = {
|
||||
style: {},
|
||||
className: '',
|
||||
remove() {
|
||||
const index = children.indexOf(element);
|
||||
if (index !== -1) children.splice(index, 1);
|
||||
},
|
||||
};
|
||||
return element;
|
||||
},
|
||||
},
|
||||
append: (element) => children.push(element),
|
||||
};
|
||||
return host;
|
||||
}
|
||||
@@ -515,15 +515,20 @@ test('evidence never carries a Mapillary token', () => {
|
||||
test('--strict accepts only the documented skips', () => {
|
||||
assert.deepEqual(
|
||||
[...STRICT_ALLOWED_SKIPS],
|
||||
['no Google 3D', 'fixtures only'],
|
||||
['no Google 3D', 'no Google key', 'fixtures only'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
strictViolations([
|
||||
{ reason: 'no Google 3D', label: 'terrain' },
|
||||
{ reason: 'no Google key', label: 'street view' },
|
||||
{ reason: 'fixtures only', label: 'keyless page' },
|
||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||
{ reason: 'Google key refused', label: 'street view' },
|
||||
]),
|
||||
[{ reason: 'no Mapillary key', label: 'the keyed steps' }],
|
||||
[
|
||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||
{ reason: 'Google key refused', label: 'street view' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
|
||||
}
|
||||
});
|
||||
|
||||
test('the CSP admits the event media embeds, and no other script origin', async () => {
|
||||
test('the CSP admits the event media embeds and the Maps JavaScript API, and no other script origin', async () => {
|
||||
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
|
||||
await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
|
||||
const directive = (name) =>
|
||||
@@ -201,7 +201,8 @@ test('the CSP admits the event media embeds, and no other script origin', async
|
||||
'https://platform.twitter.com',
|
||||
])
|
||||
assert.ok(scripts.includes(origin), origin);
|
||||
// Only those three, and never a wildcard or a scheme.
|
||||
// Only those three and Google Street View's Maps JavaScript API, never a
|
||||
// wildcard or a scheme.
|
||||
assert.deepEqual(
|
||||
scripts.filter(
|
||||
(source) => source.startsWith('http') || source === 'https:',
|
||||
@@ -210,6 +211,7 @@ test('the CSP admits the event media embeds, and no other script origin', async
|
||||
'https://www.youtube.com',
|
||||
'https://connect.facebook.net',
|
||||
'https://platform.twitter.com',
|
||||
'https://maps.googleapis.com',
|
||||
],
|
||||
);
|
||||
assert.ok(!scripts.some((source) => source.includes('*')));
|
||||
|
||||
@@ -37,6 +37,11 @@ const PROVIDER_ORIGINS = Object.freeze([
|
||||
'https://fonts.googleapis.com',
|
||||
'https://fonts.gstatic.com',
|
||||
'https://graph.mapillary.com',
|
||||
// Google Street View's tiles, controls and contributed photos (the Maps
|
||||
// JavaScript API itself comes from maps.googleapis.com above).
|
||||
'https://streetviewpixels-pa.googleapis.com',
|
||||
'https://maps.gstatic.com',
|
||||
'https://*.ggpht.com',
|
||||
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
|
||||
'https://*.xx.fbcdn.net',
|
||||
]);
|
||||
|
||||
@@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async ()
|
||||
assert.equal(panel.PANEL_BASE, '/panel/');
|
||||
assert.match(resource.text, /panelRuntime|function/);
|
||||
});
|
||||
|
||||
test('the panel lets Street Level reach Google Street View from inside the embed', () => {
|
||||
const { csp } = createGlobePanelResource({
|
||||
runtime: panelRuntime,
|
||||
panelKey: 'test-key',
|
||||
})._meta.ui;
|
||||
// The API script and lookups, panorama tiles, control images, contributed photos.
|
||||
for (const url of [
|
||||
'https://maps.googleapis.com/maps/api/js?key=k&v=weekly',
|
||||
'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1',
|
||||
'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png',
|
||||
'https://lh3.ggpht.com/p/photo.jpg',
|
||||
]) {
|
||||
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
|
||||
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
|
||||
}
|
||||
});
|
||||
@@ -106,6 +106,34 @@ test('bike share joins station information with live status inside the area', as
|
||||
assert.equal(result.data.rows[1].capacity, 10);
|
||||
});
|
||||
|
||||
test('bike share returns localized names without losing stations with malformed names', async () => {
|
||||
const informationUrl = 'https://gbfs.example/austin/station_information.json';
|
||||
const information = structuredClone(documents[informationUrl]);
|
||||
information.version = '3.0';
|
||||
information.data.stations[0].name = [
|
||||
{ language: 'hu', text: 'Magyar név' },
|
||||
{ language: 'en', text: 'Congress & 2nd' },
|
||||
];
|
||||
information.data.stations[2].name = [null, { language: 42, text: [] }];
|
||||
const source = bikeshare([system('austin', 'Austin, TX', 30.27, -97.74)]);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: {
|
||||
...source,
|
||||
getStations: (url) =>
|
||||
url === informationUrl ? information : source.getStations(url),
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_bike_share', { area: downtown });
|
||||
assert.equal(result.data.total, 2);
|
||||
assert.equal(result.data.rows[0].name, 'Congress & 2nd');
|
||||
assert.equal(result.data.rows[0].bikes_available, 4);
|
||||
assert.equal(result.data.rows[1].name, null);
|
||||
assert.doesNotMatch(result.summary, /unavailable/);
|
||||
});
|
||||
|
||||
test('bike share reports uncovered areas and failed systems honestly', async () => {
|
||||
const none = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -30,6 +30,27 @@ test('TLE text parses into named entries with catalog numbers', () => {
|
||||
assert.equal(tleCatalogNumber('1 xx'), null);
|
||||
});
|
||||
|
||||
for (const missingLine of [1, 2]) {
|
||||
test(`TLE parsing resumes after a set missing line ${missingLine}`, () => {
|
||||
const sets = Array.from({ length: 8 }, (_, i) => {
|
||||
const lines = TLE.trim().split('\n').slice(0, 3);
|
||||
lines[0] = `SAT ${i}`;
|
||||
return lines;
|
||||
});
|
||||
sets[1].splice(missingLine, 1);
|
||||
const entries = parseTleText(sets.flat().join('\r\n'));
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
['SAT 0', 'SAT 2', 'SAT 3', 'SAT 4', 'SAT 5', 'SAT 6', 'SAT 7'],
|
||||
);
|
||||
assert.ok(
|
||||
entries.every(
|
||||
(entry) => entry.line1.startsWith('1 ') && entry.line2.startsWith('2 '),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => {
|
||||
);
|
||||
assert.equal(layerKeyRequirementTooltip(ungated), '');
|
||||
});
|
||||
|
||||
test('a key-gated layer with another source ready says how to use it', () => {
|
||||
const text = layerKeyRequirementTooltip({
|
||||
requiresKeyId: 'mapillary',
|
||||
stats: {
|
||||
keyRequired: true,
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
text,
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { syncRowList } from './rowList.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
@@ -100,7 +103,11 @@ function panelLabel(layer) {
|
||||
export function layerKeyRequirementTooltip(layer = {}) {
|
||||
if (layer?.stats?.keyRequired !== true) return '';
|
||||
const requiresKeyId = String(layer.requiresKeyId || '').trim();
|
||||
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
|
||||
// A layer with several sources also names the ones it could use instead.
|
||||
return keySetupWithAlternatives(
|
||||
requiresKeyId ? keySetupRequirement(requiresKeyId) : '',
|
||||
layer.stats.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
|
||||
@@ -66,8 +66,11 @@ export class StreetLevelControls {
|
||||
status: byId('sl-status'),
|
||||
controls: byId('sl-controls'),
|
||||
providerChips: byId('sl-provider-chips'),
|
||||
nearestBtn: byId('sl-nearest-btn'),
|
||||
error: byId('sl-error'),
|
||||
errorText: byId('sl-error-text'),
|
||||
panoRow: byId('sl-pano-row'),
|
||||
sinceRow: byId('sl-since-row'),
|
||||
sinceRange: byId('sl-since'),
|
||||
sinceLabel: byId('sl-since-label'),
|
||||
legend: byId('sl-legend'),
|
||||
@@ -103,9 +106,11 @@ export class StreetLevelControls {
|
||||
this.listen(el.providerChips, 'click', (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
// Without a key the chips stay focusable for their tooltip; a click
|
||||
// says the same thing rather than switching on a layer that cannot draw.
|
||||
if (this._view?.controlsDisabled) {
|
||||
// A keyless chip shows its tooltip instead of switching; others switch.
|
||||
const chip = this._view?.providers.find(
|
||||
(entry) => entry.id === button.dataset.chipId,
|
||||
);
|
||||
if (chip?.state === 'error') {
|
||||
if (button.title) this.actions.showToast?.(button.title);
|
||||
return;
|
||||
}
|
||||
@@ -129,6 +134,15 @@ export class StreetLevelControls {
|
||||
this.listen(el.sinceRange, 'change', () =>
|
||||
this._setParams({ sinceDays: sinceDays() }),
|
||||
);
|
||||
this.listen(el.nearestBtn, 'click', () => {
|
||||
const nearest = this._view?.nearest;
|
||||
if (!nearest?.visible || nearest.disabled) return;
|
||||
this.layer.openNearest?.(undefined, {
|
||||
providerIds: nearest.providerIds,
|
||||
frame: false,
|
||||
aim: 'screen-centre',
|
||||
});
|
||||
});
|
||||
this.listen(el.followBtn, 'click', () => {
|
||||
this.layer.setFollow?.(!(this._state?.street?.follow === true));
|
||||
});
|
||||
@@ -238,24 +252,23 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighting a chip turns the layer on; darkening the last lit one turns the
|
||||
* layer off but keeps the provider on, so the layer comes back with it.
|
||||
* Lighting a chip turns the layer on and darkens the others (one source at
|
||||
* a time); darkening the lit one turns the layer off, keeping the provider.
|
||||
*/
|
||||
async _toggleProvider(providerId) {
|
||||
const chip = this._view?.providers.find((entry) => entry.id === providerId);
|
||||
if (!chip) return;
|
||||
if (!chip.active) {
|
||||
this._setParams({ [providerId]: true });
|
||||
// Every registered provider in one update (those hidden on this map
|
||||
// too), so the share link records the swap.
|
||||
const ids =
|
||||
this.layer.providerIds || this._view.providers.map((entry) => entry.id);
|
||||
this._setParams(
|
||||
Object.fromEntries(ids.map((id) => [id, id === providerId])),
|
||||
);
|
||||
await this._ensureEnabled();
|
||||
return;
|
||||
}
|
||||
const othersLit = this._view.providers.some(
|
||||
(entry) => entry.id !== providerId && entry.active,
|
||||
);
|
||||
if (othersLit) {
|
||||
this._setParams({ [providerId]: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.actions.setEnabled?.(false);
|
||||
} catch (error) {
|
||||
@@ -466,6 +479,7 @@ export class StreetLevelControls {
|
||||
this._renderFilters(view);
|
||||
this._renderViewer(view);
|
||||
this._renderMeta(view);
|
||||
this._renderNearest(view);
|
||||
this._reactToTransitions(view, state);
|
||||
}
|
||||
|
||||
@@ -510,6 +524,11 @@ export class StreetLevelControls {
|
||||
|
||||
_renderFilters(view) {
|
||||
const el = this._elements;
|
||||
const shows = view.filterControls || { pano: true, since: true };
|
||||
setProp(el.panoRow, 'hidden', !shows.pano);
|
||||
setProp(el.sinceRow, 'hidden', !shows.since);
|
||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||
setProp(el.legend, 'hidden', view.legend.length === 0);
|
||||
this._renderRadios(
|
||||
el.panoButtons,
|
||||
(button) => button.dataset.slPano === view.filter.pano,
|
||||
@@ -577,6 +596,9 @@ export class StreetLevelControls {
|
||||
el.renderButtons,
|
||||
(button) => button.dataset.slRender === viewer.renderMode,
|
||||
);
|
||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||
if (!viewer.open) return;
|
||||
setProp(el.imageBy, 'textContent', viewer.captionLeft);
|
||||
setProp(el.imageWhen, 'textContent', viewer.captionRight);
|
||||
@@ -592,6 +614,17 @@ export class StreetLevelControls {
|
||||
setProp(this._elements.coverageMeta, 'textContent', view.meta);
|
||||
}
|
||||
|
||||
_renderNearest(view) {
|
||||
const button = this._elements.nearestBtn;
|
||||
if (!button) return;
|
||||
const { nearest } = view;
|
||||
setProp(button, 'hidden', !nearest.visible);
|
||||
if (!nearest.visible) return;
|
||||
setProp(button, 'textContent', nearest.label);
|
||||
setProp(button, 'title', nearest.title);
|
||||
setProp(button, 'disabled', nearest.disabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the panel on a switch-on or when an image opens. Never persisted:
|
||||
* the stored collapse state stays the user's own choice.
|
||||
|
||||
@@ -307,12 +307,16 @@ function panelDom() {
|
||||
add(settings, 'div', { id });
|
||||
// A missing key gates the filters only; the chips stay outside the gate.
|
||||
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
|
||||
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
|
||||
for (const pano of ['all', 'pano', 'flat'])
|
||||
add(controls, 'button', { dataset: { slPano: pano } });
|
||||
add(controls, 'input', { id: 'sl-since' });
|
||||
add(controls, 'output', { id: 'sl-since-label' });
|
||||
add(panoRow, 'button', { dataset: { slPano: pano } });
|
||||
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
|
||||
add(sinceRow, 'input', { id: 'sl-since' });
|
||||
add(sinceRow, 'output', { id: 'sl-since-label' });
|
||||
add(settings, 'ul', { id: 'sl-legend' });
|
||||
add(settings, 'div', { id: 'sl-coverage-meta' });
|
||||
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
|
||||
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
||||
add(metaRow, 'button', { id: 'sl-nearest-btn' });
|
||||
// The globe's canvas is focusable too (tabindex=0).
|
||||
const globe = add(
|
||||
add(document.body, 'div', { id: 'cesiumContainer' }),
|
||||
@@ -1136,3 +1140,122 @@ test('a <body> child added while the viewer is expanded goes inert with the rest
|
||||
assert.equal(observer.watching(), false);
|
||||
}),
|
||||
));
|
||||
|
||||
test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
state.providers.push({
|
||||
...state.providers[0],
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
groundClick: true,
|
||||
count: 0,
|
||||
});
|
||||
state.groundClickReady = true;
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const asked = [];
|
||||
layer.openNearest = (point, options) => asked.push([point, options]);
|
||||
const button = dom.root.querySelector('#sl-nearest-btn');
|
||||
assert.equal(button.hidden, false);
|
||||
assert.equal(button.textContent, 'OPEN STREET VIEW');
|
||||
button.click();
|
||||
assert.deepEqual(asked, [
|
||||
[
|
||||
undefined,
|
||||
{ providerIds: ['google'], frame: false, aim: 'screen-centre' },
|
||||
],
|
||||
]);
|
||||
// Too high to mean a street: disabled, and a click does nothing.
|
||||
layer.publish({ ...state, groundClickReady: false });
|
||||
assert.equal(button.disabled, true);
|
||||
button.click();
|
||||
assert.equal(asked.length, 1);
|
||||
// Off Google 3D the provider is not listed: no button.
|
||||
layer.publish({ ...state, providers: [state.providers[0]] });
|
||||
assert.equal(button.hidden, true);
|
||||
}));
|
||||
|
||||
test('a refused Street View key cannot lock Mapillary out; its chip only explains itself', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState({ on: false, keyRequired: true });
|
||||
state.providers.push({
|
||||
...state.providers[0],
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
on: true,
|
||||
keyRequired: true,
|
||||
keyRejected: true,
|
||||
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
|
||||
});
|
||||
state.providers[0].keyRequired = false;
|
||||
const { calls, layer } = stubPanel(dom, state);
|
||||
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
|
||||
chips()[0].click(); // Mapillary: configured, dark
|
||||
await settle();
|
||||
// One source at a time: lighting Mapillary darkens Street View too.
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true, google: false }, { origin: 'user' }],
|
||||
]);
|
||||
// Now dark, the refused chip says why instead of lighting.
|
||||
state.providers[0].on = true;
|
||||
state.providers[1].on = false;
|
||||
state.keyRequired = false;
|
||||
layer.publish({ ...state });
|
||||
chips()[1].click();
|
||||
await settle();
|
||||
assert.equal(calls.setParams.length, 1);
|
||||
assert.deepEqual(calls.toasts, [
|
||||
dom.root.querySelector('#sl-provider-chips').children[1].title,
|
||||
]);
|
||||
}));
|
||||
|
||||
test('a swap names every registered provider, also those hidden on this map', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState({ on: false });
|
||||
const { calls, layer, chip } = stubPanel(dom, state);
|
||||
// Off Google 3D the Street View chip is not listed, but stays registered.
|
||||
layer.providerIds = ['mapillary', 'google'];
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true, google: false }, { origin: 'user' }],
|
||||
]);
|
||||
}));
|
||||
|
||||
test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const shown = () => ({
|
||||
pano: !dom.root.querySelector('#sl-pano-row').hidden,
|
||||
since: !dom.root.querySelector('#sl-since-row').hidden,
|
||||
controls: !dom.root.querySelector('#sl-controls').hidden,
|
||||
legend: !dom.root.querySelector('#sl-legend').hidden,
|
||||
});
|
||||
assert.deepEqual(shown(), {
|
||||
pano: true,
|
||||
since: true,
|
||||
controls: true,
|
||||
legend: true,
|
||||
});
|
||||
layer.publish({
|
||||
...state,
|
||||
filterControls: { pano: false, since: false },
|
||||
legend: [],
|
||||
});
|
||||
assert.deepEqual(shown(), {
|
||||
pano: false,
|
||||
since: false,
|
||||
controls: false,
|
||||
legend: false,
|
||||
});
|
||||
layer.publish(state);
|
||||
assert.deepEqual(shown(), {
|
||||
pano: true,
|
||||
since: true,
|
||||
controls: true,
|
||||
legend: true,
|
||||
});
|
||||
}));
|
||||
@@ -1,4 +1,7 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
@@ -44,22 +47,43 @@ function presentSince(days, now) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(ms) {
|
||||
/** YYYY-MM-DD, or YYYY-MM for a provider that dates images by month. */
|
||||
function formatDate(ms, precision = 'day') {
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
try {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the key-gated layer needs: each switched-on provider's key, and the
|
||||
* switched-off ones that would draw with a key already set.
|
||||
*/
|
||||
function keyGuidance(state) {
|
||||
const needs = (state.providers || [])
|
||||
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
|
||||
.map((p) => keySetupRequirement(p.requiresKeyId))
|
||||
.filter(Boolean);
|
||||
return keySetupWithAlternatives(
|
||||
[...new Set(needs)].join('; '),
|
||||
state.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
function presentStatus(state) {
|
||||
const pressed = state.enabled === true;
|
||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||
if (state.keyRejected)
|
||||
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
|
||||
if (state.keyRequired)
|
||||
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
|
||||
return {
|
||||
text: 'KEY REQUIRED',
|
||||
tone: 'warn',
|
||||
pressed,
|
||||
title: keyGuidance(state) || title,
|
||||
};
|
||||
if (state.coverage.loading)
|
||||
return { text: 'LOADING', tone: 'busy', pressed, title };
|
||||
return pressed
|
||||
@@ -104,12 +128,17 @@ function presentViewer(state) {
|
||||
if (street.isPano) right.push('360°');
|
||||
if (Number.isFinite(street.bearing))
|
||||
right.push(`${Math.round(street.bearing)}°`);
|
||||
if (street.capturedAt) right.push(formatDate(street.capturedAt));
|
||||
if (street.capturedAt)
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
if (street.title) left.push(street.title);
|
||||
if (street.creator) left.push(`Image by ${street.creator}`);
|
||||
return {
|
||||
open: street.open === true,
|
||||
loading: street.loading === true && !street.imageId,
|
||||
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
|
||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||
renderModes: street.renderModes !== false,
|
||||
captionLeft: left.join(' · '),
|
||||
captionRight: right.join(' · '),
|
||||
link: street.externalUrl || null,
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
@@ -124,16 +153,52 @@ function presentViewer(state) {
|
||||
};
|
||||
}
|
||||
|
||||
/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */
|
||||
function presentNearest(state) {
|
||||
const targets = (state.providers || []).filter(
|
||||
(provider) =>
|
||||
provider.on && provider.groundClick && provider.keyRequired !== true,
|
||||
);
|
||||
if (state.enabled !== true || !targets.length)
|
||||
return {
|
||||
visible: false,
|
||||
label: '',
|
||||
title: '',
|
||||
disabled: true,
|
||||
providerIds: [],
|
||||
};
|
||||
const names = targets.map((provider) => provider.name).join(' or ');
|
||||
const ready = state.groundClickReady === true;
|
||||
return {
|
||||
visible: true,
|
||||
label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`,
|
||||
title: ready
|
||||
? `Open ${names} at the centre of the view`
|
||||
: `Zoom in to a street to open ${names} here`,
|
||||
disabled: !ready || state.street.loading === true,
|
||||
providerIds: targets.map((provider) => provider.id),
|
||||
};
|
||||
}
|
||||
|
||||
function presentMeta(state) {
|
||||
if (state.providers.length === 0) return state.coverage.hint || '';
|
||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||
// Key-gated with another source ready: say how to use it.
|
||||
if (state.keyRequired && state.keyAlternatives?.length)
|
||||
return keyGuidance(state);
|
||||
if (state.sequence.selectedId)
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
|
||||
const hint = state.coverage.hint || '';
|
||||
if (state.coverage.count > 0)
|
||||
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
|
||||
return state.coverage.hint || '';
|
||||
return [
|
||||
`${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`,
|
||||
hint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return hint;
|
||||
}
|
||||
|
||||
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
||||
@@ -154,10 +219,15 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
? null
|
||||
: state.street.error || state.coverage.error || null,
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
filterControls: {
|
||||
pano: state.filterControls?.pano !== false,
|
||||
since: state.filterControls?.since !== false,
|
||||
},
|
||||
since: presentSince(filter.sinceDays, now),
|
||||
legend: state.legend || [],
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
nearest: presentNearest(state),
|
||||
wantsOpen: state.street.open === true,
|
||||
};
|
||||
}
|
||||
@@ -405,3 +405,137 @@ test('FOLLOW is disabled off Google 3D and says where to switch', () => {
|
||||
);
|
||||
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
|
||||
});
|
||||
|
||||
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
street: {
|
||||
open: true,
|
||||
providerId: 'google',
|
||||
providerName: 'Google Street View',
|
||||
providerLabel: 'STREET VIEW',
|
||||
imageId: 'pano-1',
|
||||
title: '1014 10th St',
|
||||
creator: 'Google',
|
||||
capturedAt: Date.UTC(2023, 0, 1),
|
||||
capturedAtPrecision: 'month',
|
||||
bearing: 0,
|
||||
isPano: true,
|
||||
renderModes: false,
|
||||
externalUrl:
|
||||
'https://www.google.com/maps/@?api=1&map_action=pano&pano=pano-1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(view.viewer.captionLeft, '1014 10th St · Image by Google');
|
||||
assert.equal(view.viewer.captionRight, '360° · 0° · 2023-01');
|
||||
assert.equal(view.viewer.linkLabel, 'STREET VIEW ↗');
|
||||
assert.equal(view.viewer.renderModes, false);
|
||||
});
|
||||
|
||||
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
coverage: {
|
||||
loading: false,
|
||||
count: 2514,
|
||||
hint: 'click a street to open Street View',
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
view.meta,
|
||||
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
|
||||
);
|
||||
});
|
||||
|
||||
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
|
||||
const streetView = provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
groundClick: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
});
|
||||
const view = (overrides) =>
|
||||
presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
providers: [provider(), streetView],
|
||||
...overrides,
|
||||
}),
|
||||
).nearest;
|
||||
const ready = view({ groundClickReady: true });
|
||||
assert.deepEqual(ready, {
|
||||
visible: true,
|
||||
label: 'OPEN STREET VIEW',
|
||||
title: 'Open Google Street View at the centre of the view',
|
||||
disabled: false,
|
||||
providerIds: ['google'],
|
||||
});
|
||||
const high = view({ groundClickReady: false });
|
||||
assert.equal(high.disabled, true);
|
||||
assert.equal(
|
||||
high.title,
|
||||
'Zoom in to a street to open Google Street View here',
|
||||
);
|
||||
// Hidden with Street View off, without its key, or with the layer off
|
||||
// (off Google 3D the provider is not listed at all).
|
||||
assert.equal(
|
||||
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
|
||||
.visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(view({ providers: [provider()] }).visible, false);
|
||||
assert.equal(
|
||||
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
|
||||
.nearest.visible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [
|
||||
provider({ keyRequired: true, configured: false }),
|
||||
provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
on: false,
|
||||
configured: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
}),
|
||||
],
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const guidance =
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
|
||||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||||
assert.equal(view.status.title, guidance);
|
||||
assert.equal(view.meta, guidance);
|
||||
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
|
||||
const plain = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [provider({ keyRequired: true, configured: false })],
|
||||
keyAlternatives: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(plain.status.text, 'KEY REQUIRED');
|
||||
assert.notEqual(plain.meta, guidance);
|
||||
});
|
||||
@@ -183,6 +183,28 @@ button.sl-status:focus-visible {
|
||||
border-color: rgba(255, 179, 0, 0.55);
|
||||
}
|
||||
|
||||
/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */
|
||||
.sl-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sl-meta-row .sl-meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scoped to outrank .scene-btn's flex: 1, which squeezed the button to its
|
||||
min-width beside a long coverage line and clipped its one-line label. */
|
||||
.sl-meta-row .sl-nearest-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sl-nearest-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||
.sl-error {
|
||||
font-family: var(--font-sans);
|
||||
@@ -207,6 +229,14 @@ button.sl-status:focus-visible {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sl-seg[hidden],
|
||||
.sl-controls[hidden],
|
||||
.sl-filters[hidden],
|
||||
.sl-since[hidden],
|
||||
.sl-legend[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-seg-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
@@ -480,7 +510,9 @@ button.sl-status:focus-visible {
|
||||
/* A dragged window keeps its natural height, so bound it to the viewport:
|
||||
the rail's max-height: 100% does not bind an auto-height parent.
|
||||
Id-qualified to outrank that rule. */
|
||||
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
|
||||
#right-context-rail
|
||||
> #street-level-panel.panel-floating
|
||||
> .street-level-panel-inner {
|
||||
max-height: calc(100vh - 12px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
<!-- A missing key gates the filters only: the chips above stay
|
||||
focusable so their tooltip can say how to add the key. -->
|
||||
<fieldset id="sl-controls" class="sl-controls">
|
||||
<div class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
<span class="cctv-summary-label">IMAGERY</span>
|
||||
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
|
||||
@@ -126,14 +126,18 @@
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-since">
|
||||
<div id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since">SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
|
||||
</div>
|
||||
</fieldset>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
<div class="sl-meta-row">
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user