mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(street-level): OPEN STREET VIEW, and Street View only on Google 3D
- OPEN STREET VIEW in the PROVIDERS row opens Street View's nearest panorama at the ground under the middle of the screen, without moving the camera: Street View no longer needs a pointer. It is disabled (and says so) until the camera is at street zoom. - Off the Google 3D map Street View does not appear at all: no chip, legend swatch, globe credit, layer source or button, and Google's script is not loaded (the viewer has no prewarm; the script loads on first use). The share option keeps the user's choice for when Google 3D returns. - A ground click measures the camera height at the click rather than reading the cached flag. - A chip without a usable key explains it instead of lighting; it darkens beside a lit chip, and the other chips keep switching, so a refused Street View key cannot lock Mapillary out. - The gate checks that nothing of Street View shows off Google 3D even when a share link switches it on, presses OPEN STREET VIEW on Google 3D, and asserts that each chip it switches really switched. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7bfcf177ac
commit
ef396655fc
18 files changed
+505
-110
No files matched your search
@@ -15,15 +15,24 @@ export function groundClickHeightOk(viewer, groundAt) {
|
||||
* street zoom opens the nearest image of the active, available providers
|
||||
* that have no coverage to click (`groundClick`, Google Street View). The
|
||||
* camera stays put: the user chose the spot.
|
||||
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
|
||||
* 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 }) {
|
||||
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) || !state.groundClickReady)
|
||||
if (
|
||||
!scenePick.isSurfacePick(picked) ||
|
||||
!groundClickHeightOk(state.viewer, groundAt)
|
||||
)
|
||||
return false;
|
||||
const providerIds = [...state.providers.values()]
|
||||
.filter(
|
||||
|
||||
@@ -16,7 +16,7 @@ function harness({
|
||||
{ id: 'mapillary', groundClick: false },
|
||||
{ id: 'google', groundClick: true },
|
||||
],
|
||||
ready = true,
|
||||
height = 400,
|
||||
enabled = true,
|
||||
picked = GROUND,
|
||||
ellipsoid = undefined,
|
||||
@@ -24,7 +24,6 @@ function harness({
|
||||
const opened = [];
|
||||
const state = {
|
||||
enabled,
|
||||
groundClickReady: ready,
|
||||
services: { scenePick },
|
||||
providers: new Map(
|
||||
providers.map(
|
||||
@@ -42,7 +41,14 @@ function harness({
|
||||
),
|
||||
viewer: {
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: { pickEllipsoid: () => ellipsoid },
|
||||
camera: {
|
||||
pickEllipsoid: () => ellipsoid,
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(
|
||||
-121.4944,
|
||||
38.5816,
|
||||
height,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
const openAtGround = createGroundClick({
|
||||
@@ -87,7 +93,7 @@ test('nothing opens over another layer’s object, the sky, from high up, off it
|
||||
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
|
||||
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
|
||||
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
|
||||
{ options: { ready: false }, label: 'high up' },
|
||||
{ options: { height: 5000 }, label: 'high up' },
|
||||
{
|
||||
options: {
|
||||
providers: [{ id: 'google', groundClick: true, available: false }],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createState } from './state.js';
|
||||
import { createMarker } from './marker.js';
|
||||
import { createCameraFollow } from './cameraFollow.js';
|
||||
@@ -71,6 +72,7 @@ export function createStreetLevelLayer({
|
||||
state,
|
||||
openNearest: (point, options) => layer.openNearest(point, options),
|
||||
isAvailable: (entry) => providerAvailable(entry),
|
||||
groundAt: parts.groundCaster?.groundAt ?? null,
|
||||
});
|
||||
|
||||
let notifyQueued = false;
|
||||
@@ -155,11 +157,23 @@ export function createStreetLevelLayer({
|
||||
return !required || mapStack?.getActiveId?.() === required;
|
||||
}
|
||||
|
||||
/** Leaving a provider's map stack closes the image it shows. */
|
||||
/**
|
||||
* A provider appears only on its map stack: leaving it closes the image it
|
||||
* shows, releases its viewer and takes its credit off the globe.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -256,9 +270,14 @@ 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);
|
||||
// Off its map stack a provider shows nothing: no credit, no viewer load.
|
||||
if (!providerAvailable(entry)) return;
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
// Checked again when it runs: the map may have changed meanwhile.
|
||||
whenIdle(() => {
|
||||
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function deactivate(entry) {
|
||||
@@ -275,6 +294,24 @@ export function createStreetLevelLayer({
|
||||
nearestLookup = null;
|
||||
}
|
||||
|
||||
/** The ground under the middle of the screen, or null (no scenePick, sky). */
|
||||
function screenCentreGround() {
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const pick = state.services.scenePick?.pickGroundPosition;
|
||||
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight) return null;
|
||||
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();
|
||||
@@ -317,16 +354,18 @@ export function createStreetLevelLayer({
|
||||
|
||||
/** The hint a ground-click provider shows, or the provider's own. */
|
||||
function hintFor(entry, stats) {
|
||||
if (!providerAvailable(entry)) return '';
|
||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
||||
return `zoom in to a street to open ${entry.def.name}`;
|
||||
return stats.hint || '';
|
||||
}
|
||||
|
||||
/** 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();
|
||||
const available = providerAvailable(entry);
|
||||
return {
|
||||
id: entry.def.id,
|
||||
name: entry.def.name,
|
||||
@@ -342,8 +381,8 @@ export function createStreetLevelLayer({
|
||||
hint: hintFor(entry, stats),
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
/** Why the provider cannot be used right now, or null. */
|
||||
unavailable: available ? null : unavailableReason(entry),
|
||||
/** No coverage to click: it opens where the user points. */
|
||||
groundClick: entry.def.groundClick === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -371,6 +410,7 @@ export function createStreetLevelLayer({
|
||||
const { host, ...street } = state.street;
|
||||
return composeUIState({
|
||||
enabled: state.enabled,
|
||||
groundClickReady: state.groundClickReady,
|
||||
filter: state.filter,
|
||||
providers: providerSnapshots(),
|
||||
street,
|
||||
@@ -383,7 +423,12 @@ export function createStreetLevelLayer({
|
||||
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,
|
||||
/** The key the switched-on providers share (or null), for the layer row. */
|
||||
@@ -463,6 +508,7 @@ export function createStreetLevelLayer({
|
||||
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,
|
||||
@@ -530,7 +576,13 @@ 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. */
|
||||
@@ -538,10 +590,17 @@ export function createStreetLevelLayer({
|
||||
openImage,
|
||||
/**
|
||||
* Open the nearest image any active provider (or only `providerIds`)
|
||||
* has around a point, by default the view centre.
|
||||
* has around a point; without one, around the view centre, or with
|
||||
* `aim: 'screen-centre'` the ground under the middle of the screen.
|
||||
*/
|
||||
async openNearest(point, { providerIds = null, frame = true } = {}) {
|
||||
const view = point || viewCentre(state.viewer);
|
||||
async openNearest(
|
||||
point,
|
||||
{ providerIds = null, frame = true, aim = 'view' } = {},
|
||||
) {
|
||||
const view =
|
||||
point ||
|
||||
(aim === 'screen-centre' && screenCentreGround()) ||
|
||||
viewCentre(state.viewer);
|
||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||
return false;
|
||||
abortNearest();
|
||||
|
||||
@@ -502,15 +502,16 @@ async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) {
|
||||
},
|
||||
});
|
||||
mapillary.requiresKeyId = 'mapillary';
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
const google = createGoogleProvider({
|
||||
getApiKey: () => 'browser-key',
|
||||
loader: fakeMapsLoader(fake.library),
|
||||
loader,
|
||||
});
|
||||
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
|
||||
const stack = fakeMapStack(mapStack);
|
||||
layer.attachMapStackController(stack);
|
||||
layer.attachViewerHost(fakeHost());
|
||||
return { layer, viewer, stack, fake, mapillaryLookups };
|
||||
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) => {
|
||||
@@ -523,27 +524,42 @@ test('Street View starts off, and the layer row names the key of the providers t
|
||||
assert.equal(layer.requiresKeyId, null, 'two keys: each chip names its own');
|
||||
});
|
||||
|
||||
test('Street View opens only on the Google 3D map, and leaving that map closes it', async (t) => {
|
||||
const { layer, stack } = await withStreetView(t, {
|
||||
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 google = () =>
|
||||
layer.getUIState().providers.find((p) => p.id === 'google');
|
||||
assert.match(google().unavailable, /needs the Google 3D map/);
|
||||
assert.equal(google().hint, '', 'no "click a street" off Google 3D');
|
||||
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(layer.getUIState().street.error, /needs the Google 3D map/);
|
||||
assert.match(ui().street.error, /needs the Google 3D map/);
|
||||
assert.equal(layer.getParams().google, true, 'the choice is kept');
|
||||
|
||||
stack.switchTo('photoreal');
|
||||
assert.equal(google().unavailable, null);
|
||||
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(layer.getUIState().street.providerId, 'google');
|
||||
assert.equal(ui().street.providerId, 'google');
|
||||
|
||||
stack.switchTo('bing');
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.open, false, 'closed with the map');
|
||||
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) => {
|
||||
@@ -575,3 +591,49 @@ test('an image opened from a ground click leaves the camera where it is', async
|
||||
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);
|
||||
});
|
||||
@@ -254,15 +254,6 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
/** Load the library ahead of the first panorama; a panorama is billed, so none is built. */
|
||||
async prewarm() {
|
||||
try {
|
||||
await ensureLibrary();
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
}
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
|
||||
@@ -130,11 +130,8 @@ test('an open overtaken by a newer one only shows the newer panorama', async ()
|
||||
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
|
||||
});
|
||||
|
||||
test('unmounting removes the element and every listener; prewarm builds no panorama', async () => {
|
||||
const { fake, loader, viewer, host } = setup();
|
||||
await viewer.prewarm(host);
|
||||
assert.deepEqual(loader.imports, ['streetView']);
|
||||
assert.equal(fake.built.length, 0, 'a panorama is billed: none on prewarm');
|
||||
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);
|
||||
@@ -144,6 +141,12 @@ test('unmounting removes the element and every listener; prewarm builds no panor
|
||||
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);
|
||||
|
||||
@@ -45,6 +45,7 @@ export function summarizeCoverage(providers) {
|
||||
*/
|
||||
export function composeUIState({
|
||||
enabled,
|
||||
groundClickReady = false,
|
||||
filter,
|
||||
providers,
|
||||
street,
|
||||
@@ -63,6 +64,8 @@ export function composeUIState({
|
||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||
return {
|
||||
enabled,
|
||||
/** The camera is at street zoom: a ground-click provider can open here. */
|
||||
groundClickReady: groundClickReady === true,
|
||||
keyRequired,
|
||||
keyRejected,
|
||||
filter: { ...filter },
|
||||
|
||||
Reference in new issue
Block a user