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:
German KhokhlovandClaude Opus 5.5 committed 2026-10-19 13:51:45 +04:00
commit 6390a3346f
809 files changed
+81825 -6035

No files matched your search

+194
View File
@@ -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;
}
+81
View File
@@ -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());
}
+138
View File
@@ -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,
};
}
+97 -1
View File
@@ -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,
},
};
}