Files
gods-eye-view/src/testSupport/streetLevelFakes.mjs
T
German KhokhlovandClaude Opus 5.5 28e0ba7104 fix(street-level): /revmain cleanup after the slim rebuild
- Google 3D: the OPEN button re-measures a ground whose tiles are still
  loading (a few times, a second apart) and on a map change, not only
  after the next camera move.
- Mapillary: two mounts on one host during the first download share one
  build instead of leaking a second WebGL viewer.
- Retired floating-panel keys (v8 panelPos, panelFloatHintShown) are
  dropped from localStorage; dead inline-position resets, the drag JSDoc
  and the dead qa-ui-disposal observer branch are gone.
- Docs: z ladder, panel chrome, position storage and the fixture gate's
  actual coverage; changelog fragment for #974 (old .gev-cache/mapillary
  can be deleted).
- ru/es: the layer is named as everywhere else in the UI.
- Fixtures and test fakes: unused exports removed; a fake credit removal
  of an absent credit no longer drops another one; the tile test reset
  frees upstream slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:02:09 +04:00

363 lines
10 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/providers/mapillary/policy.js';
import {
latToTileY,
lonToTileX,
tileBounds,
} from '../layers/streetLevel/tileMath.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,
nearestImage = async () => null,
} = {}) {
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,
nearestImage,
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) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 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;
},
};
}
/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */
export function tileCentre(lon, lat, z = 14) {
const x = lonToTileX(lon, z);
const y = latToTileY(lat, z);
const { west, east, south, north } = tileBounds(x, y, z);
return {
tile: { x, y, z },
lon: (west + east) / 2,
lat: (south + north) / 2,
};
}
/** A scene collection that remembers what is on the globe. */
function sceneCollection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/**
* A viewer looking straight down on a 0.004° square around `view`
* (`{lon, lat, height}`, mutable), enough for line coverage to attach,
* refresh and draw. Cesium's one-time terrain-height download is skipped.
*/
export function coverageViewer(view, t) {
const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights;
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () =>
Promise.resolve();
t?.after(() => {
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize;
});
// Node has no WebGL context to report line-width limits.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const listeners = () => {
const set = new Set();
return {
set,
addEventListener(listener) {
set.add(listener);
return () => set.delete(listener);
},
};
};
return {
view,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
groundPrimitives: sceneCollection(),
primitives: sceneCollection(),
postRender: listeners(),
preRender: listeners(),
},
camera: {
changed: new Cesium.Event(),
moveEnd: new Cesium.Event(),
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
get positionCartographic() {
return {
longitude: view.lon * RAD,
latitude: view.lat * RAD,
height: view.height,
};
},
pickEllipsoid(point) {
return Cesium.Cartesian3.fromDegrees(
view.lon - 0.002 + (point.x / 100) * 0.004,
view.lat + 0.002 - (point.y / 100) * 0.004,
);
},
computeViewRectangle: () => undefined,
},
};
}