Files
gods-eye-view/src/testSupport/streetLevelFakes.mjs
T
German KhokhlovandClaude Fable 5.1 c29ccd55d6 Merge origin/main: keep the provider registry, slim it
main grew the Street Level provider registry while this branch removed it
(#982–#985: injected providers, an empty composition, the `r` switch field).
The registry, params, uiState and pick router come back as main has them and
the slimmed layer sits behind the provider contract:

- The core (index.js) drives providers as on main, without surface modes,
  ground casting, FOLLOW, prewarm and nearest-image lookups. A provider's key
  status is asked with a 10 s timeout and asked again on a switch-on while no
  key is known.
- Mapillary is mapillaryProvider.js over the slim coverage, sequences and
  viewer modules, which read the filter and the active flag from the provider
  context.
- The viewer host mounts one provider adapter at a time, no prewarm.
- The panel has a chip per provider again (the pill switches the layer);
  no legend, EXPAND stays the Fullscreen API.
- The contract drops what no provider implements: groundCaster, meshSampler,
  getSurface/setSurface, nearestImage, prewarm, reportError, the legend and
  surface fields of the UI snapshot.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 13:24:25 +04:00

264 lines
7.1 KiB
JavaScript

/** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
import * as Cesium from 'cesium';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/policy.js';
const RAD = Math.PI / 180;
/** One provider as `getUIState().providers` lists it. */
export function providerSnapshot(overrides = {}) {
return {
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
on: true,
configured: true,
keyRequired: false,
requiresKeyId: 'mapillary',
loading: false,
count: 0,
hint: '',
error: null,
color: '#05cb63',
...overrides,
};
}
/**
* The smallest provider the core accepts. Mutate `stats` to change what
* coverageStats() answers; `calls`, `filters` and `context()` record use.
*/
export function fakeStreetLevelProvider({
id = 'mapillary',
pickPrefix = 'mly:',
html = MAPILLARY_CREDIT_HTML,
} = {}) {
const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 };
const filters = [];
const stats = {
count: 0,
loading: false,
hint: '',
error: null,
keyRequired: false,
};
let context = null;
return {
calls,
filters,
stats,
context: () => context,
id,
name: 'Mapillary',
label: 'MAPILLARY',
requiresKeyId: null,
pickPrefix,
colors: { coverage: '#05cb63' },
credit: { html },
create: (providerContext) => {
context = providerContext;
return {
status: async () => ({ configured: true }),
init() {},
activate: () => calls.activate++,
deactivate: () => calls.deactivate++,
destroy() {},
setFilter: (filter) => filters.push(filter),
coverageStats: () => ({ ...stats }),
handlePick: () => false,
viewer: {
mount: async () => calls.mount++,
open: async (imageId) => calls.open.push(imageId),
close() {},
unmount: () => calls.unmount++,
resize() {},
onPose: () => () => {},
},
};
},
};
}
/**
* A Mapillary source for the provider: a configured key, empty tiles, and the
* given lookups; `calls` records tile requests.
*/
export function fakeMapillarySource(overrides = {}) {
const calls = { tiles: [] };
return {
calls,
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async (...args) => {
calls.tiles.push(args);
return new Uint8Array(0);
},
getSequenceImages: async () => [],
...overrides,
};
}
/**
* A photo viewer in place of MapillaryJS: `calls` records use, and `open`
* reports a pose for the image (when `pose` is given) as the real one does.
*/
export function fakePhotoViewer({ pose = null } = {}) {
const calls = { mount: 0, open: [], unmount: 0 };
let emit = null;
return {
calls,
mount: async () => calls.mount++,
async open(imageId) {
calls.open.push(imageId);
if (pose) emit?.({ imageId, ...pose });
},
close() {},
unmount: () => calls.unmount++,
resize() {},
onPose(listener) {
emit = listener;
return () => {
emit = null;
};
},
};
}
/**
* A stand-in Cesium viewer a layer can be enabled on: a canvas for the click
* handler, camera events with Cesium's flight bookkeeping (`flights` counts
* them, lists them and can `land` the current one), primitive lists and a
* credit display. With `view`, the camera looks straight down on Sacramento
* from 900 m, so coverage asks for tiles.
*/
export function fakeCesiumViewer({ view = false } = {}) {
const credits = [];
let flight = null;
const flights = {
started: 0,
cancelled: 0,
/** Every flight asked for, oldest first: `{ sphere, options }`. */
list: [],
land() {
const current = flight;
flight = null;
current?.options.complete?.();
},
};
const stopFlight = () => {
const current = flight;
flight = null;
current?.options.cancel?.();
return Boolean(current);
};
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
});
const camera = Object.assign(
view
? rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
})
: {},
{
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
flyToBoundingSphere(sphere, options = {}) {
stopFlight();
flight = { sphere, options };
flights.list.push(flight);
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
);
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
groundPrimitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
},
};
}
/**
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
*/
export function rayCamera({
lon,
lat,
altitude,
heading = 0,
pitch,
width = 1600,
height = 900,
fovY = 60,
fovX = null,
}) {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, altitude);
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const h = heading * RAD;
const p = pitch * RAD;
// East-north-up axes of the view: forward, right and up.
const forward = [
Math.sin(h) * Math.cos(p),
Math.cos(h) * Math.cos(p),
Math.sin(p),
];
const right = [Math.cos(h), -Math.sin(h), 0];
const up = [
-Math.sin(h) * Math.sin(p),
-Math.cos(h) * Math.sin(p),
Math.cos(p),
];
const halfY = Math.tan((fovY / 2) * RAD);
const halfX =
fovX == null ? halfY * (width / height) : Math.tan((fovX / 2) * RAD);
return {
positionWC: position,
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, altitude),
pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) {
const sx = ((2 * point.x) / width - 1) * halfX;
const sy = (1 - (2 * point.y) / height) * halfY;
const local = [0, 1, 2].map(
(i) => forward[i] + sx * right[i] + sy * up[i],
);
const direction = Cesium.Cartesian3.normalize(
Cesium.Matrix4.multiplyByPointAsVector(
frame,
new Cesium.Cartesian3(...local),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const ray = new Cesium.Ray(position, direction);
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
},
};
}