mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix(street-level): sequence loads, playback and nearest-photo edge cases
From gekh's review of #768, each bug with a test that fails before: - P2: a failed sequence load clears the previous sequence's cones, resets the selection and can be retried; a second click while loading starts no second lookup. sequences.js had no tests: sequences.test.mjs now covers failure and retry, a superseded late answer, the cache and clearSelection. - P2: closing a photo stops MapillaryJS sequence playback, which kept the hidden viewer stepping and downloading images. - openNearest without the panel reports it instead of loading forever, and stops if the layer (or its provider) went off during the lookup; the mesh sampler's misses are bounded and forgotten with its samples; the sequence count is the union of ids across tiles; a provider withdraws only its own error, so a sequence error cannot mask KEY REJECTED; hover stays on (throttled) under an orbiting camera and re-picks when it stops; "since N days" follows the clock in a long-open tab. - A prewarm waits for one in flight and then builds if it gave up. - New coverage for the layer core (credits, KEY REJECTED vs REQUIRED, switching a provider off), the refresh wiring of the tilted-view fix and the draped-to-cast swap completion. - Cleanup: loading is derived from pending; freshStreet(); tile ranking computes distances once and shares one path for the date line; shared metresBetween / whenIdle / redrawTile; one NEAREST_RADIUS_M and one source method list; unused contract fields, exports, getImage and the decoder's image branch removed; PROVIDER_COLORS lists only Mapillary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
48276450ec
commit
5f98b871e8
31 files changed
+1129
-470
No files matched your search
@@ -1,7 +1,13 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js';
|
||||
import {
|
||||
groundUnderCamera,
|
||||
metresBetween,
|
||||
viewFocus,
|
||||
visibleBbox,
|
||||
whenIdle,
|
||||
} from '../../view.js';
|
||||
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
|
||||
import { MESH_CELL_DEG } from '../../meshSampler.js';
|
||||
import {
|
||||
@@ -27,7 +33,7 @@ import {
|
||||
} from './policy.js';
|
||||
|
||||
/** Draped lines stay at most this long while a tile's cast lines build. */
|
||||
const SWAP_MAX_WAIT_MS = 4000;
|
||||
export const SWAP_MAX_WAIT_MS = 4000;
|
||||
/** Tiles touched by new mesh samples are redrawn at most this often. */
|
||||
const REMESH_INTERVAL_MS = 1500;
|
||||
/** Gap between redrawing one dirty tile and the next. */
|
||||
@@ -104,8 +110,9 @@ export function createCoverage({ state, source }) {
|
||||
return `${z}/${x}/${y}`;
|
||||
}
|
||||
|
||||
/** The imagery filter, resolved now: "since N days" moves with the clock. */
|
||||
function filter() {
|
||||
return state.filter;
|
||||
return state.context.getFilter();
|
||||
}
|
||||
|
||||
function terrainMode() {
|
||||
@@ -122,9 +129,10 @@ export function createCoverage({ state, source }) {
|
||||
*/
|
||||
function drawnSequences(entry) {
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
for (const sequence of entry.sequenceList) {
|
||||
if (drawn.length >= PER_TILE_SEQUENCE_CAP) break;
|
||||
if (passesImageryFilter(sequence, filter())) drawn.push(sequence);
|
||||
if (passesImageryFilter(sequence, current)) drawn.push(sequence);
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
@@ -232,15 +240,22 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
// A remesh may have cleared the flag while this was in flight.
|
||||
entry.castRequested = true;
|
||||
// Keep the draped lines until the cast ones are built, so nothing blinks.
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
attachPrimitive(entry);
|
||||
removeWhenReady(entry, previous);
|
||||
redrawTile(entry);
|
||||
requestMesh(entry);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a tile's lines (cast, or with new mesh heights), keeping the old
|
||||
* ones on screen until the new ones are built, so nothing blinks.
|
||||
*/
|
||||
function redrawTile(entry) {
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
attachPrimitive(entry);
|
||||
removeWhenReady(entry, previous);
|
||||
}
|
||||
|
||||
/** The mesh cells under a tile's drawn lines, built once per filter. */
|
||||
function meshCells(entry) {
|
||||
if (entry.meshCells) return entry.meshCells;
|
||||
@@ -302,25 +317,16 @@ export function createCoverage({ state, source }) {
|
||||
if (!entry) return;
|
||||
state.coverage.remeshDirty.delete(entry);
|
||||
if (attached.has(entry)) {
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
attachPrimitive(entry);
|
||||
removeWhenReady(entry, previous);
|
||||
redrawTile(entry);
|
||||
requestRender();
|
||||
}
|
||||
if (state.coverage.remeshDirty.size)
|
||||
state.coverage.remeshTimer = setTimeout(
|
||||
() => idleTask(remesh),
|
||||
() => whenIdle(remesh, 500),
|
||||
REMESH_STAGGER_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function idleTask(task) {
|
||||
if (typeof globalThis.requestIdleCallback === 'function')
|
||||
globalThis.requestIdleCallback(() => task(), { timeout: 500 });
|
||||
else task();
|
||||
}
|
||||
|
||||
state.coverage.remeshDirty = new Set();
|
||||
state.context.meshSampler?.onSampled(onMeshSampled);
|
||||
|
||||
@@ -367,8 +373,9 @@ export function createCoverage({ state, source }) {
|
||||
0.85,
|
||||
);
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
for (const point of points) {
|
||||
if (!passesImageryFilter(point, filter())) continue;
|
||||
if (!passesImageryFilter(point, current)) continue;
|
||||
drawn.push(
|
||||
collection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||||
@@ -513,7 +520,6 @@ export function createCoverage({ state, source }) {
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
state.coverage.pending.set(key, controller);
|
||||
state.coverage.loading++;
|
||||
notify();
|
||||
try {
|
||||
// Fetch and the one-time terrain-height table load run side by side.
|
||||
@@ -578,10 +584,9 @@ export function createCoverage({ state, source }) {
|
||||
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
|
||||
} finally {
|
||||
// Only the request that still owns the key settles it: a superseded one
|
||||
// must not drop a newer request's entry or its loading count.
|
||||
// must not drop a newer request's entry (which is what counts as loading).
|
||||
if (state.coverage.pending.get(key) === controller) {
|
||||
state.coverage.pending.delete(key);
|
||||
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
}
|
||||
notify();
|
||||
@@ -632,7 +637,6 @@ export function createCoverage({ state, source }) {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
state.coverage.loading = 0;
|
||||
for (const [key, entry] of state.coverage.tiles) {
|
||||
cancelCast(entry);
|
||||
const previous = state.coverage.stale.get(key);
|
||||
@@ -654,10 +658,7 @@ export function createCoverage({ state, source }) {
|
||||
*/
|
||||
function isTilted({ nadir, ahead }, height) {
|
||||
if (!ahead) return true;
|
||||
const dLat = (ahead.lat - nadir.lat) * 111_320;
|
||||
const dLon =
|
||||
(ahead.lon - nadir.lon) * 111_320 * Math.cos((nadir.lat * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon) > Math.max(1, height);
|
||||
return metresBetween(nadir, ahead) > Math.max(1, height);
|
||||
}
|
||||
|
||||
/** Recompute the tile set for the current camera and reconcile primitives. */
|
||||
@@ -723,10 +724,9 @@ export function createCoverage({ state, source }) {
|
||||
for (const [key, controller] of [...state.coverage.pending])
|
||||
if (!wanted.has(key)) {
|
||||
// The aborted request no longer owns its key, so its `finally` will
|
||||
// not settle the count: settle it here.
|
||||
// not settle it: settle it here.
|
||||
controller.abort();
|
||||
state.coverage.pending.delete(key);
|
||||
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
|
||||
}
|
||||
for (const tile of tiles) loadTile(tile, kind);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
@@ -769,7 +769,6 @@ export function createCoverage({ state, source }) {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
state.coverage.loading = 0;
|
||||
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
||||
purgeStale();
|
||||
state.coverage.zoom = null;
|
||||
@@ -807,15 +806,6 @@ export function createCoverage({ state, source }) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Look a sequence up across loaded tiles. */
|
||||
function findSequence(id) {
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
const hit = entry.sequences.get(id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recolour every part of a sequence in one ready primitive. */
|
||||
function recolorInstances(primitive, sequence, selected) {
|
||||
const value = sequenceColor({ selected });
|
||||
@@ -893,10 +883,18 @@ export function createCoverage({ state, source }) {
|
||||
state.coverage.stopSelectionWatch = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequences (or overview points) drawn. A sequence that crosses a tile
|
||||
* edge is in both tiles' lists, so sequences count once by id.
|
||||
*/
|
||||
function sequenceCount() {
|
||||
let count = 0;
|
||||
for (const entry of state.coverage.tiles.values()) count += entry.count;
|
||||
return count;
|
||||
const ids = new Set();
|
||||
let points = 0;
|
||||
for (const entry of state.coverage.tiles.values())
|
||||
if (entry.kind === 'sequence')
|
||||
for (const id of entry.sequences.keys()) ids.add(id);
|
||||
else points += entry.count;
|
||||
return ids.size + points;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -907,7 +905,6 @@ export function createCoverage({ state, source }) {
|
||||
resetErrors,
|
||||
rebuild,
|
||||
setSurface,
|
||||
findSequence,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCoverage } from './coverage.js';
|
||||
import { SWAP_MAX_WAIT_MS, createCoverage } from './coverage.js';
|
||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||
import {
|
||||
COLORS,
|
||||
@@ -114,9 +114,10 @@ function setup({
|
||||
const state = {
|
||||
viewer,
|
||||
services: {},
|
||||
filter: { pano: 'all', sinceMs: null },
|
||||
keyRequired: false,
|
||||
context: {
|
||||
filter: { pano: 'all', sinceMs: null },
|
||||
getFilter: () => state.context.filter,
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
getSurface: () => surface,
|
||||
@@ -130,7 +131,6 @@ function setup({
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
loading: 0,
|
||||
lastError: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
@@ -188,7 +188,7 @@ test('a superseded tile request cannot strand lines on the globe (review P0 #2)'
|
||||
true,
|
||||
'#2 still owns the key',
|
||||
);
|
||||
assert.equal(state.coverage.loading, 1);
|
||||
assert.equal(state.coverage.pending.size, 1, 'still loading');
|
||||
coverage.refresh();
|
||||
assert.equal(forTile().length, 2, 'no duplicate request');
|
||||
|
||||
@@ -265,26 +265,24 @@ test('a tile whose lines cannot be cast is not rebuilt for nothing', async () =>
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('panning off a tile that is still loading settles the loading count', async () => {
|
||||
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.loading, 1);
|
||||
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);
|
||||
assert.equal(
|
||||
state.coverage.loading,
|
||||
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);
|
||||
assert.equal(state.coverage.loading, 0, 'LOADING clears');
|
||||
assert.equal(state.coverage.pending.size, 0, 'LOADING clears');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
@@ -337,7 +335,7 @@ test('a rate limit keeps the drawn tiles and asks again once the wait is over (r
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.match(state.coverage.lastError, /rate-limiting/);
|
||||
assert.match(state.coverage.lastError, /rate-limited/);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held: nothing is requested');
|
||||
@@ -494,7 +492,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
|
||||
capturedAt: 1_000_000_000_000 + i,
|
||||
isPano: true,
|
||||
});
|
||||
state.filter = { pano: 'pano', sinceMs: null };
|
||||
state.context.filter = { pano: 'pano', sinceMs: null };
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
|
||||
await settle();
|
||||
@@ -502,13 +500,14 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
|
||||
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
|
||||
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
|
||||
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
|
||||
assert.ok(coverage.findSequence('pano-0'), 'drawn lines can be looked up');
|
||||
assert.equal(coverage.findSequence('flat-699'), null);
|
||||
state.filter = { pano: 'all', sinceMs: null };
|
||||
// Picking and recolouring 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.context.filter = { pano: 'all', sinceMs: null };
|
||||
coverage.rebuild();
|
||||
assert.equal(entry.count, cap, 'all imagery is capped as before');
|
||||
assert.ok(coverage.findSequence('flat-699'), 'the newest flat line is drawn');
|
||||
assert.equal(coverage.findSequence('pano-299'), null, 'over the cap');
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -558,3 +557,199 @@ test('a selection made or cleared while a tile builds is applied once it is read
|
||||
coverage.clear();
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('a sequence that crosses a tile edge is counted once (review P3)', 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.sequenceCount(), 3, 'across, plus one per tile');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* In terrain mode with a cast tile mid-swap: the draped lines and the cast
|
||||
* primitive whose readiness the test controls.
|
||||
*/
|
||||
async function midSwap() {
|
||||
// Drawn draped first; castLine only answers once the heights are in.
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => (heightsReady = true),
|
||||
castLine: (coords) =>
|
||||
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
|
||||
};
|
||||
const context = setup({ surface: 'terrain', groundCaster });
|
||||
context.coverage.refresh();
|
||||
context.source.calls[0].resolve(context.bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
const { groundPrimitives, primitives } = context.viewer.scene;
|
||||
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
|
||||
assert.equal(primitives.items.size, 1, 'cast lines building');
|
||||
const [cast] = primitives.items;
|
||||
const control = { ready: false };
|
||||
Object.defineProperty(cast, 'ready', { get: () => control.ready });
|
||||
return { ...context, control };
|
||||
}
|
||||
|
||||
test('the draped lines go once the cast lines are ready', async () => {
|
||||
const { viewer, coverage, control } = await midSwap();
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building');
|
||||
control.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
|
||||
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay');
|
||||
assert.equal(viewer.postRender.size, 0, 'nothing left watching');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('the draped lines go after the wait even if the cast lines never get ready', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'] });
|
||||
const { viewer, coverage } = await midSwap();
|
||||
t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1);
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting');
|
||||
t.mock.timers.tick(2);
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
|
||||
assert.equal(viewer.scene.primitives.items.size, 1);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* A camera `height` m above WGS84 looking along `heading` with `pitch` (deg),
|
||||
* a 60°×40° view on a 100×100 canvas, whose screen rays really meet the
|
||||
* ellipsoid they are given (as Cesium's `pickEllipsoid` does).
|
||||
*/
|
||||
function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||
const frameAt = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
||||
function ray(point) {
|
||||
const h = (heading + (point.x / 100 - 0.5) * 60) * RAD;
|
||||
const p = (pitch + (0.5 - point.y / 100) * 40) * RAD;
|
||||
const local = new Cesium.Cartesian3(
|
||||
Math.sin(h) * Math.cos(p),
|
||||
Math.cos(h) * Math.cos(p),
|
||||
Math.sin(p),
|
||||
);
|
||||
const direction = Cesium.Matrix4.multiplyByPointAsVector(
|
||||
frameAt,
|
||||
local,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return new Cesium.Ray(
|
||||
position,
|
||||
Cesium.Cartesian3.normalize(direction, direction),
|
||||
);
|
||||
}
|
||||
return {
|
||||
positionWC: position,
|
||||
positionCartographic: {
|
||||
longitude: lon * RAD,
|
||||
latitude: lat * RAD,
|
||||
height,
|
||||
},
|
||||
pickEllipsoid(point, ellipsoid) {
|
||||
const r = ray(point);
|
||||
const hit = Cesium.IntersectionTests.rayEllipsoid(r, ellipsoid);
|
||||
return hit ? Cesium.Ray.getPoint(r, hit.start) : undefined;
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Denver, 300 m above a street 1,600 m above the ellipsoid, at the centre of
|
||||
* a z14 tile and looking east with the screen centre `aheadM` metres out.
|
||||
* Google 3D hides the globe, so only the bare-earth caster 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({ groundCaster: { groundAt: () => 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 tiles it looks at 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.deepEqual(
|
||||
keys.slice(0, 2),
|
||||
[key(0, 0), key(1, 0)],
|
||||
'the tile under the camera, then the one the screen centre looks at',
|
||||
);
|
||||
assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too');
|
||||
for (const [dx, dy] of offsets)
|
||||
assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a street view toward the horizon ranks only the ground within range', () => {
|
||||
// The screen centre meets the ground 6 km out, past the 3 km range: the
|
||||
// tiles are ranked around the camera, not along a line to the horizon.
|
||||
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
|
||||
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
|
||||
// Behind and ahead are equally near the camera (ties keep row order); a
|
||||
// line of sight to the far ground would rank the tile ahead first.
|
||||
assert.ok(
|
||||
keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)),
|
||||
'the tile ahead is not pulled forward',
|
||||
);
|
||||
for (const [dx, dy] of offsets)
|
||||
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
|
||||
coverage.clear();
|
||||
});
|
||||
@@ -4,15 +4,15 @@ import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
|
||||
/**
|
||||
* Decode a Mapillary coverage tile (`mly1_public`) in the browser.
|
||||
* Sequences are LineStrings with capture metadata; the `image` layer is only
|
||||
* present at z14 and is skipped unless asked for (it can hold >150k points).
|
||||
* Sequences are LineStrings with capture metadata; z0–5 tiles carry
|
||||
* `overview` points instead. The z14 `image` layer (it can hold >150k
|
||||
* points) is never read.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x:number,y:number,z:number}} address
|
||||
* @param {{images?: boolean}} [options]
|
||||
* @returns {{sequences: Array<object>, images: Array<object>}}
|
||||
* @returns {{sequences: Array<object>, overview: Array<object>}}
|
||||
*/
|
||||
export function decodeCoverageTile(bytes, address, { images = false } = {}) {
|
||||
const result = { sequences: [], images: [], overview: [] };
|
||||
export function decodeCoverageTile(bytes, address) {
|
||||
const result = { sequences: [], overview: [] };
|
||||
if (!bytes || !bytes.length) return result;
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const { x, y, z } = address;
|
||||
@@ -70,31 +70,5 @@ export function decodeCoverageTile(bytes, address, { images = false } = {}) {
|
||||
});
|
||||
}
|
||||
}
|
||||
const imageLayer = images ? tile.layers.image : null;
|
||||
if (imageLayer) {
|
||||
for (let i = 0; i < imageLayer.length; i++) {
|
||||
const feature = imageLayer.feature(i);
|
||||
const props = feature.properties || {};
|
||||
const point = feature.loadGeometry()?.[0]?.[0];
|
||||
if (!point) continue;
|
||||
const [lon, lat] = tileLocalToLonLat(
|
||||
point.x,
|
||||
point.y,
|
||||
imageLayer.extent,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
);
|
||||
result.images.push({
|
||||
id: String(props.id ?? feature.id),
|
||||
lon,
|
||||
lat,
|
||||
compassAngle: Number(props.compass_angle) || 0,
|
||||
capturedAt: Number(props.captured_at) || 0,
|
||||
isPano: props.is_pano === true,
|
||||
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -113,7 +113,6 @@ test('sequences and overview points decode into lon/lat records', () => {
|
||||
assert.equal(decoded.overview[0].id, '3');
|
||||
assert.equal(decoded.overview[0].isPano, false);
|
||||
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
|
||||
assert.equal(decoded.images.length, 0, 'image layer skipped by default');
|
||||
});
|
||||
|
||||
test('an empty tile decodes to empty lists', () => {
|
||||
@@ -121,7 +120,6 @@ test('an empty tile decodes to empty lists', () => {
|
||||
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
|
||||
{
|
||||
sequences: [],
|
||||
images: [],
|
||||
overview: [],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createCoverage, sequenceIdFromPick } from './coverage.js';
|
||||
import { createSequences } from './sequences.js';
|
||||
import { createMapillaryViewer } from './viewer.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import {
|
||||
COLORS,
|
||||
MAPILLARY_CREDIT_HTML,
|
||||
@@ -9,22 +10,14 @@ import {
|
||||
MAPILLARY_LABEL,
|
||||
MAPILLARY_NAME,
|
||||
MAPILLARY_PROVIDER_ID,
|
||||
MAPILLARY_SOURCE_METHODS,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
PICK_PREFIX,
|
||||
mapillaryImageUrl,
|
||||
} from './policy.js';
|
||||
|
||||
export { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
|
||||
|
||||
const SOURCE_METHODS = [
|
||||
'getStatus',
|
||||
'getTile',
|
||||
'getImage',
|
||||
'getSequenceImages',
|
||||
'nearestImages',
|
||||
];
|
||||
|
||||
/** Per-provider mutable state, created once per `create()`. */
|
||||
function createProviderState(context) {
|
||||
return {
|
||||
@@ -35,7 +28,6 @@ function createProviderState(context) {
|
||||
/** Mapillary refused the configured token: nothing is requested until re-enabled. */
|
||||
keyRejected: false,
|
||||
status: null,
|
||||
filter: context.getFilter(),
|
||||
coverage: {
|
||||
zoom: null,
|
||||
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */
|
||||
@@ -43,8 +35,8 @@ function createProviderState(context) {
|
||||
/** Tiles from the previous zoom, kept on screen until replacements land. */
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
/** Tile key → its request's controller; any entry means LOADING. */
|
||||
pending: new Map(),
|
||||
loading: 0,
|
||||
lastError: null,
|
||||
/** While Mapillary rate-limits: no requests before this time (ms). */
|
||||
holdUntil: 0,
|
||||
@@ -74,7 +66,11 @@ function createProviderState(context) {
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createMapillaryProvider({ source }) {
|
||||
if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function'))
|
||||
if (
|
||||
!MAPILLARY_SOURCE_METHODS.every(
|
||||
(method) => typeof source?.[method] === 'function',
|
||||
)
|
||||
)
|
||||
throw new TypeError('A Mapillary source is required');
|
||||
|
||||
return Object.freeze({
|
||||
@@ -84,19 +80,7 @@ export function createMapillaryProvider({ source }) {
|
||||
requiresKeyId: MAPILLARY_KEY_ID,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({
|
||||
key: MAPILLARY_PROVIDER_ID,
|
||||
html: MAPILLARY_CREDIT_HTML,
|
||||
}),
|
||||
capabilities: Object.freeze({
|
||||
coverage: 'tiles',
|
||||
sequences: true,
|
||||
pano: true,
|
||||
capturedAt: true,
|
||||
creator: true,
|
||||
follow: true,
|
||||
}),
|
||||
externalUrl: mapillaryImageUrl,
|
||||
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
|
||||
|
||||
create(context) {
|
||||
const state = createProviderState(context);
|
||||
@@ -150,8 +134,8 @@ export function createMapillaryProvider({ source }) {
|
||||
|
||||
refreshCoverage: () => parts.coverage.refresh(),
|
||||
|
||||
setFilter(filter) {
|
||||
state.filter = filter;
|
||||
/** The filter changed: redraw. Each draw reads it from the context. */
|
||||
setFilter() {
|
||||
parts.coverage.rebuild();
|
||||
parts.sequences.rerender();
|
||||
},
|
||||
@@ -167,7 +151,7 @@ export function createMapillaryProvider({ source }) {
|
||||
count: parts.coverage.sequenceCount(),
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading: state.coverage.loading > 0,
|
||||
loading: state.coverage.pending.size > 0,
|
||||
hint: state.coverage.hint,
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: state.keyRequired,
|
||||
@@ -208,19 +192,18 @@ export function createMapillaryProvider({ source }) {
|
||||
const metres = (record) => {
|
||||
const [lon2, lat2] = (record.computed_geometry || record.geometry)
|
||||
?.coordinates || [NaN, NaN];
|
||||
const dx = (lon2 - lon) * 111_320 * Math.cos((lat * Math.PI) / 180);
|
||||
const dy = (lat2 - lat) * 110_540;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
|
||||
return Number.isFinite(d) ? d : Infinity;
|
||||
};
|
||||
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
|
||||
const filter = context.getFilter();
|
||||
const hit = byDistance.find((record) =>
|
||||
passesImageryFilter(
|
||||
{
|
||||
isPano: record.is_pano === true,
|
||||
capturedAt: Number(record.captured_at) || 0,
|
||||
},
|
||||
state.filter,
|
||||
filter,
|
||||
),
|
||||
);
|
||||
return hit ? String(hit.id) : null;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for the Mapillary street-level provider. */
|
||||
export const MAPILLARY_PROVIDER_ID = 'mapillary';
|
||||
export const MAPILLARY_NAME = 'Mapillary';
|
||||
@@ -7,6 +9,14 @@ export const MAPILLARY_LABEL = 'MAPILLARY';
|
||||
export const MAPILLARY_KEY_ID = 'mapillary';
|
||||
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
||||
|
||||
/** Methods a source (./source.js or a stand-in) must have for the provider. */
|
||||
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
||||
'getStatus',
|
||||
'getTile',
|
||||
'getSequenceImages',
|
||||
'nearestImages',
|
||||
]);
|
||||
|
||||
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
|
||||
export const PICK_PREFIX = Object.freeze({
|
||||
root: 'mly:',
|
||||
@@ -33,7 +43,7 @@ export const KEY_REJECTED_MESSAGE =
|
||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
||||
/** Shown while Mapillary rate-limits tile requests (429). */
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'Mapillary is rate-limiting coverage requests — it resumes on its own';
|
||||
'Coverage requests are being rate-limited — they resume on their own';
|
||||
/**
|
||||
* How far from the camera a street-zoom view may reach for coverage: a tilted
|
||||
* view sees to the horizon, but only the ground within this range is ranked
|
||||
@@ -52,8 +62,7 @@ export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||
export const IMAGE_CONE_SIZE_PX = 26;
|
||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
|
||||
/** Nearest-image search when the user asks to look at a place. */
|
||||
export const NEAREST_RADIUS_M = 50;
|
||||
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
|
||||
export const NEAREST_LIMIT = 8;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,13 @@ import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
|
||||
import { sequenceIdFromPick } from './coverage.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
import { resolveFilter } from '../../filter.js';
|
||||
|
||||
function fakeSource({ nearest = [] } = {}) {
|
||||
return {
|
||||
hasToken: () => true,
|
||||
getStatus: async () => ({ configured: true }),
|
||||
getTile: async () => new Uint8Array(0),
|
||||
getImage: async () => ({}),
|
||||
getSequenceImages: async () => [],
|
||||
nearestImages: async () => nearest,
|
||||
};
|
||||
@@ -21,8 +21,7 @@ function fakeContext(filter = { pano: 'all', sinceMs: null }) {
|
||||
return {
|
||||
opened,
|
||||
services: {},
|
||||
getViewer: () => null,
|
||||
getFilter: () => filter,
|
||||
getFilter: typeof filter === 'function' ? filter : () => filter,
|
||||
isActive: () => true,
|
||||
notify: () => {},
|
||||
actions: {
|
||||
@@ -41,7 +40,6 @@ test('the definition satisfies the Street Level provider contract', () => {
|
||||
assert.equal(def.label, 'MAPILLARY');
|
||||
assert.equal(def.requiresKeyId, 'mapillary');
|
||||
assert.equal(def.pickPrefix, 'mly:');
|
||||
assert.equal(def.capabilities.coverage, 'tiles');
|
||||
assert.match(def.credit.html, /CC BY-SA 4\.0/);
|
||||
// One colour per source: Mapillary is green everywhere it draws.
|
||||
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
|
||||
@@ -51,9 +49,8 @@ test('the definition satisfies the Street Level provider contract', () => {
|
||||
});
|
||||
|
||||
test('image deep links match the mapillary.com share format', () => {
|
||||
const def = createMapillaryProvider({ source: fakeSource() });
|
||||
assert.equal(
|
||||
def.externalUrl(1814275685699406),
|
||||
mapillaryImageUrl(1814275685699406),
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
);
|
||||
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||
@@ -118,3 +115,23 @@ test('nearestImage picks the closest image, not the first the API returned', asy
|
||||
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
|
||||
});
|
||||
|
||||
test('a "since N days" window follows the clock in a long-open tab (review P3)', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
|
||||
const day = 86_400_000;
|
||||
const capturedAt = Date.now() - day / 2;
|
||||
const source = fakeSource({
|
||||
nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }],
|
||||
});
|
||||
// The core's context resolves the stored filter whenever it is asked.
|
||||
const instance = createMapillaryProvider({ source }).create(
|
||||
fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })),
|
||||
);
|
||||
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent');
|
||||
t.mock.timers.tick(2 * day);
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 1, lon: 2 }),
|
||||
null,
|
||||
'two days on, the image is older than the window',
|
||||
);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
|
||||
import { imageConeGlyph } from '../../glyphs.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { refineHeights } from '../../groundCast.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import {
|
||||
COLORS,
|
||||
IMAGE_CONE_MIN_SPACING_M,
|
||||
@@ -14,14 +15,6 @@ const SPRITE_ID = 'street-level:mapillary-cones';
|
||||
/** How many recently viewed sequences keep their image list in memory. */
|
||||
const SEQUENCE_CACHE_SIZE = 40;
|
||||
|
||||
/** Approximate metres between two lon/lat points (small distances). */
|
||||
function metresBetween(a, b) {
|
||||
const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180);
|
||||
const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat);
|
||||
const dy = (b.lat - a.lat) * 110_540;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
/** Normalize a graph image record into the shape the cones use. */
|
||||
export function normalizeSequenceImage(record) {
|
||||
const coordinates = record?.geometry?.coordinates;
|
||||
@@ -119,8 +112,10 @@ export function createSequences({ state, source, parts }) {
|
||||
color: COLORS.coverage,
|
||||
pano: true,
|
||||
});
|
||||
// Resolved now, so a "since N days" window keeps up with the clock.
|
||||
const filter = state.context.getFilter();
|
||||
images.forEach((image, index) => {
|
||||
if (!passesImageryFilter(image, state.filter)) return;
|
||||
if (!passesImageryFilter(image, filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
collection.add({
|
||||
id: `${PICK_PREFIX.image}${image.id}`,
|
||||
@@ -158,9 +153,10 @@ export function createSequences({ state, source, parts }) {
|
||||
/** Select a sequence: highlight its line and load its image cones. */
|
||||
async function select(sequenceId) {
|
||||
if (!sequenceId || !state.viewer) return;
|
||||
// Already on screen, or already on its way.
|
||||
if (
|
||||
state.sequence.selectedId === sequenceId &&
|
||||
state.sequence.images.length
|
||||
(state.sequence.images.length || state.sequence.abort)
|
||||
)
|
||||
return;
|
||||
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
||||
@@ -174,9 +170,14 @@ export function createSequences({ state, source, parts }) {
|
||||
state.sequence.loading = false;
|
||||
state.sequence.images = cached;
|
||||
renderCones(cached);
|
||||
state.context.actions.reportError(null);
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
// The previous sequence's cones must not stay clickable under the new
|
||||
// highlight while this one loads, nor after its load fails.
|
||||
clearCones();
|
||||
requestRender();
|
||||
const controller = new AbortController();
|
||||
state.sequence.abort = controller;
|
||||
state.sequence.loading = true;
|
||||
@@ -195,11 +196,16 @@ export function createSequences({ state, source, parts }) {
|
||||
remember(sequenceId, images);
|
||||
state.sequence.images = images;
|
||||
renderCones(images);
|
||||
state.context.actions.reportError(null);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
if (!controller.signal.aborted) {
|
||||
// Nothing to show: drop the highlight, so a click asks again.
|
||||
parts.coverage.recolorSequence(sequenceId, false);
|
||||
state.sequence.selectedId = null;
|
||||
state.context.actions.reportError(
|
||||
error?.message || 'Sequence images unavailable',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (state.sequence.abort === controller) {
|
||||
state.sequence.loading = false;
|
||||
@@ -217,6 +223,8 @@ export function createSequences({ state, source, parts }) {
|
||||
state.sequence.selectedId = null;
|
||||
state.sequence.loading = false;
|
||||
clearCones();
|
||||
// An error about the sequence goes with it.
|
||||
state.context.actions.reportError(null);
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSequences } from './sequences.js';
|
||||
|
||||
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
|
||||
const noop = () => {};
|
||||
const context2d = new Proxy({}, { get: () => noop, set: () => true });
|
||||
globalThis.document ??= {
|
||||
createElement: () => ({ getContext: () => context2d }),
|
||||
};
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A billboard collection that remembers the cones on the globe. */
|
||||
function cones() {
|
||||
const items = [];
|
||||
return {
|
||||
items,
|
||||
show: true,
|
||||
add(options) {
|
||||
items.push(options);
|
||||
return options;
|
||||
},
|
||||
removeAll() {
|
||||
items.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A source whose sequence lookups the test answers, one call at a time. */
|
||||
function deferredSource() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getSequenceImages(sequenceId, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
calls.push({ sequenceId, signal, resolve, reject });
|
||||
});
|
||||
},
|
||||
/** The latest lookup for a sequence. */
|
||||
last(sequenceId) {
|
||||
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Graph image records for a sequence, a few metres apart. */
|
||||
function records(sequenceId, count = 2) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `${sequenceId}-${i}`,
|
||||
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
|
||||
compass_angle: 90,
|
||||
captured_at: 1_700_000_000_000 + i,
|
||||
}));
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const source = deferredSource();
|
||||
const colours = [];
|
||||
const errors = [];
|
||||
const state = {
|
||||
services: {},
|
||||
viewer: {},
|
||||
context: {
|
||||
notify() {},
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
getSurface: () => 'draped',
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
actions: { reportError: (message) => errors.push(message) },
|
||||
},
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
cache: new Map(),
|
||||
collection: cones(),
|
||||
loading: false,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
const parts = {
|
||||
coverage: {
|
||||
recolorSequence: (id, selected) => colours.push([id, selected]),
|
||||
},
|
||||
};
|
||||
const sequences = createSequences({ state, source, parts });
|
||||
const drawn = () =>
|
||||
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
|
||||
return { state, source, sequences, colours, errors, drawn };
|
||||
}
|
||||
|
||||
test('a failed sequence load leaves no stale cones and can be retried (review P2-4)', async () => {
|
||||
const { state, source, sequences, colours, errors, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['A-0', 'A-1']);
|
||||
|
||||
sequences.select('B');
|
||||
assert.deepEqual(drawn(), [], "A's cones go while B loads");
|
||||
source.last('B').reject(new Error('Sequence images unavailable'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
|
||||
assert.deepEqual(colours.at(-1), ['B', false]);
|
||||
assert.deepEqual(errors, [null, 'Sequence images unavailable']);
|
||||
|
||||
// Clicking B again asks again.
|
||||
sequences.select('B');
|
||||
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['B-0', 'B-1']);
|
||||
assert.equal(state.sequence.selectedId, 'B');
|
||||
assert.equal(errors.at(-1), null, 'the error is cleared once B loads');
|
||||
});
|
||||
|
||||
test('a late answer for a superseded sequence neither draws nor is cached', async () => {
|
||||
const { state, source, sequences, drawn } = setup();
|
||||
sequences.select('A');
|
||||
sequences.select('B');
|
||||
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['B-0', 'B-1']);
|
||||
assert.equal(state.sequence.selectedId, 'B');
|
||||
assert.equal(state.sequence.cache.has('A'), false);
|
||||
assert.equal(state.sequence.loading, false);
|
||||
});
|
||||
|
||||
test('a second click on a loading sequence does not start another lookup', async () => {
|
||||
const { source, sequences } = setup();
|
||||
sequences.select('A');
|
||||
sequences.select('A');
|
||||
assert.equal(source.calls.length, 1);
|
||||
assert.equal(source.calls[0].signal.aborted, false);
|
||||
});
|
||||
|
||||
test('a cached sequence is re-selected without a lookup', async () => {
|
||||
const { state, source, sequences, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
sequences.select('B');
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
sequences.select('A');
|
||||
assert.equal(source.calls.length, 2, 'A came from the cache');
|
||||
assert.deepEqual(drawn(), ['A-0', 'A-1'], 'drawn at once');
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(state.sequence.selectedId, 'A');
|
||||
});
|
||||
|
||||
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
|
||||
const { state, source, sequences, colours, errors, drawn } = setup();
|
||||
sequences.select('A');
|
||||
sequences.clearSelection();
|
||||
assert.equal(source.last('A').signal.aborted, true);
|
||||
assert.deepEqual(colours, [
|
||||
['A', true],
|
||||
['A', false],
|
||||
]);
|
||||
assert.equal(state.sequence.selectedId, null);
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(errors.at(-1), null, 'an error about it goes too');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
|
||||
});
|
||||
@@ -117,10 +117,6 @@ export function createMapillarySource({
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
},
|
||||
|
||||
getImage(id, { signal, fields = IMAGE_FIELDS } = {}) {
|
||||
return graph(String(id), { fields }, { signal });
|
||||
},
|
||||
|
||||
async getSequenceImages(
|
||||
sequenceId,
|
||||
{ signal, limit = SEQUENCE_IMAGES_LIMIT } = {},
|
||||
|
||||
@@ -83,7 +83,7 @@ test('graph calls without a token fail fast as keyRequired without a request', a
|
||||
const { calls, fetchImpl } = fakeFetch(() => jsonResponse({}));
|
||||
const source = createMapillarySource({ token: '', fetchImpl });
|
||||
await assert.rejects(
|
||||
() => source.getImage('123'),
|
||||
() => source.getSequenceImages('123'),
|
||||
(error) =>
|
||||
error instanceof MapillarySourceError && error.keyRequired === true,
|
||||
);
|
||||
@@ -96,7 +96,7 @@ test('graph API errors carry the upstream message and status', async () => {
|
||||
);
|
||||
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||
await assert.rejects(
|
||||
() => source.getImage('999'),
|
||||
() => source.getSequenceImages('999'),
|
||||
(error) =>
|
||||
error instanceof MapillarySourceError &&
|
||||
error.status === 400 &&
|
||||
|
||||
@@ -11,7 +11,8 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
let Library = null;
|
||||
let container = null;
|
||||
let pendingOpen = null;
|
||||
let prewarming = false;
|
||||
/** The prewarm in flight, if any. */
|
||||
let prewarming = null;
|
||||
/** In-flight viewer construction, so pre-warm and open never build two. */
|
||||
let creating = null;
|
||||
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
|
||||
@@ -167,6 +168,13 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
close() {
|
||||
pendingOpen = null;
|
||||
current = null;
|
||||
// A playing sequence would keep stepping through (and downloading)
|
||||
// images in the hidden viewer.
|
||||
try {
|
||||
viewer?.getComponent('sequence')?.stop();
|
||||
} catch {
|
||||
/* sequence component not available */
|
||||
}
|
||||
},
|
||||
|
||||
unmount() {
|
||||
@@ -190,16 +198,22 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
* opening one only costs the image download. Safe to call repeatedly.
|
||||
*/
|
||||
async prewarm(host) {
|
||||
if (prewarming || !host) return;
|
||||
prewarming = true;
|
||||
try {
|
||||
await ensureLibrary();
|
||||
if (!viewer) await ensureViewer(host);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
} finally {
|
||||
prewarming = false;
|
||||
}
|
||||
if (!host) return;
|
||||
// A prewarm already in flight may give up (the layer went off and on
|
||||
// while the library downloaded): wait for it, then build if it did not.
|
||||
if (prewarming) await prewarming;
|
||||
if (viewer) return;
|
||||
const run = (async () => {
|
||||
try {
|
||||
await ensureLibrary();
|
||||
if (!viewer) await ensureViewer(host);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
}
|
||||
})();
|
||||
prewarming = run;
|
||||
await run;
|
||||
if (prewarming === run) prewarming = null;
|
||||
},
|
||||
|
||||
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
|
||||
|
||||
Reference in new issue
Block a user