mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
- 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]>
363 lines
10 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|