fix(street-level): camera ownership, mesh refresh, panel CSP (Sameh's review)

- Photo framing and FOLLOW claim the camera through the application's
  navigation, injected with attachNavigation({ run, subscribeHandoff }):
  aircraft and satellite tracking is released first, the cockpit refuses,
  and FOLLOW stops when another feature takes the camera. No UI imports
  in the layer.
- The mesh sampler follows the mesh floor's rules: it waits for the
  visible tileset to finish streaming (visibleTilesetLoaded, now exposed
  by the mesh floor service), needs a real bare-earth prior and keeps to
  the shared meshFloorSampleWithinPrior window, which cameraFollow now
  uses too. A cell is probed again once the camera is half as far from
  it (down to 40 m), so a coarse early sample is replaced and redrawn.
- The MCP globe panel's CSP allows the Mapillary Graph API and its image
  CDN (https://*.xx.fbcdn.net).
- The tile proxy reads bodies with the shared readResponseBytesCapped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 23:04:33 +01:00
1 parent 630a1abdae
commit 1fa129ea8e
13 files changed
+459 -79

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ export function createApplicationStreetLevel({ surface, sources }) {
input,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
terrain: surface?.terrain ?? null,
},
});
+93 -33
View File
@@ -4,9 +4,6 @@ import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
const SURFACE_MIN_M = -500;
const SURFACE_MAX_M = 9000;
/** Rendered mesh window around the bare earth: roofs and bridges, not whole towers. */
const MESH_BELOW_DEM_M = 15;
const MESH_ABOVE_DEM_M = 80;
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
export function createCameraFollow({ state, parts }) {
@@ -16,6 +13,48 @@ export function createCameraFollow({ state, parts }) {
render?.governorRequestRender?.('street-level-follow');
}
/**
* The application's camera authority (`attachNavigation`): `run(noun, move)`
* releases tracking and other owners before `move`, or refuses (cockpit);
* `subscribeHandoff` reports a newer owner. Without it, moves run directly.
*/
let navigation = null;
let unsubscribeHandoff = null;
/** Set while our own claim stamps a handoff, so it does not end FOLLOW. */
let claiming = false;
/** Take the camera for `move`; false when the application refuses. */
function claim(move) {
if (!navigation?.run) {
move();
return true;
}
claiming = true;
try {
return (
navigation.run('photo', () => {
move();
return true;
}) === true
);
} finally {
claiming = false;
}
}
/** Another feature took the camera: stop following it. */
function onHandoff() {
if (claiming || !state.street.follow) return;
state.street.follow = false;
state.notify?.();
}
function attachNavigation(next) {
unsubscribeHandoff?.();
navigation = next || null;
unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null;
}
/**
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so a sample counts only when plausible and
@@ -27,12 +66,13 @@ export function createCameraFollow({ state, parts }) {
const caster = parts?.groundCaster;
const dem = caster?.groundAt(lon, lat) ?? null;
if (dem === null) caster?.prepare([[lon, lat]]);
// The application's mesh window around bare earth, once that is known.
const withinPrior = state.services.ground?.meshFloorSampleWithinPrior;
const plausible = (height) =>
Number.isFinite(height) &&
height >= SURFACE_MIN_M &&
height <= SURFACE_MAX_M &&
(dem === null ||
(height >= dem - MESH_BELOW_DEM_M && height <= dem + MESH_ABOVE_DEM_M));
(dem === null || !withinPrior || withinPrior(height, dem));
let height = null;
try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
@@ -73,29 +113,31 @@ export function createCameraFollow({ state, parts }) {
const { position, bearing, altitude } = state.street;
if (!position || !state.viewer) return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
// Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once.
const flight = {};
framing = flight;
const release = () => {
if (framing === flight) framing = null;
};
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
claim(() => {
// Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once.
const flight = {};
framing = flight;
const release = () => {
if (framing === flight) framing = null;
};
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
duration: 1.6,
complete: release,
cancel: release,
},
);
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
),
duration: 1.6,
complete: release,
cancel: release,
},
);
});
}
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
@@ -107,14 +149,32 @@ export function createCameraFollow({ state, parts }) {
}
function setFollow(enabled) {
state.street.follow = enabled === true && state.street.followAvailable;
// The framing tween would keep overwriting the follow view until it lands.
if (state.street.follow) {
cancelFraming();
followCamera();
const wanted = enabled === true && state.street.followAvailable;
if (wanted && !state.street.follow) {
// Following owns the camera until another feature takes it.
const granted = claim(() => {
state.street.follow = true;
// The framing tween would keep overwriting the follow view until it lands.
cancelFraming();
followCamera();
});
if (!granted) state.street.follow = false;
} else if (!wanted) {
state.street.follow = false;
}
state.notify?.();
}
return { followCamera, lookAtPosition, cancelFraming, setFollow };
function destroy() {
attachNavigation(null);
}
return {
followCamera,
lookAtPosition,
cancelFraming,
setFollow,
attachNavigation,
destroy,
};
}
@@ -156,3 +156,80 @@ test('turning FOLLOW on stops the framing flight so it cannot fight the follow v
assert.equal(camera.cancelled, 1, 'the framing tween stops');
assert.equal(views.length, 1, 'and the camera stands at the photo');
});
/**
* The application's camera authority: `run` releases tracking (here, a tracked
* entity) and stamps a handoff before the move, or refuses like the cockpit.
*/
function navigationFor(state, { refuse = false } = {}) {
const listeners = new Set();
const nav = {
runs: [],
run(noun, move) {
nav.runs.push(noun);
if (refuse) return false;
for (const listener of listeners)
listener({ generation: nav.runs.length });
state.viewer.trackedEntity = undefined;
return move(nav.runs.length);
},
subscribeHandoff(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
handOff() {
for (const listener of listeners) listener({ generation: -1 });
},
listeners,
};
return nav;
}
test('framing a photo claims the camera through the application, releasing tracking', () => {
const { follow, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' };
const nav = navigationFor(state);
follow.attachNavigation(nav);
follow.lookAtPosition();
assert.deepEqual(nav.runs, ['photo']);
assert.equal(state.viewer.trackedEntity, undefined);
assert.equal(flights.length, 1);
});
test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => {
const { follow, state, flights, views } = setup({ sampled: 160 });
follow.attachNavigation(navigationFor(state, { refuse: true }));
follow.lookAtPosition();
follow.setFollow(true);
assert.equal(flights.length, 0);
assert.equal(views.length, 0);
assert.equal(state.street.follow, false);
});
test('FOLLOW claims the camera once and ends when another feature takes it', () => {
const { follow, state, views } = setup({ sampled: 160 });
let notified = 0;
state.notify = () => notified++;
const nav = navigationFor(state);
follow.attachNavigation(nav);
follow.setFollow(true);
assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on');
assert.deepEqual(nav.runs, ['photo']);
follow.followCamera();
assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again');
assert.equal(views.length, 2);
nav.handOff();
assert.equal(state.street.follow, false);
assert.ok(notified >= 2);
follow.followCamera();
assert.equal(views.length, 2, 'the camera is no longer driven');
});
test('destroying the follow part stops listening for handoffs', () => {
const { follow, state } = setup({ sampled: 160 });
const nav = navigationFor(state);
follow.attachNavigation(nav);
assert.equal(nav.listeners.size, 1);
follow.destroy();
assert.equal(nav.listeners.size, 0);
});
+18 -1
View File
@@ -40,8 +40,15 @@ export function createStreetLevelLayer({
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
? createGroundCaster({ terrain: services.terrain })
: null;
// Mesh samples follow the application's rules: a real bare-earth prior,
// its mesh window, and a tileset that has finished streaming.
parts.meshSampler = parts.groundCaster
? createMeshSampler({ getViewer: () => state.viewer })
? createMeshSampler({
getViewer: () => state.viewer,
groundAt: parts.groundCaster.groundAt,
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
})
: null;
parts.credits = createCredits();
parts.marker = createMarker(context);
@@ -375,6 +382,7 @@ export function createStreetLevelLayer({
entry.instance.destroy(viewer);
parts.marker.destroy(viewer);
parts.meshSampler?.destroy();
parts.follow.destroy();
unsubscribeMapStack?.();
unsubscribeMapStack = null;
mapStack = null;
@@ -405,6 +413,15 @@ export function createStreetLevelLayer({
};
},
/**
* The application's camera authority: `run(noun, move)` and
* `subscribeHandoff(listener)`. Framing and FOLLOW claim the camera
* through it, and FOLLOW stops when another feature takes the camera.
*/
attachNavigation(navigation) {
parts.follow.attachNavigation(navigation);
},
/** The application map stack; FOLLOW is available only on Google 3D. */
attachMapStackController(controller) {
unsubscribeMapStack?.();
+85 -12
View File
@@ -5,8 +5,10 @@ import { metresBetween, whenIdle } from './view.js';
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
* camera first and only within MESH_SAMPLE_RADIUS_M. A miss (tiles not
* streamed yet) is retried later. Probes hit 3D tilesets only.
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
* mesh floor, a probe waits for the visible tileset to finish streaming and a
* sample counts only within the shared window around a real bare-earth height;
* anything else is a miss, retried later. Probes hit 3D tilesets only.
*/
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
@@ -19,6 +21,16 @@ export const MESH_SAMPLE_BUDGET_MS = 6;
export const MESH_RERANK_M = 100;
/** A cell whose probe missed is tried again after this long. */
export const MESH_MISS_RETRY_MS = 8000;
/** While the visible tileset is streaming, the next probe waits this long. */
export const MESH_STREAMING_WAIT_MS = 500;
/**
* A sampled cell is probed again once the camera is half as far from it as at
* its last probe (finer tiles have loaded by then), down to this distance:
* at most a handful of probes per cell.
*/
export const MESH_REFRESH_MIN_M = 40;
/** A refreshed sample that moved less than this is not redrawn. */
const MESH_REFRESH_CHANGE_M = 0.5;
/** Cached cells (and remembered misses) before they are dropped and refilled. */
const MESH_CACHE_MAX = 80_000;
/** Listeners hear about new samples at most this often. */
@@ -80,10 +92,16 @@ function overlays(scene) {
}
/**
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
* visible tileset has finished streaming.
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
*/
export function createMeshSampler({
getViewer,
groundAt = null,
withinPrior = null,
tilesReady = null,
budgetMs = MESH_SAMPLE_BUDGET_MS,
radiusM = MESH_SAMPLE_RADIUS_M,
cacheMax = MESH_CACHE_MAX,
@@ -91,6 +109,8 @@ export function createMeshSampler({
}) {
/** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map();
/** cell key → camera distance (m) at the cell's last probe. */
const probedFrom = new Map();
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
const misses = new Map();
/** cell key → queued cell, so a cell is queued once. */
@@ -114,16 +134,40 @@ export function createMeshSampler({
return heights.get(meshCellKey(lon, lat));
}
/** The point under the camera, or null. */
/** The point under the camera, with the camera's height, or null. */
function cameraCentre() {
const carto = getViewer()?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
height: carto.height,
};
}
/** Camera distance (m) to a cell at `height`, from its ground distance. */
function cameraDistance(ground, centre, height) {
const above = (centre.height ?? 0) - (height ?? 0);
return Number.isFinite(above)
? Math.hypot(ground, Math.max(0, above))
: ground;
}
/** A sampled cell is probed again once the camera is half as far as before. */
function dueForRefresh(key, distance) {
const last = probedFrom.get(key);
return last > MESH_REFRESH_MIN_M && distance < last / 2;
}
/** A probe result the application would accept, or undefined. */
function validated(height, lon, lat) {
if (!Number.isFinite(height)) return undefined;
if (!groundAt) return height;
const prior = groundAt(lon, lat);
if (!Number.isFinite(prior)) return undefined;
return !withinPrior || withinPrior(height, prior) ? height : undefined;
}
/**
* Ask for the cells under [lon, lat] points; the list is read later, so do
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
@@ -147,14 +191,20 @@ export function createMeshSampler({
const [lon, lat] = points[pending.next++];
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const key = meshCellKey(lon, lat);
if (heights.has(key) || wanted.has(key)) continue;
if (wanted.has(key)) continue;
const cell = {
key,
lon: cellOf(lon) * MESH_CELL_DEG,
lat: cellOf(lat) * MESH_CELL_DEG,
distance: 0,
};
if (metresBetween(cell, centre) > radiusM) continue;
const ground = metresBetween(cell, centre);
if (ground > radiusM) continue;
if (
heights.has(key) &&
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
)
continue;
const retryAt = misses.get(key);
if (retryAt !== undefined) {
if (retryAt > time) continue;
@@ -220,6 +270,15 @@ export function createMeshSampler({
const scene = getViewer()?.scene;
const centre = cameraCentre();
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
// Mid-stream tiles answer with coarse heights: wait for the stream.
if (tilesReady && !tilesReady(scene)) {
running = true;
setTimeout(() => {
running = false;
schedule();
}, MESH_STREAMING_WAIT_MS);
return;
}
// Nearest first, ranked from where the camera was; out-of-range cells
// are dropped (they stay bare earth).
let worked = false;
@@ -241,7 +300,8 @@ export function createMeshSampler({
const { key, lon, lat } = heapPop(queue);
wanted.delete(key);
// Ranked from up to MESH_RERANK_M away: check the range from here.
if (metresBetween({ lon, lat }, centre) > radiusM) continue;
const ground = metresBetween({ lon, lat }, centre);
if (ground > radiusM) continue;
probed++;
const probeStarted = now();
let height;
@@ -254,15 +314,27 @@ export function createMeshSampler({
height = undefined;
}
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
if (!Number.isFinite(height)) {
if (misses.size >= cacheMax) misses.clear();
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
height = validated(height, lon, lat);
const previous = heights.get(key);
if (height === undefined) {
// A failed refresh keeps the old sample, and waits for a closer camera.
if (previous !== undefined)
probedFrom.set(key, cameraDistance(ground, centre, previous));
else {
if (misses.size >= cacheMax) misses.clear();
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
}
continue;
}
if (heights.size >= cacheMax) forget();
if (previous === undefined && heights.size >= cacheMax) forget();
misses.delete(key);
heights.set(key, height);
fresh.push([lon, lat]);
probedFrom.set(key, cameraDistance(ground, centre, height));
if (
previous === undefined ||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
)
fresh.push([lon, lat]);
}
if (fresh.length && !notifyTimer)
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
@@ -271,6 +343,7 @@ export function createMeshSampler({
function forget() {
heights.clear();
probedFrom.clear();
misses.clear();
}
+107
View File
@@ -4,9 +4,12 @@ import * as Cesium from 'cesium';
import {
MESH_CELL_DEG,
MESH_MISS_RETRY_MS,
MESH_REFRESH_MIN_M,
MESH_SAMPLE_BUDGET_MS,
MESH_STREAMING_WAIT_MS,
createMeshSampler,
} from './meshSampler.js';
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
import { metresBetween } from './view.js';
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -267,3 +270,107 @@ test('the sampling range is measured the short way round the date line', (t) =>
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
sampler.destroy();
});
/** A scene whose mesh height can change (finer tiles) and a camera that can move. */
function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) {
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
const probes = [];
const viewer = {
surface,
ready,
scene: {
sampleHeightSupported: true,
primitives: { length: 1, get: () => tileset },
sampleHeight(carto) {
probes.push(carto);
return viewer.surface;
},
},
camera: {
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height },
},
};
return { viewer, probes };
}
function liveSampler(viewer, options = {}) {
return createMeshSampler({
getViewer: () => viewer,
groundAt: () => 75,
withinPrior: meshFloorSampleWithinPrior,
tilesReady: () => viewer.ready(),
...options,
});
}
test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 700 });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
const heard = [];
sampler.onSampled((batch) => heard.push(batch.length));
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(sampler.meshAt(10.001, 50), 80);
// Finer tiles load: the surface is really 100 m.
viewer.surface = 100;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, 'no new probe from the same distance');
assert.equal(sampler.meshAt(10.001, 50), 80);
viewer.camera.positionCartographic.height = 250;
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(probes.length, 2);
assert.equal(sampler.meshAt(10.001, 50), 100);
assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw');
sampler.destroy();
});
test('refreshing stops once a cell was probed from close by', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 110 });
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle();
viewer.camera.positionCartographic.height = 85;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`);
sampler.destroy();
});
test('probes wait while the visible tileset is still streaming', async () => {
let ready = false;
const { viewer, probes } = liveViewer({ ready: () => ready });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 0);
ready = true;
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 1);
assert.equal(sampler.meshAt(10.001, 50), 80);
sampler.destroy();
});
test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => {
const { viewer } = liveViewer({ surface: 300 });
const unknown = liveSampler(viewer, { groundAt: () => null });
unknown.setEnabled(true);
unknown.request([[10.001, 50]]);
const outside = liveSampler(viewer);
outside.setEnabled(true);
outside.request([[10.001, 50]]);
await settle();
assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept');
assert.equal(
outside.meshAt(10.001, 50),
undefined,
'225 m above it: not kept',
);
unknown.destroy();
outside.destroy();
});
+5 -1
View File
@@ -195,5 +195,9 @@ export function createMeshFloorSampler({
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
{ once: true },
);
return { sampleMeshFloorCells, cachedGroundFloor };
return {
sampleMeshFloorCells,
cachedGroundFloor,
visibleTilesetLoaded: _visibleTilesetLoaded,
};
}
+6 -2
View File
@@ -14,8 +14,9 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
/**
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
* terrain and fonts. Everything from the app's own server comes through the
* MCP server.
* terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its
* coverage tiles come through the app's server). Everything from the app's
* own server comes through the MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com',
@@ -35,6 +36,9 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://gibs.earthdata.nasa.gov',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
'https://graph.mapillary.com',
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
'https://*.xx.fbcdn.net',
]);
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
+36
View File
@@ -7,6 +7,21 @@ import {
} from './globePanel.js';
import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js';
import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/providers/mapillary/policy.js';
/** Whether a CSP source list allows a URL (exact origin or `https://*.host`). */
function allows(sources, url) {
const { origin, protocol, hostname } = new URL(url);
return sources.some((source) => {
if (source === origin) return true;
const wildcard = source.match(/^(https:)\/\/\*\.(.+)$/);
return (
Boolean(wildcard) &&
protocol === wildcard[1] &&
hostname.endsWith(`.${wildcard[2]}`)
);
});
}
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
const resource = createGlobePanelResource({
@@ -45,6 +60,27 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser
assert.doesNotThrow(() => new Function(script));
});
test('the panel lets Street Level reach Mapillary from inside the embed', () => {
const { csp } = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
})._meta.ui;
// Graph API lookups (nearest image, sequences) and MapillaryJS photo loads.
for (const url of [
`${MAPILLARY_GRAPH_HOST}/images?fields=id&bbox=1,2,3,4`,
'https://scontent-man2-1.xx.fbcdn.net/m1/v/t6/photo.jpg',
'https://scontent-iad3-2.xx.fbcdn.net/m1/v/t6/photo.jpg',
]) {
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
}
assert.equal(allows(csp.connectDomains, 'https://evil.example/x'), false);
assert.equal(
allows(csp.connectDomains, 'https://fbcdn.net.evil.example/'),
false,
);
});
test('the globe panel needs a request key', () => {
for (const panelKey of [undefined, '', 42])
assert.throws(
+7
View File
@@ -939,6 +939,12 @@ export class StyleManager extends ShellFacade {
this._streetLevelControls?.destroy();
this._streetLevelControls = null;
if (!this._streetLevelPanel || !streetLevelLayer) return;
// Photo framing and FOLLOW release tracking like any explicit flight.
streetLevelLayer.attachNavigation?.({
run: (noun, move) => this._runExplicitNavigation(noun, move),
subscribeHandoff: (listener) =>
this._navigation.subscribeCameraHandoff(listener),
});
this._streetLevelControls = new StreetLevelControls({
root: this._streetLevelPanel,
layer: streetLevelLayer,
@@ -1577,6 +1583,7 @@ export class StyleManager extends ShellFacade {
this._cameraOrientationControls?.destroy();
this._clearLayersControl?.destroy();
this._cctvControls?.destroy();
this.services?.streetLevelLayer?.attachNavigation?.(null);
this._streetLevelControls?.destroy();
this._radioControls?.destroy();
this._localSdrControls?.destroy();