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

@@ -0,0 +1,593 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
createLineCoverage,
freshCoverage,
ZOOM_IN_HINT,
} from './lineCoverage.js';
import { resolveFilter } from './filter.js';
import { encodeCoverageTile } from './providers/mapillary/coverageFixture.mjs';
import { decodeCoverageTile } from './providers/mapillary/decode.js';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
import {
COLORS,
COVERAGE_TUNING,
KEY_REJECTED_MESSAGE,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
} from './providers/mapillary/policy.js';
import {
coverageZoomForHeight,
lonToTileX,
latToTileY,
tileBounds,
} from './tileMath.js';
const { maxSequences: COVERAGE_MAX_SEQUENCES, maxTiles: COVERAGE_MAX_TILES } =
COVERAGE_TUNING;
/** Line coverage as the Mapillary provider builds it, over `source`. */
function createCoverage({ state, source }) {
return createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
decodeCoverageTile(
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
tile,
),
zoomFor: coverageZoomForHeight,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
renderKey: 'mapillary-coverage',
tuning: COVERAGE_TUNING,
messages: {
keyRejected: KEY_REJECTED_MESSAGE,
rateLimited: RATE_LIMITED_MESSAGE,
},
});
}
const RAD = Math.PI / 180;
// Node has no WebGL context to report line-width limits; a browser's
// context sets these on startup, and appearances validate against them.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A scene collection that remembers what is on the globe. */
function collection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/** The centre of the z14 tile holding a point, so a small view is one tile. */
function tileCentre(lon, lat) {
const x = lonToTileX(lon, 14);
const y = latToTileY(lat, 14);
const { west, east, south, north } = tileBounds(x, y, 14);
return {
tile: { x, y, z: 14 },
lon: (west + east) / 2,
lat: (south + north) / 2,
};
}
/** A viewer looking straight down on a 0.004° square around `view`. */
function fakeViewer(view) {
return {
view,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
groundPrimitives: collection(),
primitives: collection(),
},
camera: {
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,
);
},
},
};
}
/** A source whose tile requests the test resolves, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getTile(layer, z, x, y, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject });
});
},
};
}
function setup() {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource();
const state = {
viewer,
services: {},
enabled: true,
providerOn: true,
filter: { pano: 'all', sinceDays: 0 },
context: {
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && state.providerOn,
notify() {},
},
coverage: {
...freshCoverage(),
// The key status is in; skip Cesium's one-time terrain table download.
blocked: null,
terrainReady: Promise.resolve(),
},
sequence: { selectedId: null },
};
const coverage = createCoverage({ state, source });
const bytes = encodeCoverageTile(centre.tile, {
sequences: [
{
id: 'seq-1',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
},
],
});
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
const onGlobe = () =>
viewer.scene.groundPrimitives.items.size +
viewer.scene.primitives.items.size;
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
}
test('a superseded tile request cannot strand lines on the globe', async () => {
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
const forTile = () => source.calls.filter((call) => call.key === tileKey);
coverage.refresh(); // street zoom: request #1 for the tile
assert.equal(forTile().length, 1);
viewer.view.height = 100_000; // zoom out: clear() drops request #1
coverage.refresh();
viewer.view.height = 900; // back in before #1 settles: request #2
coverage.refresh();
assert.equal(forTile().length, 2);
await settle();
// The superseded request finishes late. It must not settle request #2's
// key, or the next refresh would start a duplicate load.
forTile()[0].resolve(bytes);
await settle();
assert.equal(
state.coverage.pending.has(tileKey),
true,
'#2 still owns the key',
);
assert.equal(state.coverage.pending.size, 1, 'still loading');
coverage.refresh();
assert.equal(forTile().length, 2, 'no duplicate request');
forTile()[1].resolve(bytes);
await settle();
assert.equal(state.coverage.tiles.size, 1);
assert.ok(onGlobe() > 0, 'the tile drew its lines');
// Everything drawn can be removed: nothing is orphaned.
coverage.clear();
assert.equal(onGlobe(), 0);
});
test('sequence lines are draped on the globe and on 3D tiles alike', async () => {
const { viewer, source, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const { groundPrimitives, primitives } = viewer.scene;
assert.equal(primitives.items.size, 0);
assert.equal(groundPrimitives.items.size, 1);
const [line] = groundPrimitives.items;
assert.ok(line instanceof Cesium.GroundPolylinePrimitive);
assert.equal(line.classificationType, Cesium.ClassificationType.BOTH);
coverage.clear();
assert.equal(groundPrimitives.items.size, 0);
});
test('panning off a tile that is still loading stops counting it as loading', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
assert.equal(state.coverage.pending.size, 1);
// Same zoom, two tiles east: the first request is dropped, a new one starts.
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 2);
assert.equal(source.calls[0].signal.aborted, true);
assert.equal(
state.coverage.pending.size,
1,
'only the live request counts as loading',
);
source.calls[0].reject(new DOMException('aborted', 'AbortError'));
source.calls[1].resolve(new Uint8Array());
await settle();
assert.equal(state.coverage.pending.size, 0, 'LOADING clears');
coverage.clear();
});
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines');
viewer.view.height = 20_000_000;
coverage.refresh();
assert.equal(source.calls.length, 1, 'no tile is asked for');
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn');
assert.equal(state.coverage.zoom, null);
assert.equal(coverage.stats().hint, ZOOM_IN_HINT);
viewer.view.height = 900;
coverage.refresh();
assert.equal(coverage.stats().hint, '', 'back at street zoom');
assert.equal(source.calls.length, 2);
coverage.clear();
});
test('the key status gates coverage: nothing before it answers, nothing without a key', () => {
for (const configured of [false, true]) {
const { source, state, coverage } = setup();
state.coverage.blocked = 'status';
coverage.refresh();
assert.equal(source.calls.length, 0, 'nothing before the status answers');
assert.equal(coverage.stats().loading, true, 'waiting reads as loading');
state.enabled = false;
assert.equal(coverage.stats().loading, false, 'but not while off');
state.enabled = true;
coverage.setKeyStatus(configured);
if (configured)
assert.equal(source.calls.length, 1, 'coverage loads once it is known');
else {
assert.equal(source.calls.length, 0, 'a key-less install never asks');
assert.equal(coverage.stats().loading, false);
assert.equal(coverage.stats().keyRequired, true);
assert.equal(coverage.stats().keyRejected, false);
coverage.unblock();
assert.equal(
coverage.stats().keyRequired,
true,
'kept: it is no refusal',
);
}
coverage.clear();
}
});
test('a rejected key stops coverage requests until the layer goes off', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
const rejected = Object.assign(
new Error('Mapillary rejected the access token'),
{
keyRejected: true,
},
);
source.calls[0].reject(rejected);
await settle();
assert.equal(state.coverage.blocked, 'rejected');
assert.equal(coverage.stats().keyRequired, true, 'gated like a missing key');
assert.equal(coverage.stats().keyRejected, true);
assert.match(coverage.stats().error, /rejected MAPILLARY_CLIENT_TOKEN/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
// Turning the layer off forgets the verdict; the next run asks again.
coverage.clear();
coverage.unblock();
assert.equal(coverage.stats().keyRejected, false);
assert.equal(coverage.stats().error, null);
coverage.refresh();
assert.equal(source.calls.length, 2);
coverage.clear();
});
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, state, coverage } = setup();
coverage.refresh();
source.calls[0].reject(
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.match(coverage.stats().error, /rate-limited/);
assert.equal(coverage.stats().keyRequired, false);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held: nothing is requested');
t.mock.timers.tick(30_000);
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
assert.equal(coverage.stats().error, null);
coverage.clear();
coverage.unblock();
});
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
const { state, source, coverage, centre } = setup();
const line = (n) => [
[
[centre.lon - 0.001, centre.lat + n * 1e-6],
[centre.lon + 0.001, centre.lat + n * 1e-6],
],
];
// 700 newer flat sequences, then 300 older 360° ones.
const sequences = [];
for (let i = 0; i < 700; i++)
sequences.push({
id: `flat-${i}`,
parts: line(i),
capturedAt: 2_000_000_000_000 + i,
});
for (let i = 0; i < 300; i++)
sequences.push({
id: `pano-${i}`,
parts: line(i),
capturedAt: 1_000_000_000_000 + i,
isPano: true,
});
state.filter = { pano: 'pano', sinceDays: 0 };
coverage.refresh();
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
await settle();
const [entry] = state.coverage.tiles.values();
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
assert.equal(
entry.sequences.size,
Math.min(300, cap),
'every 360° line is drawn',
);
assert.equal(coverage.stats().count, Math.min(300, cap));
// Picking and the highlight look sequences up among the drawn lines.
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false);
state.filter = { pano: 'all', sinceDays: 0 };
coverage.rebuild();
assert.equal(entry.sequences.size, cap, 'all imagery is capped as before');
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
coverage.clear();
});
test('a sequence that crosses a tile edge is counted once', async () => {
const { viewer, source, coverage, centre } = setup();
// Look straight down on the edge between this tile and its east neighbour.
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
viewer.view.lon = east;
coverage.refresh();
assert.equal(source.calls.length, 2, 'two tiles in view');
const across = [
[
[east - 0.001, centre.lat],
[east + 0.001, centre.lat],
],
];
for (const call of source.calls) {
const [, x, y] = call.key.split('/').map(Number);
call.resolve(
encodeCoverageTile(
{ x, y, z: 14 },
{
sequences: [
{ id: 'across', parts: across },
{ id: `only-${x}`, parts: across },
],
},
),
);
}
await settle();
assert.equal(coverage.stats().count, 3, 'across, plus one per tile');
coverage.clear();
});
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
function tiltedCamera({ lon, lat, height, heading, pitch }) {
return rayCamera({
lon,
lat,
altitude: height,
heading,
pitch,
width: 100,
height: 100,
fovX: 60,
fovY: 40,
});
}
/**
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
* at ground `aheadM` metres out; the globe terrain knows the ground.
*/
function denverStreetView(aheadM) {
const z = 14;
const x = lonToTileX(-104.99, z);
const y = latToTileY(39.74, z);
const tile = tileBounds(x, y, z);
const lon = (tile.west + tile.east) / 2;
const lat = (tile.south + tile.north) / 2;
const ground = 1600;
const above = 300;
const context = setup();
context.viewer.scene.globe = {
show: true,
ellipsoid: Cesium.Ellipsoid.WGS84,
getHeight: () => ground,
};
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
context.viewer.camera = tiltedCamera({
lon,
lat,
height: ground + above,
heading: 90,
pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD,
});
context.coverage.refresh();
const keys = context.source.calls.map((call) => call.key);
/** Requested tiles as [dx, dy] from the tile under the camera. */
const offsets = keys.map((k) => {
const [, tx, ty] = k.split('/').map(Number);
return [tx - x, ty - y];
});
return {
...context,
keys,
offsets,
key: (dx, dy) => `${z}/${x + dx}/${y + dy}`,
};
}
test('a tilted street view over ground 1,600 m up asks for the ground it looks at, nearest the camera first', () => {
// The screen centre meets the ground at the centre of the next tile east.
const { state, coverage, keys, offsets, key } = denverStreetView(
(tileWidthM) => tileWidthM,
);
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
assert.ok(keys.includes(key(1, 0)), 'the tile the screen centre looks at');
for (const [dx, dy] of offsets)
assert.ok(dx >= 0 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in view`);
coverage.clear();
});
test('a street view toward the horizon asks only for the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range.
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
for (const [dx, dy] of offsets)
assert.ok(dx >= 0 && dx <= 1 && Math.abs(dy) <= 1, `${dx},${dy} near it`);
coverage.clear();
});
test('the selected sequence is drawn over the coverage, and goes with the selection', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const lines = () => [...viewer.scene.groundPrimitives.items];
assert.equal(lines().length, 1, 'the coverage');
coverage.highlight('seq-1');
assert.equal(lines().length, 2);
const highlight = lines().at(-1);
assert.equal(state.coverage.highlight, highlight, 'drawn last, on top');
assert.equal(highlight.classificationType, Cesium.ClassificationType.BOTH);
coverage.highlight('not-drawn');
assert.equal(lines().length, 1, 'nothing to highlight off the map');
coverage.highlight(null);
assert.equal(state.coverage.highlight, null);
coverage.clear();
assert.equal(lines().length, 0);
});
test('a tile that lands with the selected sequence draws the highlight again, on top', async () => {
const { viewer, source, state, coverage, bytes } = setup();
state.sequence.selectedId = 'seq-1';
coverage.refresh();
assert.equal(state.coverage.highlight, null, 'nothing loaded yet');
source.calls[0].resolve(bytes);
await settle();
assert.ok(state.coverage.highlight, 'highlighted once its tile is in');
assert.equal(
[...viewer.scene.groundPrimitives.items].at(-1),
state.coverage.highlight,
);
coverage.clear();
});
test("a zoom change drops the old zoom's lines", async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [old] = viewer.scene.groundPrimitives.items;
assert.ok(old, 'the z14 lines are drawn');
viewer.view.height = 3000; // zoom out to z13
coverage.refresh();
assert.equal(state.coverage.zoom, 13);
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
assert.ok(state.coverage.pending.size > 0, 'the new zoom is loading');
coverage.clear();
});
test('a rate limit holds every refresh inside its wait, not only the first', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, coverage } = setup();
coverage.refresh();
source.calls[0].reject(
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait');
t.mock.timers.tick(28_000);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait');
t.mock.timers.tick(1000);
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
coverage.clear();
coverage.unblock();
});
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
const { source, state, coverage, tileKey } = setup();
let terrainLoaded;
state.coverage.terrainReady = new Promise((resolve) => {
terrainLoaded = resolve;
});
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
coverage.refresh();
source.calls
.find((call) => call.key === tileKey)
.reject(new Error('Mapillary tiles HTTP 500'));
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500');
assert.deepEqual(unhandled, []);
} finally {
terrainLoaded();
await settle();
process.off('unhandledRejection', onUnhandled);
}
});