mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge origin/main: Google Street View, KartaView and Panoramax on the slim Street Level
Main gained three imagery providers (Google Street View, KartaView,
Panoramax), shared line-coverage, cone and photo-viewer modules, one source
lit at a time and the translated interface. Their shared modules still
carried what this branch removes, so the merge ports them onto it:
- lineCoverage.js is this branch's coverage.js made provider-neutral
(draped lines near the ground only, the selection drawn over them, key
and rate-limit blocking); sequenceCones.js is sequences.js the same way.
Mapillary, KartaView and Panoramax all build on them; Mapillary moves
back to providers/mapillary/ beside the others.
- No ground casting, mesh sampling, surface modes, FOLLOW, legend, prewarm,
orbit overview points (Panoramax's density dots included), stale-tile
swaps or water-filled point budget come back.
- Kept from main because the new providers need them: requiresMapStack and
attachMapStackController (Street View on Google 3D only), ground clicks,
openNearest with an optional provider nearestImage, openImage {frame},
reportError, the view-cone marker (now an SVG glyph), filters /
coverageLabel / sinceBasis, OPEN STREET VIEW, keyAlternatives and i18n.
- photoViewer.js treats the viewer as expanded when its wrap is full screen
or the iPhone popover, as EXPAND now is.
- The fixture browser gate keeps its 8 steps and adds one each for
KartaView and Panoramax; catalogs, docs, boundaries and format scope
follow.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
6390a3346f
809 files changed
+81825
-6035
No files matched your search
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Stand-ins for the Google Maps JavaScript API's Street View library and the
|
||||
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate,
|
||||
* copyright, description}}; `nearest(location, radius)` answers location
|
||||
* lookups with an id or null; `substitutes` maps an id Google answers with a
|
||||
* newer one. Like Google, `setPano` sets the id (pano_changed) at once, and
|
||||
* the position and status follow when the data arrives; status_changed fires
|
||||
* only when the status changes.
|
||||
*/
|
||||
|
||||
function latLng(lat, lng) {
|
||||
return { lat: () => lat, lng: () => lng };
|
||||
}
|
||||
|
||||
export function fakeStreetViewLibrary({
|
||||
panoramas = {},
|
||||
nearest = () => null,
|
||||
substitutes = {},
|
||||
} = {}) {
|
||||
const built = [];
|
||||
const lookups = [];
|
||||
|
||||
class StreetViewPanorama {
|
||||
constructor(element, options) {
|
||||
this.element = element;
|
||||
this.options = options;
|
||||
this.visible = options?.visible ?? true;
|
||||
this.pano = null;
|
||||
this.status = null;
|
||||
this.position = null;
|
||||
this.pov = { heading: 0, pitch: 0 };
|
||||
this.listeners = new Map();
|
||||
built.push(this);
|
||||
}
|
||||
|
||||
addListener(name, listener) {
|
||||
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
||||
this.listeners.get(name).add(listener);
|
||||
return { remove: () => this.listeners.get(name)?.delete(listener) };
|
||||
}
|
||||
|
||||
fire(name) {
|
||||
for (const listener of [...(this.listeners.get(name) || [])]) listener();
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
let count = 0;
|
||||
for (const set of this.listeners.values()) count += set.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Like Google: the id is set at once; the data loads asynchronously. */
|
||||
setPano(id) {
|
||||
this.pano = id;
|
||||
this.fire('pano_changed');
|
||||
queueMicrotask(() => {
|
||||
if (this.pano !== id) return;
|
||||
const shownId = substitutes[id] ?? id;
|
||||
const known = panoramas[shownId];
|
||||
const status = known ? 'OK' : 'ZERO_RESULTS';
|
||||
if (shownId !== id) {
|
||||
this.pano = shownId;
|
||||
this.fire('pano_changed');
|
||||
}
|
||||
if (known) {
|
||||
this.position = latLng(known.lat, known.lng);
|
||||
this.fire('position_changed');
|
||||
}
|
||||
if (status !== this.status) {
|
||||
this.status = status;
|
||||
this.fire('status_changed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPano() {
|
||||
return this.pano;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
getPov() {
|
||||
return { ...this.pov };
|
||||
}
|
||||
|
||||
/** The user drags the view. */
|
||||
setPov(pov) {
|
||||
this.pov = { ...pov };
|
||||
this.fire('pov_changed');
|
||||
}
|
||||
|
||||
setVisible(visible) {
|
||||
this.visible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
class StreetViewService {
|
||||
async getPanorama(request) {
|
||||
lookups.push(request);
|
||||
const id = request.pano ?? nearest(request.location, request.radius);
|
||||
const known = id && panoramas[id];
|
||||
if (!known)
|
||||
throw Object.assign(new Error('ZERO_RESULTS'), {
|
||||
code: 'ZERO_RESULTS',
|
||||
});
|
||||
return {
|
||||
data: {
|
||||
location: {
|
||||
pano: id,
|
||||
latLng: latLng(known.lat, known.lng),
|
||||
description: known.description,
|
||||
},
|
||||
imageDate: known.imageDate,
|
||||
copyright: known.copyright,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
library: {
|
||||
StreetViewPanorama,
|
||||
StreetViewService,
|
||||
StreetViewSource: { GOOGLE: 'google', OUTDOOR: 'outdoor' },
|
||||
StreetViewPreference: { NEAREST: 'nearest', BEST: 'best' },
|
||||
},
|
||||
built,
|
||||
lookups,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A loader over `library`; `refuseKey()` plays Google's gm_authFailure. With
|
||||
* `deferred`, imports wait for `release()`.
|
||||
*/
|
||||
export function fakeMapsLoader(library, { deferred = false } = {}) {
|
||||
let failed = false;
|
||||
const listeners = new Set();
|
||||
const imports = [];
|
||||
let release = null;
|
||||
const gate = deferred
|
||||
? new Promise((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
imports,
|
||||
async importLibrary(name) {
|
||||
imports.push(name);
|
||||
await gate;
|
||||
return library;
|
||||
},
|
||||
release: () => release?.(),
|
||||
authFailed: () => failed,
|
||||
onAuthFailure(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
refuseKey() {
|
||||
failed = true;
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** A host element with just what the viewer adapter touches. */
|
||||
export function fakeHost() {
|
||||
const children = [];
|
||||
const host = {
|
||||
children,
|
||||
ownerDocument: {
|
||||
createElement: () => {
|
||||
const element = {
|
||||
style: {},
|
||||
className: '',
|
||||
remove() {
|
||||
const index = children.indexOf(element);
|
||||
if (index !== -1) children.splice(index, 1);
|
||||
},
|
||||
};
|
||||
return element;
|
||||
},
|
||||
},
|
||||
append: (element) => children.push(element),
|
||||
};
|
||||
return host;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// DOM stand-ins for the KartaView viewer's tests (no browser in Node).
|
||||
|
||||
/** A DOM element with what the viewer touches: children, events, attributes. */
|
||||
export function fakeElement(tag) {
|
||||
const listeners = new Map();
|
||||
const element = {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [],
|
||||
parentNode: null,
|
||||
style: {},
|
||||
dataset: {},
|
||||
attributes: {},
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
clientWidth: 400,
|
||||
clientHeight: 250,
|
||||
classList: {
|
||||
values: new Set(),
|
||||
add: (name) => element.classList.values.add(name),
|
||||
remove: (name) => element.classList.values.delete(name),
|
||||
},
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
node.parentNode = element;
|
||||
element.children.push(node);
|
||||
}
|
||||
},
|
||||
remove() {
|
||||
const list = element.parentNode?.children;
|
||||
if (list) list.splice(list.indexOf(element), 1);
|
||||
element.parentNode = null;
|
||||
},
|
||||
replaceWith(next) {
|
||||
const parent = element.parentNode;
|
||||
if (!parent) return;
|
||||
parent.children[parent.children.indexOf(element)] = next;
|
||||
next.parentNode = parent;
|
||||
next.ownerDocument = element.ownerDocument;
|
||||
element.parentNode = null;
|
||||
},
|
||||
setAttribute: (name, value) => (element.attributes[name] = value),
|
||||
removeAttribute: (name) => {
|
||||
delete element.attributes[name];
|
||||
if (name === 'src') element.src = '';
|
||||
},
|
||||
addEventListener: (type, handler) => listeners.set(type, handler),
|
||||
removeEventListener: (type) => listeners.delete(type),
|
||||
setPointerCapture() {},
|
||||
/** Fire an event at the element's listener. */
|
||||
fire(type, event = {}) {
|
||||
return listeners.get(type)?.({
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
stopPropagation() {},
|
||||
...event,
|
||||
});
|
||||
},
|
||||
};
|
||||
return element;
|
||||
}
|
||||
|
||||
export function fakeHost() {
|
||||
const doc = { createElement: (tag) => fakeElement(tag) };
|
||||
const host = fakeElement('div');
|
||||
host.ownerDocument = doc;
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait, a macrotask at a time, until `condition` holds; false if it never
|
||||
* does within `ticks`. A test asserting that something did not happen waits
|
||||
* on a sign that the work is done instead of a fixed count of ticks.
|
||||
*/
|
||||
export async function until(condition, { ticks = 200 } = {}) {
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
if (await condition()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return Boolean(await condition());
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Panoramax stand-ins for tests and the hermetic QA gate: vector tiles in
|
||||
// the meta-catalogue's shape, and STAC items.
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
layerWriter,
|
||||
lineGeometry,
|
||||
zigzag,
|
||||
} from '../layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
const n = 2 ** z;
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
const tx = ((lon + 180) / 360) * n;
|
||||
const ty =
|
||||
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
|
||||
return [Math.round((tx - x) * extent), Math.round((ty - y) * extent)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a Panoramax `/api/map/{z}/{x}/{y}.mvt` tile from lon/lat features.
|
||||
* @param {{x: number, y: number, z: number}} tile
|
||||
* @param {{sequences?: Array<{id: string, date?: string, type?: string, parts: Array<Array<[number, number]>>}>, grid?: Array<{lon: number, lat: number, pano?: number, flat?: number}>, pictures?: Array<{id: string, lon: number, lat: number, sequenceId: string, heading?: number, ts?: string, type?: string}>, extent?: number}} [content]
|
||||
*/
|
||||
export function encodePanoramaxTile(
|
||||
tile,
|
||||
{ sequences = [], grid = [], pictures = [], extent = 4096 } = {},
|
||||
) {
|
||||
const pbf = new PbfWriter();
|
||||
if (sequences.length) {
|
||||
const layer = layerWriter('sequences', extent);
|
||||
for (const sequence of sequences)
|
||||
layer.add(
|
||||
2,
|
||||
lineGeometry(
|
||||
sequence.parts.map((part) =>
|
||||
part.map((point) => lonLatToTileLocal(point, tile, extent)),
|
||||
),
|
||||
),
|
||||
{
|
||||
id: sequence.id,
|
||||
type: sequence.type ?? 'flat',
|
||||
date: sequence.date ?? '2025-06-01',
|
||||
model: 'Test camera',
|
||||
},
|
||||
);
|
||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
||||
}
|
||||
const point = (lon, lat) => {
|
||||
const [px, py] = lonLatToTileLocal([lon, lat], tile, extent);
|
||||
return [9, zigzag(px), zigzag(py)];
|
||||
};
|
||||
if (grid.length) {
|
||||
const layer = layerWriter('grid', extent);
|
||||
for (const cell of grid) {
|
||||
const pano = cell.pano ?? 0;
|
||||
const flat = cell.flat ?? 1;
|
||||
layer.add(1, point(cell.lon, cell.lat), {
|
||||
nb_pictures: pano + flat,
|
||||
nb_360_pictures: pano,
|
||||
nb_flat_pictures: flat,
|
||||
});
|
||||
}
|
||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
||||
}
|
||||
if (pictures.length) {
|
||||
const layer = layerWriter('pictures', extent);
|
||||
for (const picture of pictures)
|
||||
layer.add(1, point(picture.lon, picture.lat), {
|
||||
id: picture.id,
|
||||
ts: picture.ts ?? '2025-06-01 10:00:00+00',
|
||||
heading: picture.heading ?? 90,
|
||||
sequences: JSON.stringify([picture.sequenceId]),
|
||||
first_sequence: picture.sequenceId,
|
||||
type: picture.type ?? 'flat',
|
||||
});
|
||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
||||
}
|
||||
return new Uint8Array(pbf.finish());
|
||||
}
|
||||
|
||||
/**
|
||||
* A STAC item as the meta-catalogue returns it.
|
||||
* @param {{id: string, lon: number, lat: number, sequenceId?: string, heading?: number, pano?: boolean, datetime?: string, producer?: string, license?: string, width?: number, prevId?: string|null, nextId?: string|null, host?: string}} options
|
||||
*/
|
||||
export function stacItem({
|
||||
id,
|
||||
lon,
|
||||
lat,
|
||||
sequenceId = 'seq-1',
|
||||
heading = 90,
|
||||
pano = false,
|
||||
datetime = '2025-06-01T10:00:00+00:00',
|
||||
producer = 'tester',
|
||||
license = 'CC-BY-SA-4.0',
|
||||
width = pano ? 5760 : 4032,
|
||||
prevId = null,
|
||||
nextId = null,
|
||||
host = 'https://panoramax.example.org',
|
||||
}) {
|
||||
const api = 'https://api.panoramax.xyz/api';
|
||||
const links = [
|
||||
{ rel: 'self', href: `${api}/collections/${sequenceId}/items/${id}` },
|
||||
];
|
||||
if (prevId)
|
||||
links.push({
|
||||
rel: 'prev',
|
||||
href: `${api}/collections/${sequenceId}/items/${prevId}`,
|
||||
});
|
||||
if (nextId)
|
||||
links.push({
|
||||
rel: 'next',
|
||||
href: `${api}/collections/${sequenceId}/items/${nextId}`,
|
||||
});
|
||||
return {
|
||||
type: 'Feature',
|
||||
id,
|
||||
collection: sequenceId,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
providers: [{ name: producer, roles: ['producer'] }],
|
||||
properties: {
|
||||
datetime,
|
||||
license,
|
||||
'view:azimuth': heading,
|
||||
'geovisio:producer': producer,
|
||||
'pers:interior_orientation': {
|
||||
...(pano ? { field_of_view: 360 } : {}),
|
||||
sensor_array_dimensions: [width, pano ? width / 2 : 3024],
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
hd: { href: `${host}/pictures/${id}/hd.jpg` },
|
||||
sd: { href: `${host}/pictures/${id}/sd.jpg` },
|
||||
thumb: { href: `${host}/pictures/${id}/thumb.jpg` },
|
||||
},
|
||||
links,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
/** 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';
|
||||
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;
|
||||
|
||||
@@ -31,6 +36,7 @@ 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 = [];
|
||||
@@ -65,6 +71,7 @@ export function fakeStreetLevelProvider({
|
||||
setFilter: (filter) => filters.push(filter),
|
||||
coverageStats: () => ({ ...stats }),
|
||||
handlePick: () => false,
|
||||
nearestImage,
|
||||
viewer: {
|
||||
mount: async () => calls.mount++,
|
||||
open: async (imageId) => calls.open.push(imageId),
|
||||
@@ -261,3 +268,92 @@ export function rayCamera({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user