mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge current main into HAL-522 review candidate
This commit is contained in:
commit
4240d04642
61 files changed
+10819
-1251
No files matched your search
@@ -108,7 +108,15 @@ export function createStreetLevelLayer({
|
||||
getSurface: () => state.surface,
|
||||
notify,
|
||||
actions: {
|
||||
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||
/** `{frame: false}` opens it without moving the camera. */
|
||||
openImage: (imageId, options) =>
|
||||
openImage(entry.def.id, imageId, options),
|
||||
/** This provider's image nearest a point the user picked on the map. */
|
||||
openNearest: (point) =>
|
||||
layer.openNearest(point, {
|
||||
providerIds: [entry.def.id],
|
||||
frame: false,
|
||||
}),
|
||||
/**
|
||||
* Show a provider error, or withdraw it (null). A withdrawal clears
|
||||
* only this provider's own error, never a newer one from elsewhere.
|
||||
@@ -334,15 +342,36 @@ export function createStreetLevelLayer({
|
||||
function setProviderEnabled(providerId, on) {
|
||||
const entry = state.providers.get(providerId);
|
||||
if (!entry) return false;
|
||||
const before = litIds();
|
||||
const next = on !== false;
|
||||
if (next)
|
||||
for (const other of state.providers.values())
|
||||
if (other !== entry && other.on) switchProvider(other, false);
|
||||
switchProvider(entry, next);
|
||||
sourceChanged(before);
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
const litIds = () =>
|
||||
[...state.providers]
|
||||
.filter(([, entry]) => entry.on)
|
||||
.map(([id]) => id)
|
||||
.join();
|
||||
|
||||
/**
|
||||
* After a chip, voice or a link changed the lit source: a lookup still
|
||||
* running and its spinner or error were about the source switched away
|
||||
* (an open photo keeps its own state).
|
||||
*/
|
||||
function sourceChanged(before) {
|
||||
if (litIds() === before) return;
|
||||
abortNearest();
|
||||
if (state.street.open) return;
|
||||
state.street.error = null;
|
||||
state.street.loading = false;
|
||||
}
|
||||
|
||||
function switchProvider(entry, on) {
|
||||
if (entry.on === on) return;
|
||||
entry.on = on;
|
||||
@@ -407,6 +436,9 @@ export function createStreetLevelLayer({
|
||||
hint: hintFor(entry, stats),
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
coverageLabel: entry.def.coverageLabel || null,
|
||||
highlightsSequence: entry.def.highlightsSequence !== false,
|
||||
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
};
|
||||
@@ -606,9 +638,11 @@ export function createStreetLevelLayer({
|
||||
filter: state.filter,
|
||||
});
|
||||
const lit = litByParams(decoded.providers);
|
||||
const before = litIds();
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
if (lit) switchProvider(state.providers.get(lit), true);
|
||||
sourceChanged(before);
|
||||
notify();
|
||||
setCoverageFilter(decoded.filter);
|
||||
return true;
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Cesium from 'cesium';
|
||||
import { createStreetLevelLayer } from './index.js';
|
||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
||||
import { createKartaViewProvider } from './providers/kartaview/index.js';
|
||||
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
/**
|
||||
@@ -92,8 +93,8 @@ function deferred() {
|
||||
}
|
||||
|
||||
/** A provider whose viewer reports a pose for every image it opens. */
|
||||
function posingProvider() {
|
||||
const provider = fakeProvider();
|
||||
function posingProvider(options) {
|
||||
const provider = fakeProvider(options);
|
||||
const create = provider.create;
|
||||
provider.create = (context) => {
|
||||
const instance = create(context);
|
||||
@@ -818,3 +819,127 @@ test('an empty provider composition enables and tears down without acquiring ima
|
||||
layer.enable(viewer);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
});
|
||||
|
||||
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
|
||||
const failing = fakeProvider({
|
||||
nearestImage: async () => {
|
||||
throw new Error('KartaView did not answer in time — try again');
|
||||
},
|
||||
});
|
||||
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
|
||||
const { layer, viewer } = await enabledLayer(t, [failing, other]);
|
||||
layer.attachViewerHost({});
|
||||
await layer.openNearest({ lat: 1, lon: 1 });
|
||||
assert.match(layer.getUIState().street.error, /did not answer/);
|
||||
layer.setProviderEnabled('other', true);
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
// A provider's own open (KartaView's ‹ ›) can leave the camera alone.
|
||||
await other.context().actions.openImage('img-1', { frame: false });
|
||||
assert.deepEqual(other.calls.open, ['img-1']);
|
||||
assert.equal(viewer.flights.started, 0);
|
||||
});
|
||||
|
||||
test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => {
|
||||
const provider = posingProvider();
|
||||
const { layer, viewer } = await enabledLayer(t, [provider]);
|
||||
layer.attachViewerHost({});
|
||||
await provider.context().actions.openImage('img-1', { frame: false });
|
||||
assert.deepEqual(provider.calls.open, ['img-1']);
|
||||
assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera');
|
||||
await provider.context().actions.openImage('img-2');
|
||||
assert.equal(viewer.flights.started, 1, 'a default open frames the image');
|
||||
});
|
||||
|
||||
test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => {
|
||||
const lit = posingProvider({ nearestImage: async () => 'lit-near' });
|
||||
const other = fakeProvider({
|
||||
id: 'other',
|
||||
pickPrefix: 'oth:',
|
||||
nearestImage: async () => 'other-near',
|
||||
});
|
||||
const { layer, viewer } = await enabledLayer(t, [lit, other]);
|
||||
layer.attachViewerHost({});
|
||||
const point = { lat: 38.58, lon: -121.49 };
|
||||
// The other source is off: its click asks nobody else.
|
||||
await other.context().actions.openNearest(point);
|
||||
await settle();
|
||||
assert.deepEqual(lit.calls.open, [], 'the lit source was not asked');
|
||||
assert.deepEqual(other.calls.open, []);
|
||||
await lit.context().actions.openNearest(point);
|
||||
assert.deepEqual(lit.calls.open, ['lit-near']);
|
||||
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
|
||||
});
|
||||
|
||||
test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => {
|
||||
const kartaView = createKartaViewProvider({
|
||||
source: {
|
||||
getTile: async () => ({ lines: [] }),
|
||||
nearestPhotos: async () => [],
|
||||
getPhoto: async () => {
|
||||
throw new Error('KartaView has no such photo');
|
||||
},
|
||||
getSequencePhotos: async () => ({ photos: [], hasMore: false }),
|
||||
probeImage: async () => true,
|
||||
},
|
||||
});
|
||||
const { layer } = await enabledLayer(t, [kartaView]);
|
||||
layer.setProviderEnabled('kartaview', true);
|
||||
await settle();
|
||||
const ui = layer.getUIState();
|
||||
assert.deepEqual(
|
||||
ui.legend.map((entry) => entry.key),
|
||||
['kartaview'],
|
||||
);
|
||||
assert.equal(ui.coverage.label?.noun, 'streets');
|
||||
assert.equal(
|
||||
ui.providers.find((p) => p.id === 'kartaview').highlightsSequence,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
|
||||
const slow = slowLookups();
|
||||
const looking = fakeProvider({ nearestImage: slow.nearestImage });
|
||||
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
|
||||
const { layer } = await enabledLayer(t, [looking, other]);
|
||||
layer.attachViewerHost({});
|
||||
const opening = layer.openNearest({ lat: 1, lon: 1 });
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.loading, true);
|
||||
layer.setParams({ mapillary: false, other: true });
|
||||
assert.equal(slow.lookups[0].signal.aborted, true, 'the lookup is stopped');
|
||||
assert.equal(
|
||||
layer.getUIState().street.loading,
|
||||
false,
|
||||
'no spinner left behind',
|
||||
);
|
||||
// The stopped lookup answers late: nothing opens, nothing is reported.
|
||||
slow.lookups[0].answer.resolve('img1');
|
||||
assert.equal(await opening, false);
|
||||
assert.deepEqual(looking.calls.open, []);
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
});
|
||||
|
||||
test('switching source by params clears a failed lookup’s error; reapplying the same switches does not', async (t) => {
|
||||
const failing = fakeProvider({
|
||||
nearestImage: async () => {
|
||||
throw new Error('KartaView did not answer in time — try again');
|
||||
},
|
||||
});
|
||||
const { layer: second } = await enabledLayer(t, [
|
||||
failing,
|
||||
fakeProvider({ id: 'other', pickPrefix: 'oth:' }),
|
||||
]);
|
||||
second.attachViewerHost({});
|
||||
await second.openNearest({ lat: 1, lon: 1 });
|
||||
assert.match(second.getUIState().street.error, /did not answer/);
|
||||
second.setParams({ mapillary: false, other: true });
|
||||
assert.equal(second.getUIState().street.error, null);
|
||||
// Reapplying the same switches (a restore) leaves state alone.
|
||||
await second.openNearest({ lat: 1, lon: 1 });
|
||||
const kept = second.getUIState().street.error;
|
||||
assert.match(kept, /No street-level imagery/);
|
||||
second.setParams({ mapillary: false, other: true });
|
||||
assert.equal(second.getUIState().street.error, kept);
|
||||
});
|
||||
@@ -0,0 +1,936 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { passesImageryFilter } from './filter.js';
|
||||
import {
|
||||
createHorizonCull,
|
||||
groundUnderCamera,
|
||||
metresBetween,
|
||||
viewFocus,
|
||||
visibleBbox,
|
||||
whenIdle,
|
||||
} from './view.js';
|
||||
import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js';
|
||||
import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js';
|
||||
import { tileBounds, tilesForBbox } from './tileMath.js';
|
||||
|
||||
/** Draped lines stay at most this long while a tile's cast lines build. */
|
||||
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. */
|
||||
const REMESH_STAGGER_MS = 120;
|
||||
/** Old-zoom tiles are kept at most this long after a zoom change. */
|
||||
const STALE_TILE_MAX_MS = 6000;
|
||||
/** Sequences per primitive: smaller batches build, and show, sooner. */
|
||||
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||||
|
||||
/** Separates a multi-part sequence's part index from its id in pick ids. */
|
||||
const PART_SEPARATOR = '~';
|
||||
|
||||
/** Pick ids for every part of a sequence; the first part is unsuffixed. */
|
||||
function partIds(sequence, prefix) {
|
||||
return sequence.parts.map((_, index) =>
|
||||
index
|
||||
? `${prefix}${sequence.id}${PART_SEPARATOR}${index}`
|
||||
: `${prefix}${sequence.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Sequence id for any part's pick id (`<prefix><id>[~<part>]`), else null. */
|
||||
export function sequenceIdFromPick(pickId, prefix) {
|
||||
if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix))
|
||||
return null;
|
||||
const rest = pickId.slice(prefix.length);
|
||||
const cut = rest.indexOf(PART_SEPARATOR);
|
||||
return cut === -1 ? rest : rest.slice(0, cut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any point of `box` ({west, south, east, north}) is within the mesh
|
||||
* sampler's range of `centre`, the ground point under the camera.
|
||||
*/
|
||||
export function meshBoxInRange(box, centre) {
|
||||
// Move the camera's longitude to the box's side of the date line first:
|
||||
// clamping 179.999 into a box at -179.99 would pick its far edge.
|
||||
const lon =
|
||||
centre.lon +
|
||||
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
|
||||
const nearest = {
|
||||
lon: Math.min(box.east, Math.max(box.west, lon)),
|
||||
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
|
||||
};
|
||||
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} LineCoverageTuning
|
||||
* @property {number} maxTiles Line tiles drawn at once.
|
||||
* @property {number} overviewMaxTiles Overview tiles drawn at once.
|
||||
* @property {number} maxSequences Lines drawn at once, over every tile.
|
||||
* @property {number} lineWidthPx
|
||||
* @property {number} overviewPointPx
|
||||
* @property {number} moveDebounceMs Camera rest before the tile set is recomputed.
|
||||
* @property {number} viewRangeMinM Street-zoom coverage reaches at least this far…
|
||||
* @property {number} viewRangePerHeight …or camera height times this.
|
||||
* @property {number} viewNearM Ground always covered around a tilted camera.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Camera-driven coverage from any XYZ line-tile source: overview points from
|
||||
* orbit, sequence lines near the ground. In terrain surface mode a tile is
|
||||
* drawn draped first, then swapped for lines cast to the bare earth (draped
|
||||
* lines would land on roofs), so coverage never waits on terrain heights.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags.
|
||||
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>, overview: Array<object>}>} options.fetchTile
|
||||
* A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview
|
||||
* points `{lon, lat, capturedAt, isPano}`. A rejection may carry
|
||||
* `keyRequired`, `keyRejected` or `retryAfterSec`.
|
||||
* @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor
|
||||
* Tile zoom for a camera height above ground; null draws nothing.
|
||||
* @param {{coverage: string, selected: string}} options.colors
|
||||
* @param {string} options.pickPrefix Pick-id prefix of the lines.
|
||||
* @param {string} options.renderKey Render-governor reason.
|
||||
* @param {LineCoverageTuning} options.tuning
|
||||
* @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages
|
||||
* @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point.
|
||||
*/
|
||||
export function createLineCoverage({
|
||||
state,
|
||||
fetchTile,
|
||||
zoomFor,
|
||||
colors,
|
||||
pickPrefix,
|
||||
renderKey,
|
||||
tuning,
|
||||
messages,
|
||||
passes = passesImageryFilter,
|
||||
}) {
|
||||
const { render } = state.services;
|
||||
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
|
||||
|
||||
/** Colour for a line: the provider's, GEV cyan while selected. */
|
||||
function sequenceColor({ selected = false } = {}) {
|
||||
return selected
|
||||
? Cesium.Color.fromCssColorString(colors.selected)
|
||||
: Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92);
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.(renderKey);
|
||||
}
|
||||
|
||||
function ensureTerrainReady() {
|
||||
return (state.coverage.terrainReady ||=
|
||||
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
|
||||
}
|
||||
|
||||
function tileKey({ x, y, z }) {
|
||||
return `${z}/${x}/${y}`;
|
||||
}
|
||||
|
||||
/** The imagery filter, resolved now: "since N days" moves with the clock. */
|
||||
function filter() {
|
||||
return state.context.getFilter();
|
||||
}
|
||||
|
||||
function terrainMode() {
|
||||
return (
|
||||
state.context.getSurface?.() === 'terrain' &&
|
||||
Boolean(state.context.groundCaster)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile's sequences, filtered then capped: capping first could leave a
|
||||
* dense tile of newer flat captures with no 360° lines at all.
|
||||
*/
|
||||
function drawnSequences(entry) {
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
for (const sequence of entry.sequenceList) {
|
||||
if (drawn.length >= perTileSequenceCap) break;
|
||||
if (passes(sequence, current)) drawn.push(sequence);
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched primitives for a tile's sequence parts: cast polylines where
|
||||
* terrain heights are cached, draped ground lines otherwise. Each batch
|
||||
* records the selection its colours were built with.
|
||||
*/
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
|
||||
const selectedId = state.sequence.selectedId ?? null;
|
||||
const draped = [];
|
||||
const cast = [];
|
||||
for (const sequence of sequences) {
|
||||
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
sequenceColor({ selected: sequence.id === selectedId }),
|
||||
);
|
||||
const ids = partIds(sequence, pickPrefix);
|
||||
sequence.parts.forEach((coordinates, index) => {
|
||||
const flat = ground?.castLine(coordinates, { meshAt });
|
||||
let positions;
|
||||
try {
|
||||
positions = flat
|
||||
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
|
||||
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (positions.length < 2) return;
|
||||
const geometry = flat
|
||||
? new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width: tuning.lineWidthPx,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
})
|
||||
: new Cesium.GroundPolylineGeometry({
|
||||
positions,
|
||||
width: tuning.lineWidthPx,
|
||||
});
|
||||
(flat ? cast : draped).push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
id: ids[index],
|
||||
attributes: { color },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
const primitives = [];
|
||||
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push({
|
||||
onGround: true,
|
||||
selectedId,
|
||||
primitive: new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||
appearance: new Cesium.PolylineColorAppearance(),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
asynchronous: true,
|
||||
allowPicking: true,
|
||||
}),
|
||||
});
|
||||
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push({
|
||||
onGround: false,
|
||||
selectedId,
|
||||
primitive: new Cesium.Primitive({
|
||||
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
asynchronous: true,
|
||||
allowPicking: true,
|
||||
}),
|
||||
});
|
||||
return { primitives, draped, cast: cast.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the terrain heights a tile's lines need, then redraw it cast.
|
||||
* `castRequested` stops the redraw from casting again at once; a refresh
|
||||
* `retry`s a tile whose lines are still draped (a failed or partial cast),
|
||||
* and it is redrawn only when more of its lines can be cast.
|
||||
*/
|
||||
function castTile(entry, { retry = false } = {}) {
|
||||
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
|
||||
return;
|
||||
entry.castRequested = true;
|
||||
entry.castAbort = new AbortController();
|
||||
const { signal } = entry.castAbort;
|
||||
const lines = [...entry.sequences.values()].flatMap(
|
||||
(sequence) => sequence.parts,
|
||||
);
|
||||
const caster = state.context.groundCaster;
|
||||
caster.prepareLines(lines, { signal }).then(() => {
|
||||
if (entry.castAbort?.signal === signal) entry.castAbort = null;
|
||||
const attached = [...state.coverage.tiles.values()].includes(entry);
|
||||
if (signal.aborted || !attached || !terrainMode()) return;
|
||||
// Nothing new to cast: keep what is drawn until the next refresh.
|
||||
// Counted like buildSequencePrimitives: a cast line needs two points.
|
||||
const castable = lines.filter(
|
||||
(coords) => caster.castLine(coords)?.length >= 6,
|
||||
).length;
|
||||
if (castable <= (entry.castLines || 0)) {
|
||||
entry.castRequested = (entry.castLines || 0) > 0;
|
||||
return;
|
||||
}
|
||||
// A remesh may have cleared the flag while this was in flight.
|
||||
entry.castRequested = true;
|
||||
redrawTile(entry);
|
||||
requestMesh(entry);
|
||||
});
|
||||
}
|
||||
|
||||
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
|
||||
function redrawTile(entry) {
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
attachPrimitive(entry);
|
||||
removeWhenReady(entry, previous);
|
||||
}
|
||||
|
||||
/** Lon/lat under the camera, where mesh range is measured from. */
|
||||
function meshCentre() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile's drawn lines with bounding boxes, built once per filter; mesh
|
||||
* points are densified the first time a line is in range.
|
||||
*/
|
||||
function meshParts(entry) {
|
||||
if (entry.meshParts) return entry.meshParts;
|
||||
entry.meshParts = [];
|
||||
for (const sequence of entry.sequences.values())
|
||||
for (const part of sequence.parts) {
|
||||
const box = {
|
||||
west: Infinity,
|
||||
south: Infinity,
|
||||
east: -Infinity,
|
||||
north: -Infinity,
|
||||
};
|
||||
for (const [lon, lat] of part) {
|
||||
if (lon < box.west) box.west = lon;
|
||||
if (lon > box.east) box.east = lon;
|
||||
if (lat < box.south) box.south = lat;
|
||||
if (lat > box.north) box.north = lat;
|
||||
}
|
||||
entry.meshParts.push({ part, box, points: null });
|
||||
}
|
||||
return entry.meshParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for mesh samples under a cast tile's lines within the sampler's range
|
||||
* of the camera; the rest are asked for when a camera move brings them in.
|
||||
*/
|
||||
function requestMesh(entry) {
|
||||
const sampler = state.context.meshSampler;
|
||||
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
|
||||
const centre = meshCentre();
|
||||
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
|
||||
return;
|
||||
const points = [];
|
||||
for (const line of meshParts(entry)) {
|
||||
if (!meshBoxInRange(line.box, centre)) continue;
|
||||
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
|
||||
for (const point of line.points) points.push(point);
|
||||
}
|
||||
if (points.length) sampler.request(points);
|
||||
}
|
||||
|
||||
/** Redraw the tiles new mesh samples fall in, throttled. */
|
||||
function onMeshSampled(batch) {
|
||||
if (!terrainMode() || !state.context.isActive()) return;
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
if (entry.kind !== 'sequence' || !entry.bounds) continue;
|
||||
const { west, south, east, north } = entry.bounds;
|
||||
if (
|
||||
batch.some(
|
||||
([lon, lat]) =>
|
||||
lon >= west && lon <= east && lat >= south && lat <= north,
|
||||
)
|
||||
)
|
||||
state.coverage.remeshDirty.add(entry);
|
||||
}
|
||||
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
|
||||
const wait = Math.max(
|
||||
0,
|
||||
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
|
||||
);
|
||||
state.coverage.remeshTimer = setTimeout(remesh, wait);
|
||||
}
|
||||
|
||||
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
|
||||
function remesh() {
|
||||
state.coverage.remeshTimer = null;
|
||||
state.coverage.remeshAt = Date.now();
|
||||
if (!terrainMode() || !state.context.isActive()) {
|
||||
state.coverage.remeshDirty.clear();
|
||||
return;
|
||||
}
|
||||
const attached = new Set(state.coverage.tiles.values());
|
||||
const [entry] = state.coverage.remeshDirty;
|
||||
if (!entry) return;
|
||||
state.coverage.remeshDirty.delete(entry);
|
||||
if (attached.has(entry)) {
|
||||
redrawTile(entry);
|
||||
requestRender();
|
||||
}
|
||||
if (state.coverage.remeshDirty.size)
|
||||
state.coverage.remeshTimer = setTimeout(
|
||||
() => whenIdle(remesh, 500),
|
||||
REMESH_STAGGER_MS,
|
||||
);
|
||||
}
|
||||
|
||||
state.coverage.remeshDirty = new Set();
|
||||
state.context.meshSampler?.onSampled(onMeshSampled);
|
||||
|
||||
/**
|
||||
* Remove a tile's previous primitives once all its current ones are ready
|
||||
* (or after a few seconds); detaching the tile removes them at once.
|
||||
*/
|
||||
function removeWhenReady(entry, old) {
|
||||
const scene = state.viewer?.scene;
|
||||
finishSwap(entry);
|
||||
if (!scene?.postRender) {
|
||||
removePrimitives(old);
|
||||
return;
|
||||
}
|
||||
const fresh = entry.primitives;
|
||||
const started = Date.now();
|
||||
const stop = scene.postRender.addEventListener(() => {
|
||||
const ready = fresh.every(
|
||||
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
|
||||
);
|
||||
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
finishSwap(entry);
|
||||
requestRender();
|
||||
});
|
||||
entry.swap = { old, stop };
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function finishSwap(entry) {
|
||||
if (!entry.swap) return;
|
||||
entry.swap.stop();
|
||||
removePrimitives(entry.swap.old);
|
||||
entry.swap = null;
|
||||
}
|
||||
|
||||
function buildOverviewCollection(points) {
|
||||
const collection = new Cesium.PointPrimitiveCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
const tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha(
|
||||
0.85,
|
||||
);
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
for (const point of points) {
|
||||
if (!passes(point, current)) continue;
|
||||
drawn.push(
|
||||
collection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||||
color: tint,
|
||||
pixelSize: tuning.overviewPointPx,
|
||||
// Google 3D terrain and clouds must not hide the near side's dots;
|
||||
// the horizon cull hides the far side's, which this lets through.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { collection, points: drawn };
|
||||
}
|
||||
|
||||
/** Every overview point on the globe, current zoom and old alike. */
|
||||
function* overviewPoints() {
|
||||
for (const entry of drawnEntries())
|
||||
if (entry.overviewPoints) yield* entry.overviewPoints;
|
||||
}
|
||||
|
||||
// Overview points skip the depth test: hide the far hemisphere's.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: overviewPoints,
|
||||
onChange: requestRender,
|
||||
});
|
||||
|
||||
function attachPrimitive(entry) {
|
||||
const scene = state.viewer?.scene;
|
||||
if (!scene) return;
|
||||
if (entry.kind === 'sequence') {
|
||||
// Lookup, picking and counts follow what is drawn, not the whole tile.
|
||||
const sequences = drawnSequences(entry);
|
||||
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||||
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
|
||||
entry.primitives = primitives;
|
||||
entry.count = sequences.length;
|
||||
for (const { primitive, onGround } of primitives)
|
||||
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
|
||||
// Fewer lines cast than last time: the caster has dropped heights this
|
||||
// tile used (a full cache), so it must be cast again.
|
||||
if (cast < (entry.castLines || 0)) entry.castRequested = false;
|
||||
entry.castLines = cast;
|
||||
entry.drapedLines = draped.length;
|
||||
if (draped.length) castTile(entry);
|
||||
watchSelection();
|
||||
} else {
|
||||
const { collection, points } = buildOverviewCollection(entry.points);
|
||||
entry.primitive = collection;
|
||||
entry.overviewPoints = points;
|
||||
entry.count = points.length;
|
||||
scene.primitives.add(collection);
|
||||
// Cull the new points now, then all of them whenever the camera moves.
|
||||
horizon.update(points);
|
||||
}
|
||||
}
|
||||
|
||||
function removePrimitives(list) {
|
||||
const scene = state.viewer?.scene;
|
||||
for (const { primitive, onGround } of list || []) {
|
||||
try {
|
||||
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
|
||||
primitive,
|
||||
);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detachPrimitive(entry) {
|
||||
const scene = state.viewer?.scene;
|
||||
finishSwap(entry);
|
||||
removePrimitives(entry.primitives);
|
||||
entry.primitives = [];
|
||||
if (entry.primitive) {
|
||||
try {
|
||||
scene?.primitives?.remove(entry.primitive);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
entry.primitive = null;
|
||||
entry.overviewPoints = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
|
||||
function cancelCast(entry) {
|
||||
entry.castAbort?.abort();
|
||||
entry.castAbort = null;
|
||||
}
|
||||
|
||||
function removeTile(key) {
|
||||
const entry = state.coverage.tiles.get(key);
|
||||
if (!entry) return;
|
||||
cancelCast(entry);
|
||||
detachPrimitive(entry);
|
||||
state.coverage.tiles.delete(key);
|
||||
}
|
||||
|
||||
async function loadTile(tile, kind) {
|
||||
const key = tileKey(tile);
|
||||
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
state.coverage.pending.set(key, controller);
|
||||
notify();
|
||||
try {
|
||||
// Fetch and the one-time terrain-height table load run side by side,
|
||||
// both handled from the start so an early tile failure is not unhandled.
|
||||
const [decoded] = await Promise.all([
|
||||
fetchTile(tile, { signal: controller.signal }),
|
||||
kind === 'sequence' ? ensureTerrainReady() : null,
|
||||
]);
|
||||
// Only an abort, a retire, a clear or a newer request for this tile
|
||||
// discards the bytes; a refresh that still wants the tile keeps them.
|
||||
const current = () =>
|
||||
state.coverage.pending.get(key) === controller &&
|
||||
!state.coverage.tiles.has(key);
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
!current() ||
|
||||
kind !== state.coverage.kind ||
|
||||
tile.z !== state.coverage.zoom
|
||||
)
|
||||
return;
|
||||
const entry = { kind, primitive: null, primitives: [], count: 0 };
|
||||
// Padded: vector tiles carry a small buffer past their edge.
|
||||
const bounds = tileBounds(tile.x, tile.y, tile.z);
|
||||
const pad = (bounds.east - bounds.west) * 0.05;
|
||||
entry.bounds = {
|
||||
west: bounds.west - pad,
|
||||
east: bounds.east + pad,
|
||||
south: bounds.south - pad,
|
||||
north: bounds.north + pad,
|
||||
};
|
||||
if (kind === 'sequence') {
|
||||
// Newest first; the per-tile cap applies to what passes the filter.
|
||||
entry.sequenceList = decoded.sequences.sort(
|
||||
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
|
||||
);
|
||||
entry.sequences = new Map();
|
||||
entry.total = decoded.sequences.length;
|
||||
} else {
|
||||
entry.points = decoded.overview;
|
||||
entry.sequences = new Map();
|
||||
entry.total = decoded.overview.length;
|
||||
}
|
||||
attachPrimitive(entry);
|
||||
state.coverage.tiles.set(key, entry);
|
||||
state.coverage.lastError = null;
|
||||
requestRender();
|
||||
} catch (error) {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
state.coverage.pending.get(key) !== controller
|
||||
)
|
||||
return;
|
||||
state.coverage.lastError = error?.message || 'Coverage tile failed';
|
||||
if (error?.keyRequired) state.keyRequired = true;
|
||||
if (error?.keyRejected) {
|
||||
// Every other tile would be refused too: stop asking.
|
||||
state.keyRejected = true;
|
||||
state.coverage.lastError =
|
||||
messages.keyRejected || state.coverage.lastError;
|
||||
}
|
||||
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 (which is what counts as loading).
|
||||
if (state.coverage.pending.get(key) === controller) {
|
||||
state.coverage.pending.delete(key);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
}
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source is rate-limiting: keep what is drawn, request nothing until the
|
||||
* wait is over, then refresh once.
|
||||
*/
|
||||
function holdFor(seconds) {
|
||||
clearTimeout(state.coverage.holdTimer);
|
||||
state.coverage.holdUntil = Date.now() + seconds * 1000;
|
||||
state.coverage.lastError = messages.rateLimited;
|
||||
state.coverage.holdTimer = setTimeout(() => {
|
||||
state.coverage.holdTimer = null;
|
||||
state.coverage.holdUntil = 0;
|
||||
if (state.coverage.lastError === messages.rateLimited)
|
||||
state.coverage.lastError = null;
|
||||
refresh();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
/** Forget refusals when the provider goes off, so the next run asks again. */
|
||||
function resetErrors() {
|
||||
clearTimeout(state.coverage.holdTimer);
|
||||
state.coverage.holdTimer = null;
|
||||
state.coverage.holdUntil = 0;
|
||||
state.coverage.lastError = null;
|
||||
state.keyRejected = false;
|
||||
}
|
||||
|
||||
/** Drop the previous zoom's tiles once the new ones are on screen. */
|
||||
function purgeStale() {
|
||||
clearTimeout(state.coverage.staleTimer);
|
||||
state.coverage.staleTimer = null;
|
||||
if (!state.coverage.stale.size) return;
|
||||
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
|
||||
state.coverage.stale.clear();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
|
||||
function retire() {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
for (const [key, entry] of state.coverage.tiles) {
|
||||
cancelCast(entry);
|
||||
const previous = state.coverage.stale.get(key);
|
||||
if (previous) detachPrimitive(previous);
|
||||
state.coverage.stale.set(key, entry);
|
||||
}
|
||||
state.coverage.tiles.clear();
|
||||
clearTimeout(state.coverage.staleTimer);
|
||||
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the screen centre meets the ground further ahead than the camera
|
||||
* is high (a view tilted above 45°), or misses it.
|
||||
*/
|
||||
function isTilted({ nadir, ahead }, height) {
|
||||
if (!ahead) return true;
|
||||
return metresBetween(nadir, ahead) > Math.max(1, height);
|
||||
}
|
||||
|
||||
/** Recompute the tile set for the current camera and reconcile primitives. */
|
||||
function refresh() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || !state.context.isActive() || state.keyRequired) return;
|
||||
if (state.statusKnown === false) return;
|
||||
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
|
||||
const ground = groundUnderCamera(viewer, {
|
||||
groundAt: state.context.groundCaster?.groundAt,
|
||||
});
|
||||
const cameraHeight = viewer.camera?.positionCartographic?.height;
|
||||
const height = Number.isFinite(cameraHeight)
|
||||
? cameraHeight - (ground ?? 0)
|
||||
: null;
|
||||
const level = zoomFor(height);
|
||||
const zoom = level?.zoom ?? null;
|
||||
const kind = level?.kind ?? null;
|
||||
// At street zooms the rays meet the ground where it really is (1,600 m up
|
||||
// in Denver; Google 3D hides the globe) and stop short of the horizon.
|
||||
const ranged =
|
||||
kind === 'sequence'
|
||||
? {
|
||||
groundHeight: ground,
|
||||
maxRange: Math.max(
|
||||
tuning.viewRangeMinM,
|
||||
height * tuning.viewRangePerHeight,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
// Tilted street views rank tiles along the line of sight, from the ground
|
||||
// under the camera to the ground at the centre of the screen.
|
||||
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
|
||||
let bbox = visibleBbox(viewer, {
|
||||
...ranged,
|
||||
// A tilted view's screen rows jump from the first metres to the horizon:
|
||||
// keep the ground around the camera too. Looking down needs no help.
|
||||
nearRange: focus && isTilted(focus, height) ? tuning.viewNearM : null,
|
||||
});
|
||||
if (kind === 'overview' && (!bbox || zoom <= 1))
|
||||
bbox = [-180, -85, 180, 85];
|
||||
if (zoom == null || !bbox) {
|
||||
state.coverage.hint =
|
||||
zoom == null && Number.isFinite(height) && messages.tooHigh
|
||||
? messages.tooHigh
|
||||
: 'Point the camera at the globe for street-level coverage';
|
||||
clear();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
state.coverage.hint = '';
|
||||
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
|
||||
retire();
|
||||
state.coverage.zoom = zoom;
|
||||
state.coverage.kind = kind;
|
||||
}
|
||||
const { tiles } = tilesForBbox(bbox, zoom, {
|
||||
limit: kind === 'sequence' ? tuning.maxTiles : tuning.overviewMaxTiles,
|
||||
focus: focus && { from: focus.nadir, to: focus.ahead },
|
||||
});
|
||||
const wanted = new Set(tiles.map(tileKey));
|
||||
for (const key of [...state.coverage.tiles.keys()])
|
||||
if (!wanted.has(key)) removeTile(key);
|
||||
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 it: settle it here.
|
||||
controller.abort();
|
||||
state.coverage.pending.delete(key);
|
||||
}
|
||||
for (const tile of tiles) loadTile(tile, kind);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
// The camera moved: cells that were out of the sampler's range may not be,
|
||||
// and lines left draped by a failed, partial or dropped cast get another try.
|
||||
if (terrainMode())
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
if (entry.castRequested) requestMesh(entry);
|
||||
if (entry.drapedLines) castTile(entry, { retry: true });
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(state.coverage.debounceTimer);
|
||||
state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs);
|
||||
}
|
||||
|
||||
function attach(viewer) {
|
||||
detach();
|
||||
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
|
||||
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
|
||||
state.coverage.removeCameraListener = () => {
|
||||
remove();
|
||||
removeEnd();
|
||||
};
|
||||
refresh();
|
||||
}
|
||||
|
||||
function detach() {
|
||||
clearTimeout(state.coverage.debounceTimer);
|
||||
state.coverage.debounceTimer = null;
|
||||
state.coverage.removeCameraListener?.();
|
||||
state.coverage.removeCameraListener = null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
||||
purgeStale();
|
||||
state.coverage.zoom = null;
|
||||
state.coverage.kind = null;
|
||||
clearTimeout(state.coverage.remeshTimer);
|
||||
state.coverage.remeshTimer = null;
|
||||
state.coverage.remeshDirty.clear();
|
||||
stopSelectionWatch();
|
||||
horizon.stop();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
||||
function rebuild() {
|
||||
purgeStale();
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
cancelCast(entry);
|
||||
entry.castRequested = false;
|
||||
entry.meshParts = null;
|
||||
detachPrimitive(entry);
|
||||
attachPrimitive(entry);
|
||||
}
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every tile again (a source that filters on its server had its
|
||||
* filter changed); the lines drawn now show until the new ones are in.
|
||||
*/
|
||||
function reload() {
|
||||
retire();
|
||||
refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
||||
* zoom, since the ground height under the camera may have changed.
|
||||
*/
|
||||
function setSurface() {
|
||||
if (!state.context.isActive()) return;
|
||||
rebuild();
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Recolour every part of a sequence in one ready primitive. */
|
||||
function recolorInstances(primitive, sequence, selected) {
|
||||
const value = sequenceColor({ selected });
|
||||
for (const instanceId of partIds(sequence, pickPrefix)) {
|
||||
try {
|
||||
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
|
||||
if (attributes)
|
||||
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
value,
|
||||
attributes.color,
|
||||
);
|
||||
} catch {
|
||||
/* instance not in this primitive */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every tile on the globe: the current zoom's and the stale zoom's. */
|
||||
function drawnEntries() {
|
||||
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
||||
}
|
||||
|
||||
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
|
||||
function drawnRecords(entry) {
|
||||
const records = entry.primitives || [];
|
||||
return entry.swap ? [...records, ...entry.swap.old] : records;
|
||||
}
|
||||
|
||||
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||||
function recolorSequence(id, selected) {
|
||||
for (const entry of drawnEntries()) {
|
||||
const sequence = entry.sequences.get(id);
|
||||
if (!sequence) continue;
|
||||
for (const record of drawnRecords(entry)) {
|
||||
// Still building: `syncSelection` catches it up once it is ready.
|
||||
if (!record.primitive.ready) continue;
|
||||
recolorInstances(record.primitive, sequence, selected);
|
||||
if (selected) record.selectedId = id;
|
||||
else if (record.selectedId === id) record.selectedId = null;
|
||||
}
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a ready primitive's highlight up to the current selection, when
|
||||
* it was built with another one (or none). True when it changed.
|
||||
*/
|
||||
function syncSelection(entry, record) {
|
||||
const current = state.sequence.selectedId ?? null;
|
||||
if (record.selectedId === current || !record.primitive.ready) return false;
|
||||
const previous =
|
||||
record.selectedId && entry.sequences.get(record.selectedId);
|
||||
if (previous) recolorInstances(record.primitive, previous, false);
|
||||
const next = current && entry.sequences.get(current);
|
||||
if (next) recolorInstances(record.primitive, next, true);
|
||||
record.selectedId = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A primitive cannot be recoloured until it is ready, so while any is
|
||||
* building, catch each one up with the selection as it becomes ready.
|
||||
*/
|
||||
function watchSelection() {
|
||||
const scene = state.viewer?.scene;
|
||||
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
|
||||
const stop = scene.postRender.addEventListener(() => {
|
||||
let building = false;
|
||||
let changed = false;
|
||||
for (const entry of drawnEntries()) {
|
||||
for (const record of entry.primitives || []) {
|
||||
if (!record.primitive.ready) building = true;
|
||||
else if (syncSelection(entry, record)) changed = true;
|
||||
}
|
||||
// Lines a swap still shows were ready (or never show): catch up only.
|
||||
for (const record of entry.swap?.old || [])
|
||||
if (syncSelection(entry, record)) changed = true;
|
||||
}
|
||||
if (changed) requestRender();
|
||||
if (!building) stopSelectionWatch();
|
||||
});
|
||||
state.coverage.stopSelectionWatch = stop;
|
||||
}
|
||||
|
||||
function stopSelectionWatch() {
|
||||
state.coverage.stopSelectionWatch?.();
|
||||
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() {
|
||||
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 {
|
||||
attach,
|
||||
detach,
|
||||
refresh,
|
||||
clear,
|
||||
resetErrors,
|
||||
rebuild,
|
||||
reload,
|
||||
setSurface,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createLineCoverage } from './lineCoverage.js';
|
||||
import {
|
||||
coverageViewer,
|
||||
tileCentre,
|
||||
} from '../../testSupport/streetLevelFakes.mjs';
|
||||
import { until } from '../../testSupport/kartaViewFakes.mjs';
|
||||
|
||||
const TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 0,
|
||||
maxSequences: 900,
|
||||
lineWidthPx: 2,
|
||||
overviewPointPx: 2,
|
||||
moveDebounceMs: 10,
|
||||
viewRangeMinM: 2_500,
|
||||
viewRangePerHeight: 10,
|
||||
viewNearM: 1_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Line coverage over one tile holding one line, on a viewer looking down from
|
||||
* `height`. `fetchTile` answers with that line unless `fail` says otherwise.
|
||||
*/
|
||||
function setup(
|
||||
t,
|
||||
{ zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {},
|
||||
) {
|
||||
const centre = tileCentre(-121.4944, 38.5816);
|
||||
const viewer = coverageViewer(
|
||||
{ lon: centre.lon, lat: centre.lat, height: 900 },
|
||||
t,
|
||||
);
|
||||
const state = {
|
||||
viewer,
|
||||
services: {},
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
context: {
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
getSurface: () => 'draped',
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
},
|
||||
coverage: {
|
||||
zoom: null,
|
||||
kind: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
holdUntil: 0,
|
||||
holdTimer: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: Promise.resolve(),
|
||||
hint: '',
|
||||
},
|
||||
sequence: { selectedId: null },
|
||||
};
|
||||
const asked = [];
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile) => {
|
||||
asked.push(tile);
|
||||
if (fail) throw fail;
|
||||
return {
|
||||
sequences: [
|
||||
{
|
||||
id: 'line-1',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat],
|
||||
[centre.lon + 0.001, centre.lat],
|
||||
],
|
||||
],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
},
|
||||
],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
zoomFor:
|
||||
zoomFor ??
|
||||
((height) =>
|
||||
Number.isFinite(height) && height <= 6_000
|
||||
? { zoom: 14, kind: 'sequence' }
|
||||
: null),
|
||||
colors: { coverage: '#635bff', selected: '#00e5ff' },
|
||||
pickPrefix: 'test:line:',
|
||||
renderKey: 'test-coverage',
|
||||
tuning: TUNING,
|
||||
messages,
|
||||
...(passes ? { passes } : {}),
|
||||
});
|
||||
t.after(() => {
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
});
|
||||
return { viewer, state, coverage, asked };
|
||||
}
|
||||
|
||||
test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => {
|
||||
const tooHigh = 'zoom in to see test coverage';
|
||||
const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } });
|
||||
high.viewer.view.height = 7_000;
|
||||
high.coverage.refresh();
|
||||
assert.equal(high.state.coverage.hint, tooHigh);
|
||||
assert.equal(high.state.coverage.zoom, null);
|
||||
assert.equal(high.asked.length, 0, 'nothing is asked from up there');
|
||||
const generic = setup(t);
|
||||
generic.viewer.view.height = 7_000;
|
||||
generic.coverage.refresh();
|
||||
assert.notEqual(generic.state.coverage.hint, tooHigh);
|
||||
assert.match(generic.state.coverage.hint, /Point the camera/);
|
||||
});
|
||||
|
||||
test('a provider’s passes() decides which lines are drawn and counted', async (t) => {
|
||||
const all = setup(t, { passes: () => true });
|
||||
all.coverage.refresh();
|
||||
assert.ok(await until(() => all.state.coverage.tiles.size === 1));
|
||||
assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn');
|
||||
const none = setup(t, { passes: () => false });
|
||||
none.coverage.refresh();
|
||||
assert.ok(await until(() => none.state.coverage.tiles.size === 1));
|
||||
assert.equal(none.coverage.sequenceCount(), 0, 'filtered out');
|
||||
});
|
||||
|
||||
test('a refused key without its own message keeps the error it came with', async (t) => {
|
||||
const refused = Object.assign(new Error('the source refused the key'), {
|
||||
keyRejected: true,
|
||||
});
|
||||
const { state, coverage } = setup(t, { fail: refused });
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.keyRejected === true));
|
||||
assert.equal(state.coverage.lastError, 'the source refused the key');
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const POSITION_PICK_ID = 'sl:pos';
|
||||
export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
google: '#4285f4',
|
||||
kartaview: '#635bff',
|
||||
});
|
||||
|
||||
/** Colours every provider shares: the selection highlight and the marker. */
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js';
|
||||
|
||||
/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */
|
||||
export const MAX_TILE_SEGMENTS = 20_000;
|
||||
/** A chained line stops growing here and the next one starts. */
|
||||
export const MAX_LINE_POINTS = 5_000;
|
||||
|
||||
/**
|
||||
* The way segments of a KartaView coverage tile
|
||||
* (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines
|
||||
* carry no sequence, date or projection: a filter thins them through a mask
|
||||
* (see `maskedSegments`).
|
||||
* @param {object} payload
|
||||
* @returns {Array<Array<[number, number]>>}
|
||||
*/
|
||||
export function tileSegments(payload) {
|
||||
// Not cached as empty: an unreadable tile is asked for again.
|
||||
if (!Array.isArray(payload?.lines))
|
||||
throw new Error('KartaView sent an unreadable coverage tile');
|
||||
const segments = [];
|
||||
for (const line of payload.lines) {
|
||||
if (segments.length >= MAX_TILE_SEGMENTS) break;
|
||||
const points = [];
|
||||
for (const point of Array.isArray(line?.track) ? line.track : []) {
|
||||
const lon = Number(point?.lng);
|
||||
const lat = Number(point?.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const last = points[points.length - 1];
|
||||
if (last && last[0] === lon && last[1] === lat) continue;
|
||||
points.push([lon, lat]);
|
||||
}
|
||||
if (points.length >= 2) segments.push(points);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments chained into line records for the shared coverage: segments
|
||||
* that meet end to start are joined, which cuts the line count about
|
||||
* fourfold.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
*/
|
||||
export function chainSegments(segments, { x, y, z }) {
|
||||
const key = ([lon, lat]) => `${lon},${lat}`;
|
||||
const startingAt = new Map();
|
||||
segments.forEach((segment, index) => {
|
||||
const at = key(segment[0]);
|
||||
const list = startingAt.get(at);
|
||||
if (list) list.push(index);
|
||||
else startingAt.set(at, [index]);
|
||||
});
|
||||
const used = new Uint8Array(segments.length);
|
||||
// Per start point, how far its list has been used: each segment is looked
|
||||
// at once, so a tile of segments sharing one point stays linear.
|
||||
const cursor = new Map();
|
||||
const nextFrom = (point) => {
|
||||
const at = key(point);
|
||||
const list = startingAt.get(at);
|
||||
if (!list) return undefined;
|
||||
let i = cursor.get(at) ?? 0;
|
||||
while (i < list.length && used[list[i]]) i++;
|
||||
cursor.set(at, i);
|
||||
return list[i];
|
||||
};
|
||||
const sequences = [];
|
||||
segments.forEach((segment, index) => {
|
||||
if (used[index]) return;
|
||||
used[index] = 1;
|
||||
const chain = [...segment];
|
||||
for (;;) {
|
||||
const next = nextFrom(chain[chain.length - 1]);
|
||||
if (next === undefined) break;
|
||||
// Full: the segment starts the next line instead.
|
||||
if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break;
|
||||
used[next] = 1;
|
||||
chain.push(...segments[next].slice(1));
|
||||
}
|
||||
sequences.push({
|
||||
id: `${z}/${x}/${y}/${sequences.length}`,
|
||||
parts: [chain],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
});
|
||||
});
|
||||
return { sequences, overview: [] };
|
||||
}
|
||||
|
||||
/** Decode a KartaView coverage tile into the shared coverage's line records. */
|
||||
export function decodeCoverageTile(payload, address) {
|
||||
return chainSegments(tileSegments(payload), address);
|
||||
}
|
||||
|
||||
/**
|
||||
* The segments a filtered PNG tile of the same address draws over. The PNG
|
||||
* draws the same ways a few pixels wide (on a transparent ground): a
|
||||
* segment is kept when at least half the points sampled along it have a
|
||||
* drawn pixel within one pixel. Segments either lie on it or miss it
|
||||
* almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33
|
||||
* between). A segment with no point on the tile is not this tile's to keep.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{width: number, height: number, alpha: ArrayLike<number>}} mask one alpha byte per pixel
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
*/
|
||||
export function maskedSegments(segments, mask, { x, y, z }) {
|
||||
const { width, height, alpha } = mask;
|
||||
const n = 2 ** z;
|
||||
const toPixel = ([lon, lat]) => {
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||
return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height];
|
||||
};
|
||||
const drawnNear = (px, py) => {
|
||||
const cx = Math.floor(px);
|
||||
const cy = Math.floor(py);
|
||||
for (let dy = -1; dy <= 1; dy++)
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const X = cx + dx;
|
||||
const Y = cy + dy;
|
||||
if (X < 0 || Y < 0 || X >= width || Y >= height) continue;
|
||||
if (alpha[Y * width + X] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return segments.filter((segment) => {
|
||||
let on = 0;
|
||||
let tested = 0;
|
||||
let [ax, ay] = toPixel(segment[0]);
|
||||
for (let i = 1; i < segment.length; i++) {
|
||||
const [bx, by] = toPixel(segment[i]);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX),
|
||||
);
|
||||
// Each point once: a segment's next stretch starts where this one ends.
|
||||
for (let j = i === 1 ? 0 : 1; j <= steps; j++) {
|
||||
const px = ax + ((bx - ax) * j) / steps;
|
||||
const py = ay + ((by - ay) * j) / steps;
|
||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||
tested++;
|
||||
if (drawnNear(px, py)) on++;
|
||||
}
|
||||
ax = bx;
|
||||
ay = by;
|
||||
}
|
||||
return tested > 0 && on / tested >= MASK_KEEP_SHARE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A PNG mask's alpha, one byte per pixel, decoded off the main thread
|
||||
* where the browser can.
|
||||
* @param {Uint8Array} bytes the PNG
|
||||
* @returns {Promise<{width: number, height: number, alpha: Uint8Array}>}
|
||||
*/
|
||||
export async function readMaskImage(bytes) {
|
||||
const bitmap = await createImageBitmap(
|
||||
new Blob([bytes], { type: 'image/png' }),
|
||||
);
|
||||
try {
|
||||
const { width, height } = bitmap;
|
||||
const canvas =
|
||||
typeof OffscreenCanvas === 'function'
|
||||
? new OffscreenCanvas(width, height)
|
||||
: Object.assign(document.createElement('canvas'), { width, height });
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const rgba = context.getImageData(0, 0, width, height).data;
|
||||
const alpha = new Uint8Array(width * height);
|
||||
for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
|
||||
return { width, height, alpha };
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
MAX_LINE_POINTS,
|
||||
MAX_TILE_SEGMENTS,
|
||||
chainSegments,
|
||||
decodeCoverageTile,
|
||||
maskedSegments,
|
||||
tileSegments,
|
||||
} from './decode.js';
|
||||
|
||||
const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
|
||||
const TILE = { x: 4412, y: 6061, z: 14 };
|
||||
|
||||
test('segments that meet end to start chain into one line', () => {
|
||||
const { sequences, overview } = decodeCoverageTile(
|
||||
{
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(2, 1)], matched: '1' },
|
||||
{ track: [at(5, 5), at(6, 6)], matched: '1' },
|
||||
{ track: [at(2, 1), at(3, 1), at(4, 2)], matched: '1' },
|
||||
],
|
||||
},
|
||||
TILE,
|
||||
);
|
||||
assert.deepEqual(overview, []);
|
||||
assert.equal(sequences.length, 2);
|
||||
assert.deepEqual(sequences[0].parts, [
|
||||
[
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[3, 1],
|
||||
[4, 2],
|
||||
],
|
||||
]);
|
||||
assert.equal(sequences[0].id, '14/4412/6061/0');
|
||||
assert.equal(sequences[1].id, '14/4412/6061/1');
|
||||
// Nothing to date or project: every line passes as flat and undated.
|
||||
assert.equal(sequences[0].capturedAt, 0);
|
||||
assert.equal(sequences[0].isPano, false);
|
||||
});
|
||||
|
||||
test('a junction continues one way only, and every segment is drawn exactly once', () => {
|
||||
const { sequences } = decodeCoverageTile(
|
||||
{
|
||||
lines: [
|
||||
{ track: [at(0, 0), at(1, 0)] },
|
||||
{ track: [at(1, 0), at(2, 0)] },
|
||||
{ track: [at(1, 0), at(1, 1)] },
|
||||
// A loop back to its own start must end, not spin.
|
||||
{ track: [at(9, 9), at(9, 8)] },
|
||||
{ track: [at(9, 8), at(9, 9)] },
|
||||
],
|
||||
},
|
||||
TILE,
|
||||
);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
assert.equal(sequences.length, 3);
|
||||
// 5 segments of 2 points, 2 joins in chains: 10 - 2 = 8 points.
|
||||
assert.equal(points, 8);
|
||||
});
|
||||
|
||||
test('bad points, repeats and one-point tracks are skipped; an empty tile has no lines', () => {
|
||||
const { sequences } = decodeCoverageTile(
|
||||
{
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(1, 1), { lat: 'x', lng: '2' }, at(2, 2)] },
|
||||
{ track: [at(3, 3)] },
|
||||
{ track: null },
|
||||
null,
|
||||
],
|
||||
},
|
||||
TILE,
|
||||
);
|
||||
assert.equal(sequences.length, 1);
|
||||
assert.deepEqual(sequences[0].parts[0], [
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
]);
|
||||
assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []);
|
||||
});
|
||||
|
||||
test('a tile that is not a line list fails, so it is asked for again rather than kept empty', () => {
|
||||
for (const payload of [
|
||||
null,
|
||||
{},
|
||||
{ lines: 'nope' },
|
||||
{ status: { apiCode: 690 } },
|
||||
])
|
||||
assert.throws(
|
||||
() => decodeCoverageTile(payload, TILE),
|
||||
/unreadable coverage tile/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a tile whose segments all share one point decodes in linear time', () => {
|
||||
// [P, Q_i, P]: every chain step looks at the same start point's list.
|
||||
// 60k: the old quadratic chaining took over a second; capped and linear
|
||||
// decoding takes tens of milliseconds.
|
||||
const lines = Array.from({ length: 60_000 }, (_, i) => ({
|
||||
track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)],
|
||||
}));
|
||||
const started = performance.now();
|
||||
const { sequences } = decodeCoverageTile({ lines }, TILE);
|
||||
const ms = performance.now() - started;
|
||||
assert.ok(
|
||||
ms < 1_000,
|
||||
`${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`,
|
||||
);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
|
||||
assert.equal(
|
||||
points,
|
||||
MAX_TILE_SEGMENTS * 2 + sequences.length,
|
||||
'every segment drawn once',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tile is capped in segments and in points per line', () => {
|
||||
const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({
|
||||
track: [at(i, 0), at(i + 1, 0)],
|
||||
}));
|
||||
const { sequences } = decodeCoverageTile({ lines }, TILE);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
// One long chain, cut into lines of at most MAX_LINE_POINTS.
|
||||
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
|
||||
assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length);
|
||||
assert.ok(
|
||||
sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS),
|
||||
);
|
||||
});
|
||||
|
||||
/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */
|
||||
function lonLatAt(px, py, { x, y, z } = TILE, size = 512) {
|
||||
const n = 2 ** z;
|
||||
const lon = ((x + px / size) / n) * 360 - 180;
|
||||
const my = Math.PI * (1 - (2 * (y + py / size)) / n);
|
||||
const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/** A 512 px mask drawn on the given rows. */
|
||||
function rowsMask(rows) {
|
||||
const alpha = new Uint8Array(512 * 512);
|
||||
for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512);
|
||||
return { width: 512, height: 512, alpha };
|
||||
}
|
||||
|
||||
test('a mask keeps the segments it draws over, chained as before', () => {
|
||||
const along = (row, from = 40, to = 470) => [
|
||||
lonLatAt(from, row),
|
||||
lonLatAt(to, row),
|
||||
];
|
||||
const segments = [
|
||||
along(100), // drawn at row 100
|
||||
along(101.6), // a pixel off: still drawn within one pixel
|
||||
along(300), // not drawn
|
||||
// Two thirds along the drawn row, then off it: kept (at least half).
|
||||
[lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)],
|
||||
// A third along it, then off: dropped.
|
||||
[lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)],
|
||||
// Off the tile entirely: not this tile's to keep.
|
||||
[lonLatAt(-200, 100), lonLatAt(-20, 100)],
|
||||
];
|
||||
const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE);
|
||||
assert.deepEqual(kept, [segments[0], segments[1], segments[3]]);
|
||||
assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []);
|
||||
// A full mask keeps every segment that is on the tile.
|
||||
assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5);
|
||||
});
|
||||
|
||||
test('decoding is segments then chaining: the same lines either way', () => {
|
||||
const payload = {
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(2, 1)] },
|
||||
{ track: [at(2, 1), at(3, 1)] },
|
||||
{ track: [at(5, 5), at(6, 6)] },
|
||||
],
|
||||
};
|
||||
const segments = tileSegments(payload);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.deepEqual(
|
||||
chainSegments(segments, TILE),
|
||||
decodeCoverageTile(payload, TILE),
|
||||
);
|
||||
assert.throws(() => tileSegments({}), /unreadable coverage tile/);
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
import { createLineCoverage } from '../../lineCoverage.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import {
|
||||
chainSegments,
|
||||
maskedSegments,
|
||||
readMaskImage,
|
||||
tileSegments,
|
||||
} from './decode.js';
|
||||
import { createKartaViewSource } from './source.js';
|
||||
import { createKartaViewViewer } from './viewer.js';
|
||||
import {
|
||||
ARCHIVED_MESSAGE,
|
||||
COLORS,
|
||||
COVERAGE_MAX_HEIGHT_M,
|
||||
COVERAGE_NOUN,
|
||||
COVERAGE_STREET_ZOOM_BELOW_M,
|
||||
COVERAGE_TUNING,
|
||||
FILTERED_OUT_MESSAGE,
|
||||
KARTAVIEW_CREDIT_HTML,
|
||||
KARTAVIEW_LABEL,
|
||||
KARTAVIEW_NAME,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
LINE_CLICK_HINT,
|
||||
NEAREST_PROBES,
|
||||
NEAREST_RADIUS_M,
|
||||
NO_COVERAGE_HINT,
|
||||
PICK_PREFIX,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
SEQUENCE_PAGE_EDGE,
|
||||
SEQUENCE_PAGE_SIZE,
|
||||
TOO_HIGH_HINT,
|
||||
assignCourses,
|
||||
cameraBearing,
|
||||
normalizePhoto,
|
||||
passesKartaViewFilter,
|
||||
tileFilterQuery,
|
||||
} from './policy.js';
|
||||
|
||||
/** Photos kept in memory by id (sequence pages fill it). */
|
||||
const PHOTO_CACHE_SIZE = 4000;
|
||||
/** Sequence pages kept in memory. */
|
||||
const PAGE_CACHE_SIZE = 12;
|
||||
/** Decoded coverage tiles kept, and for how long (masks too). */
|
||||
const TILE_CACHE_SIZE = 24;
|
||||
const TILE_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
/** z14 near the street, z13 a little higher, nothing above. */
|
||||
export function kartaViewZoomFor(heightM) {
|
||||
if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null;
|
||||
return {
|
||||
zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14,
|
||||
kind: 'sequence',
|
||||
};
|
||||
}
|
||||
|
||||
/** Least-recently-used map: reading an entry renews it. */
|
||||
function createLru(limit) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView
|
||||
* sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so
|
||||
* a pan back or a zoom across z13/z14 would fetch them all again.
|
||||
*/
|
||||
export function createTileCache({
|
||||
load,
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = keyOf(tile);
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
kept.set(key, { at: now(), tile: loaded });
|
||||
return loaded;
|
||||
},
|
||||
/** Drop every tile (the provider was switched off). */
|
||||
clear: () => kept.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
|
||||
function pageOf(index) {
|
||||
return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* KartaView Street Level provider: keyless coverage lines from KartaView's
|
||||
* tile API, the photos nearest a clicked line, cones along the photo's
|
||||
* sequence, and a flat or 360° viewer with ‹ › along the sequence.
|
||||
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object, readMask?: typeof readMaskImage}} [options]
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createKartaViewProvider({
|
||||
source = createKartaViewSource(),
|
||||
viewerOptions = {},
|
||||
readMask = readMaskImage,
|
||||
} = {}) {
|
||||
return Object.freeze({
|
||||
id: KARTAVIEW_PROVIDER_ID,
|
||||
name: KARTAVIEW_NAME,
|
||||
label: KARTAVIEW_LABEL,
|
||||
requiresKeyId: null,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
// Its own marker, as Street View has: a purple cone that turns with the view.
|
||||
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
|
||||
credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }),
|
||||
defaultOn: false,
|
||||
// 360° / flat and SINCE, as Mapillary: KartaView filters the lines on
|
||||
// its server (see tileFilterQuery), the photos by their own record.
|
||||
filters: Object.freeze(['pano', 'since']),
|
||||
// SINCE is the upload date for KartaView: the panel notes it.
|
||||
sinceBasis: 'upload',
|
||||
// Its lines are streets, not sequences: a selected drive shows as cones.
|
||||
highlightsSequence: false,
|
||||
coverageLabel: Object.freeze({
|
||||
noun: COVERAGE_NOUN,
|
||||
action: LINE_CLICK_HINT,
|
||||
}),
|
||||
|
||||
create(context) {
|
||||
const state = {
|
||||
context,
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
statusKnown: true,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
holdUntil: 0,
|
||||
holdTimer: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: null,
|
||||
hint: '',
|
||||
kind: null,
|
||||
},
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
const photos = createLru(PHOTO_CACHE_SIZE);
|
||||
const pages = createLru(PAGE_CACHE_SIZE);
|
||||
/** The photo last opened in each sequence: its cones centre on it. */
|
||||
const anchors = new Map();
|
||||
/** The sequence page the cones were drawn from. */
|
||||
let drawnPage = null;
|
||||
|
||||
/** A record as a photo; the one already known keeps its identity (and course). */
|
||||
function remember(record) {
|
||||
const fresh = normalizePhoto(record);
|
||||
if (!fresh) return null;
|
||||
const known = photos.get(fresh.id);
|
||||
const photo = known ? Object.assign(known, fresh) : fresh;
|
||||
photos.set(photo.id, photo);
|
||||
return photo;
|
||||
}
|
||||
|
||||
async function getPhoto(photoId, { signal } = {}) {
|
||||
const cached = photos.get(photoId);
|
||||
if (cached) return cached;
|
||||
const photo = remember(await source.getPhoto(photoId, { signal }));
|
||||
if (!photo) throw new Error('KartaView has no position for this photo');
|
||||
return photo;
|
||||
}
|
||||
|
||||
/** A page of a sequence's photos, in index order (shared while in flight). */
|
||||
function loadPage(sequenceId, page) {
|
||||
const key = `${sequenceId}:${page}`;
|
||||
let entry = pages.get(key);
|
||||
if (!entry) {
|
||||
entry = source
|
||||
.getSequencePhotos(sequenceId, {
|
||||
page,
|
||||
itemsPerPage: SEQUENCE_PAGE_SIZE,
|
||||
})
|
||||
.then(({ photos: records, hasMore }) => {
|
||||
const list = assignCourses(
|
||||
records
|
||||
.map(remember)
|
||||
.filter(Boolean)
|
||||
.sort(
|
||||
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
|
||||
),
|
||||
metresBetween,
|
||||
);
|
||||
// The open photo may have just learnt which way it looked. The
|
||||
// page is good whatever the viewer is doing.
|
||||
try {
|
||||
viewer.refreshPose?.();
|
||||
} catch {
|
||||
/* the next pose carries the direction */
|
||||
}
|
||||
return { page, hasMore, photos: list };
|
||||
});
|
||||
pages.set(key, entry);
|
||||
entry.catch(() => pages.delete(key));
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page holding `photo`. Pages follow photo ids, which only roughly
|
||||
* follow the index, and photos go missing, so the page the index
|
||||
* suggests is a first guess: step towards the photo until a page holds
|
||||
* it, or the pages straddle where it would be.
|
||||
*/
|
||||
async function homePage(photo) {
|
||||
let number = pageOf(photo.sequenceIndex ?? 0);
|
||||
const index = photo.sequenceIndex ?? 0;
|
||||
let page = await loadPage(photo.sequenceId, number);
|
||||
if (!page.photos.length && number > 1) {
|
||||
// Past the end (many photos deleted): find the last page by halving.
|
||||
let low = 1;
|
||||
let high = number - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2);
|
||||
if ((await loadPage(photo.sequenceId, middle)).photos.length)
|
||||
low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
number = low;
|
||||
}
|
||||
const seen = new Set();
|
||||
while (!seen.has(number) && seen.size < 8) {
|
||||
seen.add(number);
|
||||
page = await loadPage(photo.sequenceId, number);
|
||||
if (page.photos.some((p) => p.id === photo.id)) return page;
|
||||
const first = page.photos[0]?.sequenceIndex;
|
||||
const last = page.photos.at(-1)?.sequenceIndex;
|
||||
// Past the last page, or before this page's photos: go back.
|
||||
if ((first === undefined || index < first) && number > 1) number--;
|
||||
else if (last !== undefined && index > last && page.hasMore) number++;
|
||||
else break;
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* The photos around `photo` in index order: its page, plus a
|
||||
* neighbour page when it sits near an edge (pages overlap in index).
|
||||
* @returns {Promise<{home: number, photos: Array<object>}>}
|
||||
*/
|
||||
async function windowOf(photo) {
|
||||
const home = await homePage(photo);
|
||||
const list = home.photos;
|
||||
const found = list.findIndex((p) => p.id === photo.id);
|
||||
// A photo no page lists sits where its index would.
|
||||
const at =
|
||||
found !== -1
|
||||
? found
|
||||
: list.filter(
|
||||
(p) => (p.sequenceIndex ?? 0) < (photo.sequenceIndex ?? 0),
|
||||
).length;
|
||||
const parts = [list];
|
||||
if (at < SEQUENCE_PAGE_EDGE && home.page > 1)
|
||||
parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos);
|
||||
if (at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore)
|
||||
parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos);
|
||||
if (parts.length === 1) return { home: home.page, photos: list };
|
||||
const byId = new Map(parts.flat().map((p) => [p.id, p]));
|
||||
return {
|
||||
home: home.page,
|
||||
photos: [...byId.values()].sort(
|
||||
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo id → whether its file is there (archived ones are not). Only
|
||||
* a definite answer is kept: a storage hiccup is asked again next
|
||||
* time, and meanwhile the photo is tried. A probe is shared, so it
|
||||
* runs to its own timeout rather than any one caller's abort.
|
||||
*/
|
||||
const probes = createLru(PHOTO_CACHE_SIZE);
|
||||
function showable(photo) {
|
||||
if (!photo?.urls?.view) return Promise.resolve(false);
|
||||
let probe = probes.get(photo.id);
|
||||
if (!probe) {
|
||||
probe = source.probeImage(photo.urls.view).then(
|
||||
(answer) => {
|
||||
if (answer === null) probes.delete(photo.id);
|
||||
return answer !== false;
|
||||
},
|
||||
() => {
|
||||
probes.delete(photo.id);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
probes.set(photo.id, probe);
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
// A tile's way segments, kept a while: panning back, or a filter
|
||||
// change, asks KartaView nothing more for them.
|
||||
const tiles = createTileCache({
|
||||
load: async (tile, { signal }) =>
|
||||
tileSegments(await source.getTile(tile, { signal })),
|
||||
});
|
||||
// A filtered tile's mask, by filter and tile.
|
||||
const masks = createTileCache({
|
||||
load: async ({ query, ...tile }, { signal }) =>
|
||||
readMask(await source.getTileMask(tile, query, { signal })),
|
||||
keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`,
|
||||
});
|
||||
/** The tile's lines under the filter now: all, or the masked ones. */
|
||||
async function fetchTile(tile, options) {
|
||||
const query = tileFilterQuery(context.getFilter());
|
||||
if (!query) return chainSegments(await tiles.get(tile, options), tile);
|
||||
const [segments, mask] = await Promise.all([
|
||||
tiles.get(tile, options),
|
||||
masks.get({ ...tile, query }, options),
|
||||
]);
|
||||
return chainSegments(maskedSegments(segments, mask, tile), tile);
|
||||
}
|
||||
/** The query the drawn lines were loaded under. */
|
||||
let drawnQuery = tileFilterQuery(context.getFilter());
|
||||
|
||||
/**
|
||||
* The next showable photo `delta` along the sequence, or null at its
|
||||
* end. Archived photos are skipped, a few at most.
|
||||
*/
|
||||
async function step(photo, delta) {
|
||||
if (!photo?.sequenceId) return null;
|
||||
let from = photo;
|
||||
for (let skipped = 0; skipped <= NEAREST_PROBES; skipped++) {
|
||||
const { photos: list } = await windowOf(from);
|
||||
let at = list.findIndex((p) => p.id === from.id);
|
||||
if (at === -1) {
|
||||
// Not found anywhere: the nearest index on the right side.
|
||||
const index = from.sequenceIndex ?? 0;
|
||||
const later = list.findIndex((p) => (p.sequenceIndex ?? 0) > index);
|
||||
if (delta > 0 && later === -1) return null;
|
||||
at =
|
||||
delta > 0
|
||||
? later - 1
|
||||
: later === -1
|
||||
? list.length
|
||||
: list.findIndex((p) => (p.sequenceIndex ?? 0) >= index);
|
||||
}
|
||||
const next = list[at + delta];
|
||||
if (!next) return null;
|
||||
if (await showable(next)) return next.id;
|
||||
from = next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile,
|
||||
zoomFor: kartaViewZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.line,
|
||||
renderKey: 'kartaview-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT },
|
||||
// Filtered on the way in (fetchTile): a line has no date to test.
|
||||
passes: () => true,
|
||||
});
|
||||
|
||||
const cones = createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
let anchor = anchors.get(sequenceId);
|
||||
if (!anchor) return [];
|
||||
let window = await windowOf(anchor);
|
||||
signal?.throwIfAborted();
|
||||
// Another photo of the drive opened while that page loaded (its
|
||||
// re-centre found nothing drawn yet): centre on the newest one.
|
||||
while (anchors.get(sequenceId) !== anchor) {
|
||||
anchor = anchors.get(sequenceId);
|
||||
window = await windowOf(anchor);
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
drawnPage = { sequenceId, page: window.home };
|
||||
countPhotos(sequenceId);
|
||||
return thinImages(
|
||||
window.photos.map((photo) => ({
|
||||
id: photo.id,
|
||||
lon: photo.lon,
|
||||
lat: photo.lat,
|
||||
compassAngle: cameraBearing(photo) ?? 0,
|
||||
capturedAt: photo.capturedAt ?? 0,
|
||||
uploadedAt: photo.uploadedAt,
|
||||
isPano: photo.isPano,
|
||||
})),
|
||||
);
|
||||
},
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:kartaview-cones',
|
||||
renderKey: 'kartaview-sequence',
|
||||
// By projection and upload date, as the lines.
|
||||
passes: passesKartaViewFilter,
|
||||
});
|
||||
|
||||
/** Sequence id → its photo count from KartaView (asked once). */
|
||||
const counts = new Map();
|
||||
function countPhotos(sequenceId) {
|
||||
if (counts.has(sequenceId) || !source.getSequenceCount) return;
|
||||
counts.set(sequenceId, undefined);
|
||||
source.getSequenceCount(sequenceId).then(
|
||||
(count) => {
|
||||
if (count === null) counts.delete(sequenceId);
|
||||
else {
|
||||
counts.set(sequenceId, count);
|
||||
context.notify();
|
||||
}
|
||||
},
|
||||
() => counts.delete(sequenceId),
|
||||
);
|
||||
}
|
||||
|
||||
/** ‹ › open through the core (loading, errors, cones), camera still. */
|
||||
function openStepped(photoId) {
|
||||
context.actions.openImage(photoId, { frame: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* The cones show one page of a sequence: once a photo of the selected
|
||||
* sequence opens on another page (‹ ›, a cone, a line), re-centre them.
|
||||
*/
|
||||
async function recentreCones(photo) {
|
||||
if (!photo?.sequenceId) return;
|
||||
if (state.sequence.selectedId !== photo.sequenceId) return;
|
||||
const drawn = drawnPage;
|
||||
if (drawn?.sequenceId !== photo.sequenceId) return;
|
||||
const window = await windowOf(photo).catch(() => null);
|
||||
// Still the drawn page and the latest photo once the page is in.
|
||||
if (
|
||||
window &&
|
||||
drawnPage === drawn &&
|
||||
window.home !== drawn.page &&
|
||||
anchors.get(photo.sequenceId) === photo
|
||||
)
|
||||
cones.select(photo.sequenceId, { force: true });
|
||||
}
|
||||
|
||||
const viewer = createKartaViewViewer({
|
||||
getPhoto: async (photoId) => {
|
||||
const photo = await getPhoto(photoId);
|
||||
if (photo.sequenceId) anchors.set(photo.sequenceId, photo);
|
||||
return photo;
|
||||
},
|
||||
step,
|
||||
onStep: openStepped,
|
||||
onShown: recentreCones,
|
||||
// After a GPU reset the same photo is drawn again by the viewer
|
||||
// itself: going through the core would cancel a lookup the user
|
||||
// started. A failure still shows.
|
||||
onReopen: (photoId) =>
|
||||
viewer
|
||||
.open(photoId)
|
||||
.catch((error) =>
|
||||
context.actions.reportError(
|
||||
error?.message || 'The KartaView photo could not be loaded',
|
||||
),
|
||||
),
|
||||
render: context.services?.render,
|
||||
...viewerOptions,
|
||||
});
|
||||
|
||||
return {
|
||||
async status() {
|
||||
return { configured: true };
|
||||
},
|
||||
|
||||
init(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
cones.ensureCollections(cesiumViewer);
|
||||
cones.setVisible(false);
|
||||
},
|
||||
|
||||
activate(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
cones.setVisible(true);
|
||||
coverage.attach(cesiumViewer);
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
cones.clearSelection();
|
||||
cones.setVisible(false);
|
||||
drawnPage = null;
|
||||
// Tens of MB of decoded lines are not kept for a source left off.
|
||||
tiles.clear();
|
||||
masks.clear();
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
cones.destroy(cesiumViewer);
|
||||
tiles.clear();
|
||||
masks.clear();
|
||||
},
|
||||
|
||||
refreshCoverage: () => coverage.refresh(),
|
||||
|
||||
/**
|
||||
* The filter changed: the lines come from KartaView again under it
|
||||
* (the old ones show meanwhile), the cones are drawn again.
|
||||
*/
|
||||
setFilter() {
|
||||
const query = tileFilterQuery(context.getFilter());
|
||||
if (query !== drawnQuery) {
|
||||
drawnQuery = query;
|
||||
coverage.reload();
|
||||
}
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
setSurface() {
|
||||
coverage.setSurface();
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
coverageStats() {
|
||||
const { hint } = state.coverage;
|
||||
const count = coverage.sequenceCount();
|
||||
const loading = state.coverage.pending.size > 0;
|
||||
const empty =
|
||||
state.coverage.zoom !== null &&
|
||||
!loading &&
|
||||
count === 0 &&
|
||||
!state.coverage.lastError;
|
||||
return {
|
||||
count,
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading,
|
||||
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
};
|
||||
},
|
||||
|
||||
/** A line opens the photo nearest the click; a cone opens its photo. */
|
||||
handlePick(pickId, { groundPoint } = {}) {
|
||||
if (pickId.startsWith(PICK_PREFIX.image)) {
|
||||
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
|
||||
return true;
|
||||
}
|
||||
if (pickId.startsWith(PICK_PREFIX.line)) {
|
||||
const point = groundPoint?.();
|
||||
if (point) context.actions.openNearest(point);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Never from the cache: it is keyed by sequence, the cones by page.
|
||||
selectSequence: (sequenceId) =>
|
||||
cones.select(sequenceId, { force: true }),
|
||||
clearSequence: () => {
|
||||
cones.clearSelection();
|
||||
drawnPage = null;
|
||||
},
|
||||
// The drive's own count, not the thinned page of it the cones show.
|
||||
sequenceStats: () => ({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images:
|
||||
counts.get(state.sequence.selectedId) ??
|
||||
state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The nearest photo within the search radius that can be shown, or
|
||||
* null. Many photos are archived (their files answer 409) or have no
|
||||
* file at all, so the nearest few are tried in turn.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const filter = context.getFilter();
|
||||
const records = await source.nearestPhotos(
|
||||
{ lat, lon, filter },
|
||||
{ signal },
|
||||
);
|
||||
const near = [];
|
||||
let filteredOut = 0;
|
||||
for (const record of records) {
|
||||
const photo = remember(record);
|
||||
if (!photo?.urls.view) continue;
|
||||
const metres = metresBetween({ lat, lon }, photo);
|
||||
if (metres > NEAREST_RADIUS_M) continue;
|
||||
if (!passesKartaViewFilter(photo, filter)) filteredOut++;
|
||||
else near.push({ photo, metres });
|
||||
}
|
||||
near.sort((a, b) => a.metres - b.metres);
|
||||
// Probed side by side (photo storage is not the API host): the
|
||||
// nearest one that answers wins.
|
||||
const candidates = near.slice(0, NEAREST_PROBES);
|
||||
const answers = await Promise.all(
|
||||
candidates.map(({ photo }) => showable(photo, { signal })),
|
||||
);
|
||||
const hit = candidates.find((_, i) => answers[i]);
|
||||
if (hit) return hit.photo.id;
|
||||
if (near.length) throw new Error(ARCHIVED_MESSAGE);
|
||||
if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE);
|
||||
return null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* A minimal equirectangular panorama renderer on WebGL: one full-screen
|
||||
* triangle whose fragment shader turns each pixel's view ray into texture
|
||||
* coordinates. The image centre is the camera's forward direction; yaw turns
|
||||
* right from it, pitch looks up.
|
||||
*/
|
||||
|
||||
export const PANO_FOV_DEFAULT_DEG = 80;
|
||||
export const PANO_FOV_MIN_DEG = 30;
|
||||
export const PANO_FOV_MAX_DEG = 100;
|
||||
export const PANO_PITCH_LIMIT_DEG = 85;
|
||||
|
||||
const VERTEX = `
|
||||
attribute vec2 a_position;
|
||||
varying vec2 v_ndc;
|
||||
void main() {
|
||||
v_ndc = a_position;
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FRAGMENT = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_image;
|
||||
uniform float u_yaw;
|
||||
uniform float u_pitch;
|
||||
uniform float u_tanHalfFov;
|
||||
uniform float u_aspect;
|
||||
varying vec2 v_ndc;
|
||||
const float PI = 3.141592653589793;
|
||||
void main() {
|
||||
vec3 ray = normalize(vec3(v_ndc.x * u_tanHalfFov * u_aspect, v_ndc.y * u_tanHalfFov, -1.0));
|
||||
float cp = cos(u_pitch), sp = sin(u_pitch);
|
||||
ray = vec3(ray.x, ray.y * cp - ray.z * sp, ray.y * sp + ray.z * cp);
|
||||
float cy = cos(u_yaw), sy = sin(u_yaw);
|
||||
ray = vec3(ray.x * cy - ray.z * sy, ray.y, ray.x * sy + ray.z * cy);
|
||||
float lon = atan(ray.x, -ray.z);
|
||||
float lat = asin(clamp(ray.y, -1.0, 1.0));
|
||||
gl_FragColor = texture2D(u_image, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI));
|
||||
}`;
|
||||
|
||||
/** Wrap degrees into [0, 360). */
|
||||
export function wrapDegrees(value) {
|
||||
return ((value % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
export function clampPitch(degrees) {
|
||||
return Math.max(
|
||||
-PANO_PITCH_LIMIT_DEG,
|
||||
Math.min(PANO_PITCH_LIMIT_DEG, degrees),
|
||||
);
|
||||
}
|
||||
|
||||
export function clampFov(degrees) {
|
||||
return Math.max(PANO_FOV_MIN_DEG, Math.min(PANO_FOV_MAX_DEG, degrees));
|
||||
}
|
||||
|
||||
/**
|
||||
* Texture coordinates the shader samples for a view ray, for tests and
|
||||
* picking: `yaw`/`pitch` in degrees, the ray in camera space (x right, y up,
|
||||
* z backward).
|
||||
*/
|
||||
export function rayToUv(ray, { yaw = 0, pitch = 0 } = {}) {
|
||||
const p = (pitch * Math.PI) / 180;
|
||||
const w = (yaw * Math.PI) / 180;
|
||||
const length = Math.hypot(ray[0], ray[1], ray[2]) || 1;
|
||||
let [x, y, z] = ray.map((v) => v / length);
|
||||
[y, z] = [
|
||||
y * Math.cos(p) - z * Math.sin(p),
|
||||
y * Math.sin(p) + z * Math.cos(p),
|
||||
];
|
||||
[x, z] = [
|
||||
x * Math.cos(w) - z * Math.sin(w),
|
||||
x * Math.sin(w) + z * Math.cos(w),
|
||||
];
|
||||
const lon = Math.atan2(x, -z);
|
||||
const lat = Math.asin(Math.max(-1, Math.min(1, y)));
|
||||
return [0.5 + lon / (2 * Math.PI), 0.5 - lat / Math.PI];
|
||||
}
|
||||
|
||||
function compile(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const log = gl.getShaderInfoLog(shader);
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(`Panorama shader failed: ${log}`);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0;
|
||||
|
||||
/**
|
||||
* A panorama view on `canvas`. Throws when WebGL is unavailable, so the
|
||||
* caller can fall back to a flat picture.
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @returns {{setImage: (image: TexImageSource) => void, setView: (view: {yaw?: number, pitch?: number, fov?: number}) => void, getView: () => {yaw: number, pitch: number, fov: number}, resize: () => void, render: () => void, hasImage: () => boolean, destroy: () => void}}
|
||||
*/
|
||||
export function createPanoramaRenderer(canvas) {
|
||||
const gl =
|
||||
canvas.getContext('webgl', {
|
||||
antialias: false,
|
||||
depth: false,
|
||||
preserveDrawingBuffer: false,
|
||||
}) || canvas.getContext('experimental-webgl');
|
||||
if (!gl) throw new Error('WebGL is unavailable');
|
||||
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
|
||||
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
|
||||
throw new Error('Panorama program failed to link');
|
||||
gl.useProgram(program);
|
||||
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
// One triangle covering the screen.
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 3, -1, -1, 3]),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
const position = gl.getAttribLocation(program, 'a_position');
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
const uniforms = Object.fromEntries(
|
||||
['u_image', 'u_yaw', 'u_pitch', 'u_tanHalfFov', 'u_aspect'].map((name) => [
|
||||
name,
|
||||
gl.getUniformLocation(program, name),
|
||||
]),
|
||||
);
|
||||
const texture = gl.createTexture();
|
||||
let loaded = false;
|
||||
let frame = 0;
|
||||
const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG };
|
||||
|
||||
/** Match the drawing buffer to the canvas's laid-out size. */
|
||||
function syncSize() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
|
||||
const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
syncSize();
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
frame = 0;
|
||||
if (gl.isContextLost()) return;
|
||||
// Every frame: the canvas may have been laid out since the last one.
|
||||
syncSize();
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
if (!loaded) return;
|
||||
gl.uniform1i(uniforms.u_image, 0);
|
||||
gl.uniform1f(uniforms.u_yaw, (view.yaw * Math.PI) / 180);
|
||||
gl.uniform1f(uniforms.u_pitch, (view.pitch * Math.PI) / 180);
|
||||
gl.uniform1f(uniforms.u_tanHalfFov, Math.tan((view.fov * Math.PI) / 360));
|
||||
gl.uniform1f(uniforms.u_aspect, canvas.width / Math.max(1, canvas.height));
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
return {
|
||||
setImage(image) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
// RGBA: the format a decoded ImageBitmap uploads without conversion.
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
image,
|
||||
);
|
||||
// The pixels are on the GPU now; a bitmap's copy can go.
|
||||
image.close?.();
|
||||
// Linear only: a mipmapped lookup draws a seam where longitude wraps.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
const repeat =
|
||||
isPowerOfTwo(image.width) && isPowerOfTwo(image.height)
|
||||
? gl.REPEAT
|
||||
: gl.CLAMP_TO_EDGE;
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
loaded = true;
|
||||
resize();
|
||||
scheduleRender();
|
||||
},
|
||||
setView(next = {}) {
|
||||
if (Number.isFinite(next.yaw)) view.yaw = wrapDegrees(next.yaw);
|
||||
if (Number.isFinite(next.pitch)) view.pitch = clampPitch(next.pitch);
|
||||
if (Number.isFinite(next.fov)) view.fov = clampFov(next.fov);
|
||||
scheduleRender();
|
||||
},
|
||||
getView: () => ({ ...view }),
|
||||
resize,
|
||||
render: scheduleRender,
|
||||
hasImage: () => loaded,
|
||||
clear() {
|
||||
loaded = false;
|
||||
// Hand the 32 MB texture back: a closed viewer keeps only 1×1.
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array(4),
|
||||
);
|
||||
scheduleRender();
|
||||
},
|
||||
destroy() {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
loaded = false;
|
||||
gl.deleteTexture(texture);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PANO_FOV_MAX_DEG,
|
||||
PANO_FOV_MIN_DEG,
|
||||
PANO_PITCH_LIMIT_DEG,
|
||||
clampFov,
|
||||
clampPitch,
|
||||
createPanoramaRenderer,
|
||||
rayToUv,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
|
||||
const close = (actual, expected) =>
|
||||
actual.forEach((value, i) =>
|
||||
assert.ok(Math.abs(value - expected[i]) < 1e-9, `${actual} ≉ ${expected}`),
|
||||
);
|
||||
|
||||
test('straight ahead samples the image centre; yaw turns right, pitch looks up', () => {
|
||||
close(rayToUv([0, 0, -1]), [0.5, 0.5]);
|
||||
close(rayToUv([0, 0, -1], { yaw: 90 }), [0.75, 0.5]);
|
||||
close(rayToUv([0, 0, -1], { yaw: -90 }), [0.25, 0.5]);
|
||||
close(rayToUv([0, 0, -1], { pitch: 45 }), [0.5, 0.25]);
|
||||
close(rayToUv([0, 0, -1], { pitch: -90 }), [0.5, 1]);
|
||||
// Pitch first, then yaw: looking up-right stays up.
|
||||
close(rayToUv([0, 0, -1], { yaw: 90, pitch: 45 }), [0.75, 0.25]);
|
||||
// A ray to the right of the screen is the same as turning right.
|
||||
close(rayToUv([1, 0, 0]), [0.75, 0.5]);
|
||||
});
|
||||
|
||||
test('view limits: pitch stops short of the poles, field of view stays usable', () => {
|
||||
assert.equal(clampPitch(120), PANO_PITCH_LIMIT_DEG);
|
||||
assert.equal(clampPitch(-120), -PANO_PITCH_LIMIT_DEG);
|
||||
assert.equal(clampFov(5), PANO_FOV_MIN_DEG);
|
||||
assert.equal(clampFov(170), PANO_FOV_MAX_DEG);
|
||||
assert.equal(wrapDegrees(-30), 330);
|
||||
assert.equal(wrapDegrees(725), 5);
|
||||
});
|
||||
|
||||
/**
|
||||
* A WebGL stand-in that records draw calls and uniform values; `lost` makes
|
||||
* it report a lost context.
|
||||
*/
|
||||
function stubGl() {
|
||||
const gl = {
|
||||
lost: false,
|
||||
calls: [],
|
||||
uniforms: {},
|
||||
isContextLost: () => gl.lost,
|
||||
getShaderParameter: () => true,
|
||||
getProgramParameter: () => true,
|
||||
getUniformLocation: (program, name) => name,
|
||||
getExtension: () => null,
|
||||
uniform1f: (name, value) => (gl.uniforms[name] = value),
|
||||
};
|
||||
return new Proxy(gl, {
|
||||
get(target, key) {
|
||||
if (key in target) return target[key];
|
||||
if (typeof key === 'string' && /^[A-Z_0-9]+$/.test(key)) return key;
|
||||
return (...args) => {
|
||||
target.calls.push(key);
|
||||
return { key, args };
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A panorama on a stub canvas, with frames run when the test says. */
|
||||
function stubPanorama(t, { width = 400, height = 250 } = {}) {
|
||||
const gl = stubGl();
|
||||
const canvas = {
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
width: 300,
|
||||
height: 150,
|
||||
getContext: () => gl,
|
||||
};
|
||||
const frames = [];
|
||||
const saved = {
|
||||
request: globalThis.requestAnimationFrame,
|
||||
cancel: globalThis.cancelAnimationFrame,
|
||||
};
|
||||
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = saved.request;
|
||||
globalThis.cancelAnimationFrame = saved.cancel;
|
||||
});
|
||||
const renderer = createPanoramaRenderer(canvas);
|
||||
const runFrames = () => {
|
||||
while (frames.length) frames.shift()();
|
||||
};
|
||||
return { gl, canvas, renderer, runFrames };
|
||||
}
|
||||
|
||||
const IMAGE = { width: 1024, height: 512 };
|
||||
|
||||
test('a panorama draws once it has an image; looking up and right are positive angles', (t) => {
|
||||
const { gl, renderer, runFrames } = stubPanorama(t);
|
||||
renderer.render();
|
||||
runFrames();
|
||||
assert.equal(gl.calls.includes('drawArrays'), false, 'nothing to draw yet');
|
||||
renderer.setImage(IMAGE);
|
||||
renderer.setView({ yaw: 30, pitch: 30 });
|
||||
runFrames();
|
||||
assert.ok(gl.calls.includes('drawArrays'));
|
||||
assert.ok(gl.uniforms.u_pitch > 0, `u_pitch ${gl.uniforms.u_pitch}`);
|
||||
assert.ok(Math.abs(gl.uniforms.u_pitch - Math.PI / 6) < 1e-9);
|
||||
assert.ok(Math.abs(gl.uniforms.u_yaw - Math.PI / 6) < 1e-9);
|
||||
renderer.setView({ pitch: -20 });
|
||||
runFrames();
|
||||
assert.ok(gl.uniforms.u_pitch < 0, 'looking down is negative');
|
||||
});
|
||||
|
||||
test('a lost WebGL context draws nothing', (t) => {
|
||||
const { gl, renderer, runFrames } = stubPanorama(t);
|
||||
renderer.setImage(IMAGE);
|
||||
gl.lost = true;
|
||||
gl.calls.length = 0;
|
||||
runFrames();
|
||||
assert.deepEqual(
|
||||
gl.calls.filter((call) =>
|
||||
['viewport', 'clear', 'drawArrays'].includes(call),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('the canvas is measured again when it draws, not only when its image arrived', (t) => {
|
||||
// The image arrives while the canvas is still hidden (0×0)…
|
||||
const { canvas, renderer, runFrames } = stubPanorama(t, {
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
renderer.setImage(IMAGE);
|
||||
assert.deepEqual([canvas.width, canvas.height], [1, 1]);
|
||||
// …and is laid out before the frame draws.
|
||||
canvas.clientWidth = 400;
|
||||
canvas.clientHeight = 250;
|
||||
runFrames();
|
||||
assert.deepEqual([canvas.width, canvas.height], [400, 250]);
|
||||
});
|
||||
|
||||
test('an image uploads as RGBA and its bitmap is released; closing hands the texture back as 1×1', (t) => {
|
||||
const { gl, renderer } = stubPanorama(t);
|
||||
const uploads = [];
|
||||
gl.texImage2D = (...args) => uploads.push(args);
|
||||
let closed = false;
|
||||
renderer.setImage({
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
close: () => (closed = true),
|
||||
});
|
||||
assert.equal(uploads.length, 1);
|
||||
assert.equal(
|
||||
uploads[0][2],
|
||||
'RGBA',
|
||||
'the format a decoded bitmap uploads without conversion',
|
||||
);
|
||||
assert.equal(closed, true, 'the decoded copy is freed once on the GPU');
|
||||
renderer.clear();
|
||||
const last = uploads.at(-1);
|
||||
assert.deepEqual(
|
||||
[last[3], last[4]],
|
||||
[1, 1],
|
||||
'a closed viewer keeps a 1×1 texture',
|
||||
);
|
||||
assert.equal(renderer.hasImage(), false);
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for the KartaView street-level provider (keyless). */
|
||||
export const KARTAVIEW_PROVIDER_ID = 'kartaview';
|
||||
export const KARTAVIEW_NAME = 'KartaView';
|
||||
export const KARTAVIEW_LABEL = 'KARTAVIEW';
|
||||
|
||||
/**
|
||||
* The public API. Its documented twin, api.openstreetcam.org, still answers
|
||||
* but serves empty coverage tiles.
|
||||
*/
|
||||
export const KARTAVIEW_API_HOST = 'https://api.kartaview.org';
|
||||
/** KartaView's image proxy: resizes a photo given its base64url address. */
|
||||
export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org';
|
||||
|
||||
/** Stable id prefixes for picked primitives; every one starts with `kv:`. */
|
||||
export const PICK_PREFIX = Object.freeze({
|
||||
root: 'kv:',
|
||||
line: 'kv:line:',
|
||||
image: 'kv:img:',
|
||||
});
|
||||
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: PROVIDER_COLORS.kartaview,
|
||||
selected: SHARED_COLORS.selected,
|
||||
});
|
||||
|
||||
/**
|
||||
* Coverage lines are OSM ways with photos, from z13–14 JSON tiles. Lower
|
||||
* zooms take seconds per tile (z10 does not answer), so higher up the panel
|
||||
* asks the user to zoom in.
|
||||
*/
|
||||
export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500;
|
||||
export const COVERAGE_MAX_HEIGHT_M = 6_000;
|
||||
export const COVERAGE_TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 0,
|
||||
// Chained ways are short: a dense downtown z13 tile holds ~1,500, so each
|
||||
// tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn).
|
||||
maxSequences: 14_400,
|
||||
lineWidthPx: 2.5,
|
||||
overviewPointPx: 2.5,
|
||||
moveDebounceMs: 320,
|
||||
viewRangeMinM: 2_500,
|
||||
viewRangePerHeight: 10,
|
||||
viewNearM: 1_000,
|
||||
});
|
||||
export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage';
|
||||
export const NO_COVERAGE_HINT = 'no KartaView photos in view';
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'KartaView is rate-limiting requests — they resume on their own';
|
||||
|
||||
/**
|
||||
* The photo API answers 429 to more than about three requests at once, so
|
||||
* at most two are in flight; a 429 is retried after a short wait.
|
||||
*/
|
||||
export const PHOTO_API_CONCURRENCY = 2;
|
||||
export const PHOTO_API_RETRIES = 3;
|
||||
export const PHOTO_API_RETRY_BASE_MS = 800;
|
||||
/** A Retry-After longer than this is not waited out. */
|
||||
export const PHOTO_API_RETRY_MAX_MS = 10_000;
|
||||
/** The API is HTTP/1.1 (six connections): tiles 3, lookups 2, pages 1. */
|
||||
export const TILE_CONCURRENCY = 3;
|
||||
export const PAGE_CONCURRENCY = 1;
|
||||
export const TILE_TIMEOUT_MS = 20_000;
|
||||
export const PHOTO_API_TIMEOUT_MS = 15_000;
|
||||
/**
|
||||
* The first try of a lookup: KartaView sometimes sits on a lookup for 11 s
|
||||
* and more, then answers the same one again in 3 s.
|
||||
*/
|
||||
export const PHOTO_API_FIRST_TIMEOUT_MS = 8_000;
|
||||
/** A sequence page is about 650 kB of JSON. */
|
||||
export const PAGE_TIMEOUT_MS = 30_000;
|
||||
/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */
|
||||
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
/** Whether a photo file is there (not archived): headers only. */
|
||||
export const IMAGE_PROBE_TIMEOUT_MS = 8_000;
|
||||
/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */
|
||||
export const NEAREST_PROBES = 6;
|
||||
export const ARCHIVED_MESSAGE =
|
||||
'KartaView’s photos here are archived and cannot be shown';
|
||||
|
||||
/** Photos asked for around a point; the API returns them nearest first. */
|
||||
export const NEAREST_LIMIT = 10;
|
||||
/** The photo API's search zoom: 18 searches the few tens of metres around the point. */
|
||||
export const NEAREST_SEARCH_ZOOM = 18;
|
||||
|
||||
/**
|
||||
* Photos of a sequence fetched at once, around the open one. Records are
|
||||
* ~2.6 kB each, so a page is ~650 kB; the cones and ‹ › use it. Pages are in
|
||||
* photo-id order, which only roughly follows the sequence index.
|
||||
*/
|
||||
export const SEQUENCE_PAGE_SIZE = 250;
|
||||
/** Within this many photos of a page's edge, its neighbour page joins it. */
|
||||
export const SEQUENCE_PAGE_EDGE = 12;
|
||||
|
||||
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
|
||||
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
||||
/** Enough for a docked viewer; an expanded one gets the full size. */
|
||||
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
/** A flat photo's first version, then its sharp one. */
|
||||
export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]);
|
||||
export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
|
||||
|
||||
/** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */
|
||||
export const KARTAVIEW_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, CC BY-SA 4.0';
|
||||
|
||||
/**
|
||||
* The imagery filter (360° or flat, SINCE). KartaView filters coverage on
|
||||
* its server only in its PNG tiles: its JSON tiles come back empty once a
|
||||
* filter is asked for. So a filtered PNG tile is a mask, and a JSON line is
|
||||
* kept where it lies on the mask's drawn pixels.
|
||||
*
|
||||
* KartaView's date filter is the upload date, and SINCE goes by it
|
||||
* throughout (lines, the photo a click opens, the cones), as KartaView's own
|
||||
* map does; the panel says so under the slider.
|
||||
*/
|
||||
export const MASK_TIMEOUT_MS = 20_000;
|
||||
/**
|
||||
* A filtered PNG tile is drawn on demand and a dense one can outlast
|
||||
* KartaView's own 60 s (504); it keeps drawing, and has the tile a few
|
||||
* seconds later. A timeout or a 5xx is asked again this many times in all.
|
||||
*/
|
||||
export const MASK_ATTEMPTS = 4;
|
||||
export const MASK_RETRY_WAIT_MS = 4_000;
|
||||
/** Real masks are tens of kilobytes. */
|
||||
export const MASK_MAX_BYTES = 2 * 1024 * 1024;
|
||||
/** A segment is kept when this share of the points along it lies on the mask. */
|
||||
export const MASK_KEEP_SHARE = 0.5;
|
||||
/** Points along a segment are tested this many mask pixels apart. */
|
||||
export const MASK_SAMPLE_PX = 2;
|
||||
export const MASK_SLOW_MESSAGE =
|
||||
'KartaView did not filter this area in time — move the map to ask again';
|
||||
export const FILTERED_OUT_MESSAGE =
|
||||
'None of KartaView’s photos here pass the imagery filter';
|
||||
/** Under the SINCE slider while KartaView is lit. */
|
||||
export const SINCE_NOTE = 'KartaView: by upload date';
|
||||
|
||||
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
|
||||
function utcDay(ms) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const filtersSince = (filter) =>
|
||||
Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0;
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Whether a photo passes the resolved imagery filter by its projection and
|
||||
* upload date, the cut-off taken as its UTC day, as KartaView's startDate.
|
||||
* @param {{isPano: boolean, uploadedAt: number|null}} photo
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter
|
||||
*/
|
||||
export function passesKartaViewFilter(photo, filter) {
|
||||
if (!filter) return true;
|
||||
if (filter.pano === 'pano' && !photo.isPano) return false;
|
||||
if (filter.pano === 'flat' && photo.isPano) return false;
|
||||
if (!filtersSince(filter)) return true;
|
||||
const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS;
|
||||
return (photo.uploadedAt ?? 0) >= day;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query of a filtered PNG coverage tile, or '' when nothing is
|
||||
* filtered. The day, not the instant, so it is stable for a day (and keys
|
||||
* the mask cache).
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
|
||||
*/
|
||||
export function tileFilterQuery(filter) {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.pano === 'pano') params.set('fieldOfView', '360');
|
||||
else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane');
|
||||
if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The photo search's own filter: projection and upload date (its
|
||||
* `fieldOfView` is ignored).
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function photoFilterParams(filter) {
|
||||
const params = {};
|
||||
if (filter?.pano === 'pano') params.projection = 'SPHERE';
|
||||
else if (filter?.pano === 'flat') params.projection = 'PLANE';
|
||||
if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs);
|
||||
return params;
|
||||
}
|
||||
|
||||
/** The cone and coverage noun: a line is a street with photos, not a sequence. */
|
||||
export const COVERAGE_NOUN = 'streets';
|
||||
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
|
||||
|
||||
/** Base64url of a string, as the image proxy reads addresses. */
|
||||
function base64Url(text) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** A photo resized by KartaView's image proxy to fit `width`×`height`. */
|
||||
export function resizedImageUrl(url, [width, height]) {
|
||||
if (!url) return null;
|
||||
return `${KARTAVIEW_CDN_HOST}/pr:sharp/rs:fit:${width}:${height}/${base64Url(url)}`;
|
||||
}
|
||||
|
||||
/** Epoch ms for a KartaView timestamp ('2016-02-19 15:28:18.000', UTC), or null. */
|
||||
export function kartaViewTimeMs(value) {
|
||||
const match =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(
|
||||
String(value ?? ''),
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, y, mo, d, h = '0', mi = '0', s = '0'] = match;
|
||||
if (Number(y) < 1990) return null;
|
||||
const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */
|
||||
export function bearingBetween(a, b) {
|
||||
const rad = Math.PI / 180;
|
||||
const dLon = (b.lon - a.lon) * rad;
|
||||
const y = Math.sin(dLon) * Math.cos(b.lat * rad);
|
||||
const x =
|
||||
Math.cos(a.lat * rad) * Math.sin(b.lat * rad) -
|
||||
Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon);
|
||||
return (Math.atan2(y, x) / rad + 360) % 360;
|
||||
}
|
||||
|
||||
/** Photos closer than this say nothing about the direction of travel. */
|
||||
const COURSE_MIN_STEP_M = 2;
|
||||
/** How many photos along to look for one far enough away. */
|
||||
const COURSE_SEARCH = 5;
|
||||
|
||||
/**
|
||||
* Give each photo of a drive (in index order) its `course`: the direction
|
||||
* of travel from it to the next photo a few metres on (from the previous one
|
||||
* at the end). KartaView is dashcam imagery, so this is where the camera
|
||||
* looks; its `heading` is the device compass, which reads 0 without one and
|
||||
* can sit 110° off inside a car.
|
||||
* @param {Array<{lon: number, lat: number, course?: number|null}>} photos
|
||||
* @param {(a: object, b: object) => number} metres
|
||||
*/
|
||||
export function assignCourses(photos, metres) {
|
||||
photos.forEach((photo, i) => {
|
||||
let course = null;
|
||||
for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++)
|
||||
if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) {
|
||||
course = bearingBetween(photo, photos[j]);
|
||||
break;
|
||||
}
|
||||
for (
|
||||
let k = i - 1;
|
||||
course === null && k >= i - COURSE_SEARCH && k >= 0;
|
||||
k--
|
||||
)
|
||||
if (metres(photos[k], photo) >= COURSE_MIN_STEP_M)
|
||||
course = bearingBetween(photos[k], photo);
|
||||
photo.course = course;
|
||||
});
|
||||
return photos;
|
||||
}
|
||||
|
||||
/** Where the camera looked: the direction of travel, else the compass, else unknown. */
|
||||
export function cameraBearing(photo) {
|
||||
return photo?.course ?? photo?.heading ?? null;
|
||||
}
|
||||
|
||||
/** A coordinate pair as numbers; the road-matched one when KartaView has it. */
|
||||
function positionOf(record) {
|
||||
const matchLat = Number(record.matchLat);
|
||||
const matchLon = Number(record.matchLng);
|
||||
if (
|
||||
Number.isFinite(matchLat) &&
|
||||
Number.isFinite(matchLon) &&
|
||||
(matchLat !== 0 || matchLon !== 0)
|
||||
)
|
||||
return { lat: matchLat, lon: matchLon };
|
||||
return { lat: Number(record.lat), lon: Number(record.lng) };
|
||||
}
|
||||
|
||||
const STORAGE_HOST = /^storage\d+$/;
|
||||
|
||||
/** A KartaView photo-storage address, or null for anything else. */
|
||||
function storageUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
return url.protocol === 'https:' &&
|
||||
/^storage\d+\.openstreetcam\.org$/.test(url.hostname)
|
||||
? url.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo file's address. The API's `fileurl*` fields always name storage1,
|
||||
* but a photo whose `storage` is another host (storage2…) is only there, so
|
||||
* the address is built from `storage` and the relative `filepath*`. A
|
||||
* `fileurl*` fallback must itself be on KartaView's storage.
|
||||
*/
|
||||
function fileUrl(record, pathKey, urlKey) {
|
||||
const path = record[pathKey];
|
||||
if (STORAGE_HOST.test(record.storage || '') && path)
|
||||
return storageUrl(
|
||||
`https://${record.storage}.openstreetcam.org/files/photo/${path}`,
|
||||
);
|
||||
return storageUrl(record[urlKey]);
|
||||
}
|
||||
|
||||
/** A storage URL with its `{{sizeprefix}}` placeholder filled. */
|
||||
function sized(record, prefix) {
|
||||
const fill = (value) =>
|
||||
typeof value === 'string' && value.includes('{{sizeprefix}}')
|
||||
? value.replace('{{sizeprefix}}', prefix)
|
||||
: null;
|
||||
const path = fill(record.filepath);
|
||||
return fileUrl(
|
||||
{ ...record, filepath: path, fileurl: fill(record.fileurl) },
|
||||
'filepath',
|
||||
'fileurl',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo API record in the shape the provider uses, or null without an id
|
||||
* or a position.
|
||||
* @returns {{id: string, sequenceId: string|null, sequenceIndex: number|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, urls: {view: string|null, full: string|null, pano: string|null}}|null}
|
||||
*/
|
||||
export function normalizePhoto(record) {
|
||||
if (!record || record.id == null) return null;
|
||||
const { lat, lon } = positionOf(record);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
// 0,0 is a missing fix, not a photo in the Gulf of Guinea.
|
||||
if (lat === 0 && lon === 0) return null;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
||||
// An exact 0 is a device without a compass (whole drives read 0), not north.
|
||||
const heading =
|
||||
record.heading === '' || record.heading == null
|
||||
? NaN
|
||||
: Number(record.heading) || NaN;
|
||||
const isPano =
|
||||
record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360;
|
||||
const full =
|
||||
fileUrl(record, 'filepathProc', 'fileurlProc') || sized(record, 'proc');
|
||||
const index = Number(record.sequenceIndex);
|
||||
return {
|
||||
id: String(record.id),
|
||||
sequenceId: record.sequenceId != null ? String(record.sequenceId) : null,
|
||||
sequenceIndex: Number.isFinite(index) ? index : null,
|
||||
lon,
|
||||
lat,
|
||||
heading: Number.isFinite(heading) ? heading : null,
|
||||
isPano,
|
||||
capturedAt:
|
||||
kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded),
|
||||
// KartaView's SINCE goes by this (its server filters by upload date).
|
||||
uploadedAt: kartaViewTimeMs(record.dateAdded),
|
||||
urls: {
|
||||
// A 1280 px rendition; for a 360° photo it is the front view only.
|
||||
view:
|
||||
fileUrl(record, 'filepathLTh', 'fileurlLTh') ||
|
||||
sized(record, 'lth') ||
|
||||
full,
|
||||
full,
|
||||
// A 360° photo's equirectangular image ('wrapped_proc').
|
||||
pano: isPano && /wrapped/.test(full || '') ? full : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the image link goes. KartaView's photo pages fail for signed-out
|
||||
* visitors ("Sequence cannot be loaded"), so it is the full-size photo
|
||||
* through KartaView's image proxy, which also serves photos whose storage
|
||||
* host answers 502 directly.
|
||||
*/
|
||||
export function kartaViewPhotoUrl(photo) {
|
||||
const url = photo?.urls?.full || photo?.urls?.view;
|
||||
return url
|
||||
? `${KARTAVIEW_CDN_HOST}/pr:sharp/${base64Url(url)}`
|
||||
: 'https://kartaview.org';
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assignCourses,
|
||||
bearingBetween,
|
||||
cameraBearing,
|
||||
kartaViewPhotoUrl,
|
||||
kartaViewTimeMs,
|
||||
normalizePhoto,
|
||||
passesKartaViewFilter,
|
||||
photoFilterParams,
|
||||
resizedImageUrl,
|
||||
tileFilterQuery,
|
||||
} from './policy.js';
|
||||
|
||||
/** A photo record as /2.0/photo returns it (trimmed to what is read). */
|
||||
function record(overrides = {}) {
|
||||
return {
|
||||
id: '7102491',
|
||||
sequenceId: '14010',
|
||||
sequenceIndex: '950',
|
||||
lat: '42.335712',
|
||||
lng: '-83.048301',
|
||||
matchLat: '42.335731506347656',
|
||||
matchLng: '-83.04828643798828',
|
||||
heading: '299.58',
|
||||
fieldOfView: '0',
|
||||
projection: 'PLANE',
|
||||
shotDate: '2016-08-23 20:17:47.000',
|
||||
dateAdded: '2016-08-24 04:01:07',
|
||||
storage: 'storage1',
|
||||
filepath: '2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
filepathLTh: '2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
filepathProc: '2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
fileurl:
|
||||
'https://storage1.openstreetcam.org/files/photo/2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
fileurlLTh:
|
||||
'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
fileurlProc:
|
||||
'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('a photo record normalizes to numbers, the road-matched position and UTC capture time', () => {
|
||||
const photo = normalizePhoto(record());
|
||||
assert.equal(photo.id, '7102491');
|
||||
assert.equal(photo.sequenceId, '14010');
|
||||
assert.equal(photo.sequenceIndex, 950);
|
||||
assert.equal(photo.lat, 42.335731506347656);
|
||||
assert.equal(photo.lon, -83.04828643798828);
|
||||
assert.equal(photo.heading, 299.58);
|
||||
assert.equal(photo.isPano, false);
|
||||
assert.equal(photo.capturedAt, Date.UTC(2016, 7, 23, 20, 17, 47));
|
||||
assert.equal(photo.urls.pano, null);
|
||||
});
|
||||
|
||||
test('photo files come from the storage host the record names, not the one its URLs say', () => {
|
||||
// The API writes storage1 into every URL; this photo lives on storage2.
|
||||
const photo = normalizePhoto(record({ storage: 'storage2' }));
|
||||
assert.equal(
|
||||
photo.urls.view,
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
);
|
||||
assert.equal(
|
||||
photo.urls.full,
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
);
|
||||
// The link goes through the image proxy, which serves photos whose
|
||||
// storage host answers 502 directly.
|
||||
const link = kartaViewPhotoUrl(photo);
|
||||
assert.match(link, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/[\w-]+$/);
|
||||
assert.equal(
|
||||
Buffer.from(link.split('/').at(-1), 'base64url').toString(),
|
||||
photo.urls.full,
|
||||
);
|
||||
// A fallback address must be on KartaView's storage, over https.
|
||||
for (const fileurlLTh of [
|
||||
'https://tracker.example/x.jpg',
|
||||
'http://storage1.openstreetcam.org/a.jpg',
|
||||
'https://storage1.openstreetcam.org.evil.example/a.jpg',
|
||||
'javascript:alert(1)',
|
||||
]) {
|
||||
const hostile = normalizePhoto(
|
||||
record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }),
|
||||
);
|
||||
assert.notEqual(hostile.urls.view, fileurlLTh);
|
||||
assert.doesNotMatch(
|
||||
String(hostile.urls.view),
|
||||
/tracker|javascript|evil|^http:/,
|
||||
);
|
||||
}
|
||||
// An unexpected storage value falls back to the URLs as given.
|
||||
const odd = normalizePhoto(record({ storage: 'https://evil.example' }));
|
||||
assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//);
|
||||
// No large thumbnail path: the size prefix fills in.
|
||||
const bare = normalizePhoto(
|
||||
record({ filepathLTh: '', fileurlLTh: '', storage: 'storage3' }),
|
||||
);
|
||||
assert.equal(
|
||||
bare.urls.view,
|
||||
'https://storage3.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('a 360° photo keeps its equirectangular image; the unmatched position is the fallback', () => {
|
||||
const photo = normalizePhoto(
|
||||
record({
|
||||
projection: 'SPHERE',
|
||||
fieldOfView: '360',
|
||||
matchLat: '0',
|
||||
matchLng: '0',
|
||||
storage: 'storage13',
|
||||
filepathProc: '2022/10/6/wrapped_proc/6187609_626b8.jpg',
|
||||
}),
|
||||
);
|
||||
assert.equal(photo.isPano, true);
|
||||
assert.equal(photo.lat, 42.335712);
|
||||
assert.equal(
|
||||
photo.urls.pano,
|
||||
'https://storage13.openstreetcam.org/files/photo/2022/10/6/wrapped_proc/6187609_626b8.jpg',
|
||||
);
|
||||
// A sphere without a wrapped image has nothing to project.
|
||||
assert.equal(
|
||||
normalizePhoto(record({ projection: 'SPHERE', fieldOfView: '360' })).urls
|
||||
.pano,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('records without an id or a position are dropped; a missing heading is null', () => {
|
||||
assert.equal(normalizePhoto(null), null);
|
||||
// 0,0 is a missing fix.
|
||||
assert.equal(
|
||||
normalizePhoto(
|
||||
record({ lat: '0', lng: '0', matchLat: '0', matchLng: '0' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(normalizePhoto(record({ lat: '95', matchLat: '95' })), null);
|
||||
assert.equal(normalizePhoto(record({ id: undefined })), null);
|
||||
assert.equal(
|
||||
normalizePhoto(
|
||||
record({ lat: 'x', lng: 'y', matchLat: undefined, matchLng: undefined }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(normalizePhoto(record({ heading: '' })).heading, null);
|
||||
// A device without a compass reads 0 for a whole drive: unknown, not north.
|
||||
assert.equal(normalizePhoto(record({ heading: '0' })).heading, null);
|
||||
assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5);
|
||||
assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null);
|
||||
});
|
||||
|
||||
test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => {
|
||||
assert.equal(kartaViewTimeMs('2022-03-21'), Date.UTC(2022, 2, 21));
|
||||
assert.equal(
|
||||
kartaViewTimeMs('2022-03-21T06:42:59'),
|
||||
Date.UTC(2022, 2, 21, 6, 42, 59),
|
||||
);
|
||||
assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null);
|
||||
assert.equal(kartaViewTimeMs(''), null);
|
||||
assert.equal(kartaViewTimeMs(null), null);
|
||||
const fallback = normalizePhoto(record({ shotDate: null }));
|
||||
assert.equal(fallback.capturedAt, Date.UTC(2016, 7, 24, 4, 1, 7));
|
||||
});
|
||||
|
||||
test('resized images go through the proxy with a base64url address', () => {
|
||||
const url = resizedImageUrl(
|
||||
'https://storage13.openstreetcam.org/files/photo/a?b=c+d',
|
||||
[1024, 512],
|
||||
);
|
||||
const [, encoded] = /\/rs:fit:1024:512\/(.+)$/.exec(url);
|
||||
assert.match(url, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:/);
|
||||
assert.doesNotMatch(encoded, /[+/=]/);
|
||||
const decoded = Buffer.from(
|
||||
encoded.replace(/-/g, '+').replace(/_/g, '/'),
|
||||
'base64',
|
||||
).toString();
|
||||
assert.equal(
|
||||
decoded,
|
||||
'https://storage13.openstreetcam.org/files/photo/a?b=c+d',
|
||||
);
|
||||
assert.equal(resizedImageUrl(null, [1, 1]), null);
|
||||
});
|
||||
|
||||
test('a storage value that only contains a storage host name builds no address from it', () => {
|
||||
for (const storage of ['evil.example/#storage1', 'storage1.evil.example']) {
|
||||
const photo = normalizePhoto(record({ storage }));
|
||||
// Not a storage host: the API's own storage URLs serve.
|
||||
assert.equal(
|
||||
photo.urls.view,
|
||||
'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
storage,
|
||||
);
|
||||
assert.equal(
|
||||
photo.urls.full,
|
||||
'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
storage,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 360° field of view makes a panorama whatever the projection says', () => {
|
||||
assert.equal(
|
||||
normalizePhoto(record({ projection: 'PLANE', fieldOfView: '360' })).isPano,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
normalizePhoto(record({ projection: 'PLANE', fieldOfView: '359' })).isPano,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a longitude past ±180 is not a position', () => {
|
||||
assert.equal(normalizePhoto(record({ lng: '181', matchLng: '181' })), null);
|
||||
assert.equal(normalizePhoto(record({ lng: '-181', matchLng: '-181' })), null);
|
||||
assert.equal(
|
||||
normalizePhoto(record({ lng: '180', matchLng: '180' })).lon,
|
||||
180,
|
||||
);
|
||||
});
|
||||
|
||||
test('with no processed-photo path the size prefix gives the full photo', () => {
|
||||
const photo = normalizePhoto(
|
||||
record({ filepathProc: '', fileurlProc: '', storage: 'storage3' }),
|
||||
);
|
||||
assert.equal(
|
||||
photo.urls.full,
|
||||
'https://storage3.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
);
|
||||
// Only the large thumbnail, no path at all: the thumbnail still shows.
|
||||
const thumbOnly = normalizePhoto(
|
||||
record({
|
||||
filepathProc: '',
|
||||
fileurlProc: '',
|
||||
filepath: '',
|
||||
fileurl: '',
|
||||
storage: 'storage3',
|
||||
}),
|
||||
);
|
||||
assert.equal(thumbOnly.urls.full, null);
|
||||
assert.match(
|
||||
thumbOnly.urls.view,
|
||||
/^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//,
|
||||
);
|
||||
});
|
||||
|
||||
test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => {
|
||||
// North along a meridian, 5 m apart, then a repeat of the last position.
|
||||
const step = 5 / 110_540;
|
||||
const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step }));
|
||||
photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place
|
||||
const metres = (a, b) =>
|
||||
Math.hypot(
|
||||
(b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180),
|
||||
(b.lat - a.lat) * 110_540,
|
||||
);
|
||||
assignCourses(photos, metres);
|
||||
for (const photo of photos)
|
||||
assert.ok(
|
||||
Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5,
|
||||
`${photo.course}`,
|
||||
);
|
||||
// Eastward: 90°.
|
||||
const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 }));
|
||||
assignCourses(east, metres);
|
||||
assert.ok(
|
||||
Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5,
|
||||
);
|
||||
// A lone photo has no course; the compass is the fallback, then unknown.
|
||||
const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres);
|
||||
assert.equal(lone.course, null);
|
||||
assert.equal(cameraBearing({ ...lone, heading: 42 }), 42);
|
||||
assert.equal(cameraBearing({ ...lone, heading: null }), null);
|
||||
assert.equal(
|
||||
cameraBearing({ course: 90, heading: 200 }),
|
||||
90,
|
||||
'the drive beats the compass',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) <
|
||||
1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('the imagery filter becomes KartaView’s tile query and photo-search params', () => {
|
||||
// 2026-03-05 23:30 UTC: the cut-off day is the UTC one.
|
||||
const sinceMs = Date.UTC(2026, 2, 5, 23, 30);
|
||||
assert.equal(tileFilterQuery(null), '');
|
||||
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), '');
|
||||
assert.equal(
|
||||
tileFilterQuery({ pano: 'pano', sinceMs: null }),
|
||||
'fieldOfView=360',
|
||||
);
|
||||
assert.equal(
|
||||
tileFilterQuery({ pano: 'flat', sinceMs }),
|
||||
'fieldOfView=plane&startDate=2026-03-05',
|
||||
);
|
||||
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), '');
|
||||
// The search ignores fieldOfView; its projection filters instead.
|
||||
assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {});
|
||||
assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), {
|
||||
projection: 'SPHERE',
|
||||
startDate: '2026-03-05',
|
||||
});
|
||||
assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), {
|
||||
projection: 'PLANE',
|
||||
});
|
||||
});
|
||||
|
||||
test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => {
|
||||
const photo = normalizePhoto(
|
||||
record({
|
||||
shotDate: '2016-08-23 20:17:47.000',
|
||||
dateAdded: '2026-03-05 00:10:00',
|
||||
}),
|
||||
);
|
||||
assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10));
|
||||
const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) });
|
||||
// Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's
|
||||
// startDate=2026-03-05 keeps it.
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }),
|
||||
true,
|
||||
);
|
||||
assert.equal(passesKartaViewFilter(photo, null), true);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(
|
||||
{ ...photo, uploadedAt: null },
|
||||
since('2020-01-01T00:00:00Z'),
|
||||
),
|
||||
false,
|
||||
'no upload date passes no SINCE',
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
MAX_RESPONSE_BYTES,
|
||||
KARTAVIEW_API_HOST,
|
||||
MASK_ATTEMPTS,
|
||||
MASK_MAX_BYTES,
|
||||
MASK_RETRY_WAIT_MS,
|
||||
MASK_SLOW_MESSAGE,
|
||||
MASK_TIMEOUT_MS,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PAGE_CONCURRENCY,
|
||||
PAGE_TIMEOUT_MS,
|
||||
PHOTO_API_CONCURRENCY,
|
||||
PHOTO_API_RETRIES,
|
||||
PHOTO_API_RETRY_BASE_MS,
|
||||
PHOTO_API_RETRY_MAX_MS,
|
||||
PHOTO_API_FIRST_TIMEOUT_MS,
|
||||
PHOTO_API_TIMEOUT_MS,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
photoFilterParams,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
|
||||
* `timedOut` when it did not answer in time (the one failure worth asking
|
||||
* again at once).
|
||||
*/
|
||||
export class KartaViewSourceError extends Error {
|
||||
constructor(
|
||||
message,
|
||||
{ status = 0, retryAfterSec = null, timedOut = false } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'KartaViewSourceError';
|
||||
this.status = status;
|
||||
this.retryAfterSec = retryAfterSec;
|
||||
this.timedOut = timedOut;
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer in time — try again';
|
||||
const UNREACHABLE = 'KartaView could not be reached — check the network';
|
||||
const UNREADABLE = 'KartaView sent an answer that could not be read';
|
||||
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
|
||||
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
|
||||
|
||||
/** Resolves after `ms`, or rejects when `signal` aborts first. */
|
||||
function wait(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(signal.reason);
|
||||
const timer = setTimeout(done, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
function done() {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Seconds from a Retry-After header, or null. */
|
||||
function retryAfterSeconds(response) {
|
||||
const header = response.headers?.get?.('retry-after');
|
||||
const value = header == null || header === '' ? NaN : Number(header);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A gate letting at most `limit` tasks run at once, first in first out, or
|
||||
* newest first with `lifo` (the last request is the one the user waits on).
|
||||
*/
|
||||
export function createLimiter(limit, { lifo = false } = {}) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
}
|
||||
running++;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
running--;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
run(task, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item = { task, resolve, reject, signal };
|
||||
queue.push(item);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const at = queue.indexOf(item);
|
||||
if (at === -1) return;
|
||||
queue.splice(at, 1);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
next();
|
||||
});
|
||||
},
|
||||
get pending() {
|
||||
return running + queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KartaView's public API, browser-side and keyless (it allows any origin).
|
||||
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
|
||||
* at most three, photo lookups two and sequence pages one, so a lookup never
|
||||
* waits behind slow tiles or pages. The photo API answers 429 to more than
|
||||
* about three requests at once; a 429 (and a lookup that timed out) is
|
||||
* retried.
|
||||
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number, maskRetryMs?: number}} [options]
|
||||
*/
|
||||
export function createKartaViewSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
apiHost = KARTAVIEW_API_HOST,
|
||||
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
||||
maskRetryMs = MASK_RETRY_WAIT_MS,
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
function getJson(url, { signal, timeoutMs }) {
|
||||
return fetchBody(url, { signal, timeoutMs, as: 'json' });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON
|
||||
* or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the
|
||||
* timeout.
|
||||
*/
|
||||
async function fetchBody(
|
||||
url,
|
||||
{ signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES },
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
new KartaViewSourceError(NO_ANSWER, { timedOut: true }),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
let response = null;
|
||||
let read = false;
|
||||
try {
|
||||
response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (response.status === 429)
|
||||
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
|
||||
status: 429,
|
||||
retryAfterSec: retryAfterSeconds(response) ?? 2,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new KartaViewSourceError(
|
||||
`KartaView answered HTTP ${response.status}`,
|
||||
{ status: response.status },
|
||||
);
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
const tooMuch = () =>
|
||||
new KartaViewSourceError('KartaView sent too much data', {
|
||||
status: response.status,
|
||||
});
|
||||
if (length > maxBytes) throw tooMuch();
|
||||
// KartaView answers chunked, without a length: count while reading.
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
read = true;
|
||||
try {
|
||||
if (as === 'bytes')
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw error;
|
||||
throw new KartaViewSourceError(UNREADABLE, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
read = true;
|
||||
const chunks = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw tooMuch();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const body = new Uint8Array(bytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
if (as === 'bytes') return body;
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(body));
|
||||
} catch {
|
||||
throw new KartaViewSourceError(UNREADABLE, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
if (error instanceof KartaViewSourceError) throw error;
|
||||
// fetch rejects on no network, a blocked request or a refused CORS check.
|
||||
throw new KartaViewSourceError(UNREACHABLE);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
// An answer turned away unread (429, an error, too long) still holds
|
||||
// its connection, one of the six the browser has: let it go.
|
||||
if (response && !read) {
|
||||
controller.abort();
|
||||
if (response.body && !response.body.locked)
|
||||
response.body.cancel?.().catch?.(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo-API call through `gate`, retrying 429s with backoff and, once, a
|
||||
* call that timed out.
|
||||
*/
|
||||
async function photoApi(
|
||||
path,
|
||||
params,
|
||||
{ signal } = {},
|
||||
{
|
||||
gate = limiter,
|
||||
timeoutMs = PHOTO_API_TIMEOUT_MS,
|
||||
firstTimeoutMs = PHOTO_API_FIRST_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
const query = params ? `?${new URLSearchParams(params)}` : '';
|
||||
const url = `${apiHost}${path}${query}`;
|
||||
let timeouts = 0;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const payload = await gate.run(
|
||||
// A slow first answer is usually quick when asked again.
|
||||
() =>
|
||||
getJson(url, {
|
||||
signal,
|
||||
timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
const code = Number(payload?.status?.apiCode);
|
||||
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
|
||||
if (Number.isFinite(code) && (code < 600 || code > 603))
|
||||
throw new KartaViewSourceError(
|
||||
`KartaView refused the request (code ${code})`,
|
||||
{ status: Number(payload?.status?.httpCode) || 0 },
|
||||
);
|
||||
return payload?.result ?? {};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const timedOut = error?.timedOut === true;
|
||||
if (timedOut && timeouts++ < 1) continue;
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
|
||||
if (error?.status === 429)
|
||||
throw new KartaViewSourceError(
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
{ status: 429, retryAfterSec: error.retryAfterSec },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
await wait(
|
||||
Math.min(
|
||||
PHOTO_API_RETRY_MAX_MS,
|
||||
Math.max(
|
||||
retryBaseMs * 2 ** attempt,
|
||||
(error.retryAfterSec ?? 0) * 1000,
|
||||
),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`;
|
||||
const once = () =>
|
||||
tileLimiter.run(
|
||||
() => getJson(url, { signal, timeoutMs: TILE_TIMEOUT_MS }),
|
||||
{ signal },
|
||||
);
|
||||
try {
|
||||
return await once();
|
||||
} catch (error) {
|
||||
// A cold dense tile can outlast the timeout; KartaView has it ready
|
||||
// by the second ask.
|
||||
if (signal?.aborted || error?.timedOut !== true) throw error;
|
||||
return once();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A filtered coverage tile as PNG bytes, `query` from tileFilterQuery
|
||||
* (KartaView filters only its PNG tiles). It draws a filtered tile on
|
||||
* demand; a dense one can outlast its own 60 s (504) and still be drawn,
|
||||
* so a timeout or a 5xx is asked again a few seconds later.
|
||||
*/
|
||||
async getTileMask({ x, y, z }, query, { signal } = {}) {
|
||||
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
// Outside the gate between tries: a wait holds no connection.
|
||||
return await tileLimiter.run(
|
||||
() =>
|
||||
fetchBody(url, {
|
||||
signal,
|
||||
timeoutMs: MASK_TIMEOUT_MS,
|
||||
as: 'bytes',
|
||||
maxBytes: MASK_MAX_BYTES,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const drawing =
|
||||
error?.timedOut === true || [502, 503, 504].includes(error?.status);
|
||||
if (!drawing) throw error;
|
||||
if (attempt >= MASK_ATTEMPTS)
|
||||
throw new KartaViewSourceError(MASK_SLOW_MESSAGE, {
|
||||
timedOut: true,
|
||||
});
|
||||
await wait(maskRetryMs, signal);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a photo file is there: true, false when it is gone (archived
|
||||
* photos answer 409, and a HEAD says 200 regardless), or null when the
|
||||
* storage did not say (timeout, network, 5xx). It GETs and stops at the
|
||||
* headers.
|
||||
*/
|
||||
async probeImage(url, { signal } = {}) {
|
||||
if (!url) return false;
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (response.ok) return true;
|
||||
return GONE_STATUSES.includes(response.status) ? false : null;
|
||||
} catch {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
// The body is not needed: drop it.
|
||||
controller.abort();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Photo records around a point, nearest first; with a resolved imagery
|
||||
* `filter`, only those of its projection uploaded since its cut-off.
|
||||
*/
|
||||
async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) {
|
||||
const result = await photoApi(
|
||||
'/2.0/photo/',
|
||||
{
|
||||
lat: lat.toFixed(7),
|
||||
lng: lon.toFixed(7),
|
||||
zoomLevel: String(NEAREST_SEARCH_ZOOM),
|
||||
itemsPerPage: String(NEAREST_LIMIT),
|
||||
...photoFilterParams(filter),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
},
|
||||
|
||||
/** One photo record. */
|
||||
async getPhoto(photoId, { signal } = {}) {
|
||||
const result = await photoApi(
|
||||
`/2.0/photo/${encodeURIComponent(photoId)}`,
|
||||
null,
|
||||
{ signal },
|
||||
);
|
||||
if (!result.data || typeof result.data !== 'object')
|
||||
throw new KartaViewSourceError('KartaView has no such photo');
|
||||
return result.data;
|
||||
},
|
||||
|
||||
/** How many photos a sequence has, or null if KartaView does not say. */
|
||||
async getSequenceCount(sequenceId, { signal } = {}) {
|
||||
const result = await photoApi(
|
||||
`/2.0/sequence/${encodeURIComponent(sequenceId)}`,
|
||||
null,
|
||||
{ signal },
|
||||
);
|
||||
const count = Number(result.data?.countActivePhotos);
|
||||
return Number.isFinite(count) && count > 0 ? count : null;
|
||||
},
|
||||
|
||||
/** A page of a sequence's photos in capture order (1-based `page`). */
|
||||
async getSequencePhotos(
|
||||
sequenceId,
|
||||
{ page, itemsPerPage },
|
||||
{ signal } = {},
|
||||
) {
|
||||
const result = await photoApi(
|
||||
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
||||
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
||||
{ signal },
|
||||
{
|
||||
gate: pageLimiter,
|
||||
timeoutMs: PAGE_TIMEOUT_MS,
|
||||
firstTimeoutMs: PAGE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return {
|
||||
photos: Array.isArray(result.data) ? result.data : [],
|
||||
hasMore: result.hasMoreData === true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
KartaViewSourceError,
|
||||
createKartaViewSource,
|
||||
createLimiter,
|
||||
} from './source.js';
|
||||
import {
|
||||
MASK_ATTEMPTS,
|
||||
MASK_SLOW_MESSAGE,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PHOTO_API_CONCURRENCY,
|
||||
PHOTO_API_RETRIES,
|
||||
PHOTO_API_FIRST_TIMEOUT_MS,
|
||||
PHOTO_API_TIMEOUT_MS,
|
||||
PAGE_TIMEOUT_MS,
|
||||
TILE_CONCURRENCY,
|
||||
} from './policy.js';
|
||||
|
||||
/** A body streamed as browsers do, in `size`-byte chunks. */
|
||||
function streamed(text, size = 16_384) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let at = 0;
|
||||
return {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (at >= bytes.length) return { done: true };
|
||||
const value = bytes.slice(at, at + size);
|
||||
at += size;
|
||||
return { done: false, value };
|
||||
},
|
||||
cancel: async () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// A 200 as browsers deliver it: streamed, so the code under test reads it
|
||||
// the way it does in production (`json` is there only to fail loudly).
|
||||
const ok = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: streamed(JSON.stringify(body)),
|
||||
json: async () => {
|
||||
throw new Error('a browser answer is read from its stream');
|
||||
},
|
||||
});
|
||||
const status = (code, headers = new Map()) => ({
|
||||
ok: false,
|
||||
status: code,
|
||||
headers,
|
||||
json: async () => ({}),
|
||||
});
|
||||
const photoPayload = (data) => ({
|
||||
status: { apiCode: 600, httpCode: 200 },
|
||||
result: { data, hasMoreData: false },
|
||||
});
|
||||
|
||||
test('coverage tiles come from api.kartaview.org as JSON', async () => {
|
||||
const urls = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return ok({ lines: [] });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await source.getTile({ x: 4412, y: 6061, z: 14 }), {
|
||||
lines: [],
|
||||
});
|
||||
assert.deepEqual(urls, [
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/4412/6061/14.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('photo lookups run at most two at a time', async () => {
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running--;
|
||||
return ok(photoPayload({ id: '1' }));
|
||||
},
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 7 }, (_, i) => source.getPhoto(String(i))),
|
||||
);
|
||||
assert.equal(peak, PHOTO_API_CONCURRENCY);
|
||||
});
|
||||
|
||||
test('a 429 is retried after a wait, then answered', async () => {
|
||||
let calls = 0;
|
||||
const source = createKartaViewSource({
|
||||
retryBaseMs: 1,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return calls < 3
|
||||
? status(429, new Map([['retry-after', '0']]))
|
||||
: ok(photoPayload([{ id: '9' }]));
|
||||
},
|
||||
});
|
||||
const records = await source.nearestPhotos({ lat: 42.3, lon: -83.04 });
|
||||
assert.deepEqual(records, [{ id: '9' }]);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('rate-limiting that outlasts the retries is reported with a wait', async () => {
|
||||
let calls = 0;
|
||||
const source = createKartaViewSource({
|
||||
retryBaseMs: 1,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return status(429, new Map([['retry-after', '0.001']]));
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getPhoto('1'), (error) => {
|
||||
assert.ok(error instanceof KartaViewSourceError);
|
||||
assert.equal(error.status, 429);
|
||||
assert.ok(error.retryAfterSec > 0);
|
||||
assert.match(error.message, /try again in a moment/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls, PHOTO_API_RETRIES + 1);
|
||||
});
|
||||
|
||||
test('an API refusal inside a 200 is a readable error with its code; 601 is just empty', async () => {
|
||||
const refusing = createKartaViewSource({
|
||||
fetchImpl: async () =>
|
||||
ok({
|
||||
status: { apiCode: 612, apiMessage: ' out of range ', httpCode: 400 },
|
||||
result: {},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
refusing.getPhoto('1'),
|
||||
/KartaView refused the request \(code 612\)/,
|
||||
);
|
||||
const empty = createKartaViewSource({
|
||||
fetchImpl: async () =>
|
||||
ok({ status: { apiCode: 601 }, result: { data: [] } }),
|
||||
});
|
||||
assert.deepEqual(await empty.nearestPhotos({ lat: 0, lon: 0 }), []);
|
||||
const page = await empty.getSequencePhotos('2008', {
|
||||
page: 2,
|
||||
itemsPerPage: 1000,
|
||||
});
|
||||
assert.deepEqual(page, { photos: [], hasMore: false });
|
||||
});
|
||||
|
||||
test('HTTP errors, network failures and non-JSON become readable errors', async () => {
|
||||
const http = createKartaViewSource({ fetchImpl: async () => status(500) });
|
||||
await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/);
|
||||
let asked = 0;
|
||||
const offline = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
asked++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
// Not a timeout: no network is said as much, and not asked again at once.
|
||||
await assert.rejects(
|
||||
offline.getPhoto('1'),
|
||||
/could not be reached — check the network/,
|
||||
);
|
||||
assert.equal(asked, 1);
|
||||
const garbled = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token <');
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
garbled.getTile({ x: 0, y: 0, z: 14 }),
|
||||
/could not be read/,
|
||||
);
|
||||
const torn = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: streamed('{"lines": [', 4),
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
torn.getTile({ x: 0, y: 0, z: 14 }),
|
||||
/could not be read/,
|
||||
);
|
||||
});
|
||||
|
||||
test('an abort stops a queued lookup without fetching, and wins over a retry wait', async () => {
|
||||
let calls = 0;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const source = createKartaViewSource({
|
||||
retryBaseMs: 10_000,
|
||||
fetchImpl: async (url) => {
|
||||
calls++;
|
||||
if (url.includes('/slow')) await gate;
|
||||
return status(429);
|
||||
},
|
||||
});
|
||||
// Two slow lookups fill the queue; the third waits for a slot.
|
||||
const first = new AbortController();
|
||||
const slow = [
|
||||
source.getPhoto('slow', { signal: first.signal }),
|
||||
source.getPhoto('slow', { signal: first.signal }),
|
||||
];
|
||||
const controller = new AbortController();
|
||||
const queued = source.getPhoto('queued', { signal: controller.signal });
|
||||
controller.abort(new Error('overtaken'));
|
||||
await assert.rejects(queued, /overtaken/);
|
||||
assert.equal(calls, 2, 'the queued lookup never fetched');
|
||||
release();
|
||||
// Each slow one is now waiting 10 s to retry; nothing else is asked.
|
||||
const retrying = new AbortController();
|
||||
const waiting = source.getPhoto('slow', { signal: retrying.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
retrying.abort(new Error('closed'));
|
||||
await assert.rejects(waiting, /closed/);
|
||||
// The two first lookups are still waiting out their backoff: drop them.
|
||||
first.abort(new Error('done'));
|
||||
for (const promise of slow) await assert.rejects(promise, /done/);
|
||||
});
|
||||
|
||||
test('the limiter runs tasks in order and keeps going after a failure', async () => {
|
||||
const limiter = createLimiter(1);
|
||||
const order = [];
|
||||
const failing = limiter.run(async () => {
|
||||
order.push('a');
|
||||
throw new Error('a failed');
|
||||
});
|
||||
const second = limiter.run(async () => {
|
||||
order.push('b');
|
||||
return 'b';
|
||||
});
|
||||
await assert.rejects(failing, /a failed/);
|
||||
assert.equal(await second, 'b');
|
||||
assert.deepEqual(order, ['a', 'b']);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(limiter.pending, 0);
|
||||
});
|
||||
|
||||
test('tiles, lookups and sequence pages queue separately, so a lookup never waits behind them', async () => {
|
||||
const running = { tile: 0, photo: 0, page: 0 };
|
||||
const peak = { tile: 0, photo: 0, page: 0 };
|
||||
const release = [];
|
||||
const kind = (url) =>
|
||||
url.includes('/tiles/')
|
||||
? 'tile'
|
||||
: url.includes('/photos')
|
||||
? 'page'
|
||||
: 'photo';
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
const k = kind(url);
|
||||
running[k]++;
|
||||
peak[k] = Math.max(peak[k], running[k]);
|
||||
if (k !== 'photo') await new Promise((resolve) => release.push(resolve));
|
||||
running[k]--;
|
||||
return k === 'tile' ? ok({ lines: [] }) : ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
const tiles = Array.from({ length: 9 }, (_, i) =>
|
||||
source.getTile({ x: i, y: 0, z: 14 }),
|
||||
);
|
||||
const pages = [1, 2].map((page) =>
|
||||
source.getSequencePhotos('1', { page, itemsPerPage: 500 }),
|
||||
);
|
||||
// Every slow tile and page is in flight or queued; a lookup still answers.
|
||||
assert.deepEqual(await source.nearestPhotos({ lat: 1, lon: 1 }), []);
|
||||
assert.equal(peak.tile, TILE_CONCURRENCY);
|
||||
assert.equal(peak.page, 1);
|
||||
while (release.length) {
|
||||
release.shift()();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await Promise.all([...tiles, ...pages]);
|
||||
assert.equal(peak.tile, TILE_CONCURRENCY);
|
||||
assert.equal(peak.page, 1);
|
||||
});
|
||||
|
||||
test('a lookup that times out is asked once more, then reported as no answer in time', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const outcome = await onMockClock(t, () =>
|
||||
createKartaViewSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
calls++;
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
},
|
||||
}).getPhoto('1'),
|
||||
);
|
||||
assert.match(
|
||||
String(outcome?.error?.message),
|
||||
/did not answer in time — try again/,
|
||||
);
|
||||
assert.equal(outcome.error.timedOut, true);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('an image probe reads only the status: an archived photo (409) is not showable', async () => {
|
||||
const seen = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
seen.push(signal);
|
||||
return {
|
||||
ok: !url.includes('archived'),
|
||||
status: url.includes('archived') ? 409 : 200,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await source.probeImage('https://storage13.openstreetcam.org/ok.jpg'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await source.probeImage('https://storage13.openstreetcam.org/archived.jpg'),
|
||||
false,
|
||||
);
|
||||
assert.equal(await source.probeImage(null), false);
|
||||
assert.ok(
|
||||
seen.every((signal) => signal.aborted),
|
||||
'the body is dropped',
|
||||
);
|
||||
const offline = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
// No answer is not an answer: the caller may try again later.
|
||||
assert.equal(
|
||||
await offline.probeImage('https://x.openstreetcam.org/a.jpg'),
|
||||
null,
|
||||
);
|
||||
const busy = createKartaViewSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 502 }),
|
||||
});
|
||||
assert.equal(
|
||||
await busy.probeImage('https://x.openstreetcam.org/a.jpg'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a cold tile that times out is asked once more; an unreachable one is not', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const outcome = await onMockClock(t, () =>
|
||||
createKartaViewSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
calls++;
|
||||
if (calls === 1)
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
return Promise.resolve(ok({ lines: [] }));
|
||||
},
|
||||
}).getTile({ x: 1, y: 2, z: 13 }),
|
||||
);
|
||||
assert.deepEqual(outcome.value, { lines: [] });
|
||||
assert.equal(calls, 2);
|
||||
let asked = 0;
|
||||
const offline = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
asked++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
offline.getTile({ x: 1, y: 2, z: 13 }),
|
||||
/could not be reached/,
|
||||
);
|
||||
assert.equal(asked, 1);
|
||||
});
|
||||
|
||||
test('an absurdly large answer is refused before it is parsed', async () => {
|
||||
let parsed = false;
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map([['content-length', String(64 * 1024 * 1024)]]),
|
||||
json: async () => {
|
||||
parsed = true;
|
||||
return { lines: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/);
|
||||
assert.equal(parsed, false);
|
||||
});
|
||||
|
||||
test('sequence pages run one at a time, the newest first', async () => {
|
||||
const order = [];
|
||||
let release;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
const page = new URL(url).searchParams.get('page');
|
||||
order.push(page);
|
||||
if (page === '1') await gate;
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
const pages = ['1', '2', '3'].map((page) =>
|
||||
source.getSequencePhotos('9', { page, itemsPerPage: 250 }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
release();
|
||||
await Promise.all(pages);
|
||||
assert.deepEqual(order, ['1', '3', '2']);
|
||||
});
|
||||
|
||||
/** Every pending promise callback, without moving the mocked clock. */
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/**
|
||||
* Run `call` on the mocked clock in 100 ms steps until it settles (60 s at
|
||||
* most). `stamp()` reads the clock, for a fetch to record when it was asked.
|
||||
*/
|
||||
async function onMockClock(t, call, { until = 60_000 } = {}) {
|
||||
let now = 0;
|
||||
const stamp = () => now;
|
||||
let outcome = null;
|
||||
const promise = call(stamp);
|
||||
promise.then(
|
||||
(value) => (outcome = { value, at: now }),
|
||||
(error) => (outcome = { error, at: now }),
|
||||
);
|
||||
while (!outcome && now < until) {
|
||||
await flush();
|
||||
if (outcome) break;
|
||||
t.mock.timers.tick(100);
|
||||
now += 100;
|
||||
}
|
||||
await flush();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and 2 s without one', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
async function waits(retryAfter) {
|
||||
const asked = [];
|
||||
const headers = new Map(
|
||||
retryAfter === undefined ? [] : [['retry-after', retryAfter]],
|
||||
);
|
||||
const outcome = await onMockClock(t, (stamp) =>
|
||||
createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
asked.push(stamp());
|
||||
return asked.length <= PHOTO_API_RETRIES
|
||||
? status(429, headers)
|
||||
: ok(photoPayload({ id: '1' }));
|
||||
},
|
||||
}).getPhoto('1'),
|
||||
);
|
||||
assert.deepEqual(outcome?.value, { id: '1' }, `answered (${retryAfter})`);
|
||||
return asked.slice(1).map((at, i) => at - asked[i]);
|
||||
}
|
||||
assert.deepEqual(await waits('0'), [800, 1600, 3200], 'doubling backoff');
|
||||
assert.deepEqual(await waits('5'), [5000, 5000, 5000], 'Retry-After wins');
|
||||
assert.deepEqual(await waits('30'), [10_000, 10_000, 10_000], 'capped');
|
||||
assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s');
|
||||
});
|
||||
|
||||
test('a lookup that hangs is given up after a short first try, then the full timeout, then reported', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const asked = [];
|
||||
const outcome = await onMockClock(t, (stamp) =>
|
||||
createKartaViewSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
asked.push(stamp());
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
},
|
||||
}).getPhoto('1'),
|
||||
);
|
||||
assert.deepEqual(asked, [0, PHOTO_API_FIRST_TIMEOUT_MS]);
|
||||
assert.match(String(outcome?.error?.message), /did not answer in time/);
|
||||
assert.equal(outcome.at, PHOTO_API_FIRST_TIMEOUT_MS + PHOTO_API_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test('the nearest lookup asks for the few photos around the point at the search zoom', async () => {
|
||||
const urls = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url));
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
await source.nearestPhotos({ lat: 42.33, lon: -83.04 });
|
||||
assert.equal(urls[0].pathname, '/2.0/photo/');
|
||||
assert.deepEqual(Object.fromEntries(urls[0].searchParams), {
|
||||
lat: '42.3300000',
|
||||
lng: '-83.0400000',
|
||||
zoomLevel: String(NEAREST_SEARCH_ZOOM),
|
||||
itemsPerPage: String(NEAREST_LIMIT),
|
||||
});
|
||||
assert.equal(NEAREST_SEARCH_ZOOM, 18);
|
||||
assert.equal(NEAREST_LIMIT, 10);
|
||||
});
|
||||
|
||||
test('a chunked answer with no length is cut off once it grows past the cap', async () => {
|
||||
let reads = 0;
|
||||
let cancelled = false;
|
||||
const chunk = new Uint8Array(6 * 1024 * 1024);
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () =>
|
||||
++reads > 10 ? { done: true } : { done: false, value: chunk },
|
||||
cancel: async () => {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/);
|
||||
assert.equal(reads, 3, 'stopped at the third 6 MB chunk');
|
||||
assert.equal(cancelled, true);
|
||||
const small = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
const bytes = new TextEncoder().encode('{"lines":[]}');
|
||||
let given = false;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () =>
|
||||
given
|
||||
? { done: true }
|
||||
: ((given = true), { done: false, value: bytes }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await small.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
|
||||
});
|
||||
|
||||
test('a chunked answer split mid-character is reassembled in order', async () => {
|
||||
const payload = {
|
||||
lines: [
|
||||
{
|
||||
track: [{ lat: '1', lng: '2' }],
|
||||
name: 'Strada Memorandumului — Cluj-Napoca 😀',
|
||||
},
|
||||
],
|
||||
note: 'ăîşţ'.repeat(200),
|
||||
};
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
// 3-byte chunks cut multi-byte characters in two.
|
||||
body: streamed(JSON.stringify(payload), 3),
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), payload);
|
||||
});
|
||||
|
||||
test('a sequence page gets the full 30 s on its first try, then 30 s once more', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const asked = [];
|
||||
const outcome = await onMockClock(
|
||||
t,
|
||||
(stamp) =>
|
||||
createKartaViewSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
asked.push(stamp());
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
},
|
||||
}).getSequencePhotos('9', { page: 1, itemsPerPage: 250 }),
|
||||
{ until: 120_000 },
|
||||
);
|
||||
assert.deepEqual(asked, [0, PAGE_TIMEOUT_MS]);
|
||||
assert.match(String(outcome?.error?.message), /did not answer in time/);
|
||||
assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test('an answer turned away unread (an error, a 429, a length past the cap) lets its connection go', async () => {
|
||||
/** A response whose body is never read: records its cancel and the abort. */
|
||||
const unread = (answer) => {
|
||||
const seen = { cancelled: false, signal: null };
|
||||
const fetchImpl = async (url, { signal }) => {
|
||||
seen.signal = signal;
|
||||
return {
|
||||
...answer,
|
||||
body: {
|
||||
locked: false,
|
||||
getReader: () => {
|
||||
throw new Error('not read');
|
||||
},
|
||||
cancel: async () => {
|
||||
seen.cancelled = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
return { seen, fetchImpl };
|
||||
};
|
||||
const cases = [
|
||||
[{ ok: false, status: 503, headers: new Map() }, /HTTP 503/],
|
||||
[
|
||||
{
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map([['content-length', String(64 * 1024 * 1024)]]),
|
||||
},
|
||||
/too much data/,
|
||||
],
|
||||
];
|
||||
for (const [answer, message] of cases) {
|
||||
const { seen, fetchImpl } = unread(answer);
|
||||
const source = createKartaViewSource({ fetchImpl });
|
||||
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), message);
|
||||
assert.equal(seen.signal.aborted, true, `${message}: request aborted`);
|
||||
assert.equal(seen.cancelled, true, `${message}: body cancelled`);
|
||||
}
|
||||
// A 429 is let go before its retry is asked.
|
||||
const { seen, fetchImpl } = unread({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Map([['retry-after', '0']]),
|
||||
});
|
||||
const signals = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url, init) => {
|
||||
signals.push(init.signal);
|
||||
if (signals.length === 1) return fetchImpl(url, init);
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
await source.getPhoto('1');
|
||||
assert.equal(signals[0].aborted, true);
|
||||
assert.equal(seen.cancelled, true);
|
||||
assert.equal(signals[1].aborted, false, 'a read answer is left alone');
|
||||
});
|
||||
|
||||
test('a filtered tile comes as PNG bytes; KartaView still drawing it (504) is asked again, then given up', async () => {
|
||||
const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]);
|
||||
const bytesOf = () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: {
|
||||
getReader: () => {
|
||||
let given = false;
|
||||
return {
|
||||
read: async () =>
|
||||
given
|
||||
? { done: true }
|
||||
: ((given = true), { done: false, value: png }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const urls = [];
|
||||
let answers = [status(504), status(503), bytesOf()];
|
||||
const source = createKartaViewSource({
|
||||
maskRetryMs: 0,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return answers.shift();
|
||||
},
|
||||
});
|
||||
const tile = { x: 13054, y: 8474, z: 14 };
|
||||
assert.deepEqual(await source.getTileMask(tile, 'fieldOfView=360'), png);
|
||||
assert.deepEqual(urls, [
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
]);
|
||||
// Still drawing after every try: given up, saying so.
|
||||
urls.length = 0;
|
||||
answers = Array.from({ length: 10 }, () => status(504));
|
||||
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), {
|
||||
message: MASK_SLOW_MESSAGE,
|
||||
timedOut: true,
|
||||
});
|
||||
assert.equal(urls.length, MASK_ATTEMPTS);
|
||||
// A refusal is not asked again.
|
||||
urls.length = 0;
|
||||
answers = [status(400)];
|
||||
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), /HTTP 400/);
|
||||
assert.equal(urls.length, 1);
|
||||
});
|
||||
|
||||
test('a filtered nearest search asks KartaView for the projection and upload date', async () => {
|
||||
const urls = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url));
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
await source.nearestPhotos({
|
||||
lat: 42.3314,
|
||||
lon: -83.0458,
|
||||
filter: { pano: 'pano', sinceMs: Date.UTC(2026, 0, 2, 12) },
|
||||
});
|
||||
await source.nearestPhotos({ lat: 42.3314, lon: -83.0458 });
|
||||
assert.equal(urls[0].searchParams.get('projection'), 'SPHERE');
|
||||
assert.equal(urls[0].searchParams.get('startDate'), '2026-01-02');
|
||||
assert.equal(urls[1].searchParams.has('projection'), false);
|
||||
assert.equal(urls[1].searchParams.has('startDate'), false);
|
||||
});
|
||||
@@ -0,0 +1,698 @@
|
||||
import {
|
||||
createPanoramaRenderer,
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
IMAGE_TIMEOUT_MS,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
PANO_FULL_SIZE,
|
||||
PANO_MID_SIZE,
|
||||
PANO_PREVIEW_SIZE,
|
||||
cameraBearing,
|
||||
kartaViewPhotoUrl,
|
||||
resizedImageUrl,
|
||||
} from './policy.js';
|
||||
|
||||
const LOAD_FAILED = 'The KartaView photo could not be loaded';
|
||||
|
||||
/**
|
||||
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
|
||||
* off the main thread into an ImageBitmap where the browser can: uploading
|
||||
* a decoded bitmap costs a fraction of uploading an <img> (90 ms → 15 ms for
|
||||
* 4096×2048 on a phone-class CPU).
|
||||
*/
|
||||
export function loadImage(
|
||||
url,
|
||||
{ crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {},
|
||||
) {
|
||||
if (
|
||||
crossOrigin &&
|
||||
url &&
|
||||
typeof globalThis.createImageBitmap === 'function' &&
|
||||
typeof globalThis.fetch === 'function'
|
||||
)
|
||||
return loadBitmap(url, timeoutMs);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error(LOAD_FAILED));
|
||||
const image = new Image();
|
||||
if (crossOrigin) image.crossOrigin = 'anonymous';
|
||||
image.decoding = 'async';
|
||||
const timer = setTimeout(() => {
|
||||
image.src = '';
|
||||
reject(new Error(LOAD_FAILED));
|
||||
}, timeoutMs);
|
||||
image.onload = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(LOAD_FAILED));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBitmap(url, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(LOAD_FAILED);
|
||||
return await createImageBitmap(await response.blob());
|
||||
} catch {
|
||||
throw new Error(LOAD_FAILED);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Free a decoded bitmap nobody will draw (an <img> has nothing to free). */
|
||||
function release(image) {
|
||||
image?.close?.();
|
||||
}
|
||||
|
||||
/** The panorama size a canvas this wide can show sharply at this field of view. */
|
||||
export function panoSizeFor(canvasPixels, fovDeg) {
|
||||
const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg);
|
||||
return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewer adapter for KartaView photos: a flat photo in an <img>, a 360°
|
||||
* photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step
|
||||
* along the sequence (also ← →). Poses follow the view direction.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
|
||||
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
|
||||
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
|
||||
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
|
||||
* @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
|
||||
* @param {object} [options.render] Render governor.
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createKartaViewViewer({
|
||||
getPhoto,
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
onReopen = onStep,
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
}) {
|
||||
const listeners = new Set();
|
||||
let root = null;
|
||||
let picture = null;
|
||||
let canvas = null;
|
||||
let prevButton = null;
|
||||
let nextButton = null;
|
||||
let renderer = null;
|
||||
/** WebGL failed once: 360° photos show their front view instead. */
|
||||
let rendererFailed = false;
|
||||
let photo = null;
|
||||
/** What shows: 'flat', 'pano' or null. */
|
||||
let showing = null;
|
||||
let openSeq = 0;
|
||||
let renderMode = 'letterbox';
|
||||
let drag = null;
|
||||
let poseFrame = 0;
|
||||
let removeListeners = [];
|
||||
/** The document the viewer was built in (new canvases come from it). */
|
||||
let documentOf = null;
|
||||
/** A context loss's look direction, for the reopen of that photo. */
|
||||
let restoreView = null;
|
||||
/** Width of the panorama texture on screen (0: the preview). */
|
||||
let panoWidth = 0;
|
||||
/** The flat photo on screen is the 2,560 px version. */
|
||||
let flatSharp = false;
|
||||
/** The sharper version loading ({seq, kind}): a resize starts no second one. */
|
||||
let upgrading = null;
|
||||
/** A resize came while it loaded: look again once it lands. */
|
||||
let upgradeAgain = false;
|
||||
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
||||
let openingId = null;
|
||||
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
||||
let keepViewFor = null;
|
||||
/** ‹ › presses run one after another, each from where the last one went. */
|
||||
let steps = Promise.resolve();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('kartaview-viewer');
|
||||
}
|
||||
|
||||
function emit() {
|
||||
if (!photo || !showing) return;
|
||||
// A lost GPU context, still being recovered: the open that redraws the
|
||||
// panorama poses once it is on screen.
|
||||
if (showing === 'pano' && !renderer) return;
|
||||
const view = showing === 'pano' ? renderer.getView() : null;
|
||||
const heading = cameraBearing(photo);
|
||||
const pose = {
|
||||
providerId: KARTAVIEW_PROVIDER_ID,
|
||||
imageId: photo.id,
|
||||
position: { lon: photo.lon, lat: photo.lat },
|
||||
bearing:
|
||||
heading === null
|
||||
? null
|
||||
: view
|
||||
? wrapDegrees(heading + view.yaw)
|
||||
: heading,
|
||||
tilt: view ? view.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: showing === 'pano',
|
||||
capturedAt: photo.capturedAt,
|
||||
capturedAtPrecision: 'day',
|
||||
// KartaView's only place name is the sequence's, where the drive began.
|
||||
title: null,
|
||||
creator: null,
|
||||
sequenceId: photo.sequenceId,
|
||||
externalUrl: kartaViewPhotoUrl(photo),
|
||||
};
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Re-emit on the next frame: a drag moves the view many times a frame. */
|
||||
function emitSoon() {
|
||||
if (poseFrame) return;
|
||||
poseFrame = requestAnimationFrame(() => {
|
||||
poseFrame = 0;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
function syncButtons() {
|
||||
const enabled = Boolean(photo?.sequenceId) && showing !== null;
|
||||
if (prevButton) prevButton.disabled = !enabled;
|
||||
if (nextButton) nextButton.disabled = !enabled;
|
||||
if (enabled) checkEnds(photo);
|
||||
}
|
||||
|
||||
/** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */
|
||||
function checkEnds(target) {
|
||||
for (const [delta, button] of [
|
||||
[-1, prevButton],
|
||||
[1, nextButton],
|
||||
])
|
||||
step(target, delta).then(
|
||||
(id) => {
|
||||
if (photo === target && button && !id) disable(button);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Disable a step button, keeping keyboard focus in the viewer. */
|
||||
function disable(button) {
|
||||
if (button.ownerDocument?.activeElement === button) root?.focus?.();
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
function show(kind) {
|
||||
showing = kind;
|
||||
if (picture) picture.hidden = kind !== 'flat';
|
||||
if (canvas) canvas.hidden = kind !== 'pano';
|
||||
if (root) root.dataset.kind = kind || '';
|
||||
syncButtons();
|
||||
}
|
||||
|
||||
function applyRenderMode() {
|
||||
if (picture)
|
||||
picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain';
|
||||
}
|
||||
|
||||
function ensureRenderer() {
|
||||
if (renderer || rendererFailed || !canvas) return renderer;
|
||||
try {
|
||||
renderer = createRenderer(canvas);
|
||||
} catch {
|
||||
rendererFailed = true;
|
||||
renderer = null;
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lost WebGL context (GPU reset, memory pressure) takes the panorama
|
||||
* with it: drop the renderer, and show the photo again on a new one.
|
||||
*/
|
||||
function onContextLost(event) {
|
||||
event.preventDefault?.();
|
||||
const lost = renderer;
|
||||
renderer = null;
|
||||
// The new renderer starts facing the photo's heading: keep the user's look.
|
||||
if (showing === 'pano' && photo && lost)
|
||||
restoreView = { id: photo.id, view: lost.getView() };
|
||||
try {
|
||||
lost?.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// A lost canvas hands back its lost context: draw on a new element.
|
||||
const fresh = makeCanvas(documentOf);
|
||||
fresh.hidden = canvas.hidden;
|
||||
canvas.replaceWith?.(fresh);
|
||||
canvas = fresh;
|
||||
// Show the photo again through the core, unless a newer one is opening.
|
||||
if (showing === 'pano' && photo && openingId === null) {
|
||||
const again = photo.id;
|
||||
show(null);
|
||||
openingId = again;
|
||||
onReopen(again);
|
||||
}
|
||||
}
|
||||
|
||||
/** The panorama canvas, wired for drag, pinch, wheel and context loss. */
|
||||
function makeCanvas(doc) {
|
||||
const element = doc.createElement('canvas');
|
||||
element.className = 'sl-kv-pano';
|
||||
element.hidden = true;
|
||||
listen(element, 'pointerdown', onPointerDown);
|
||||
listen(element, 'pointermove', onPointerMove);
|
||||
listen(element, 'pointerup', onPointerUp);
|
||||
listen(element, 'pointercancel', onPointerUp);
|
||||
listen(element, 'wheel', onWheel, { passive: false });
|
||||
listen(element, 'webglcontextlost', onContextLost);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step along the sequence from the photo showing, or from the one on its
|
||||
* way after an earlier press, and open it through the core.
|
||||
*/
|
||||
function stepBy(delta) {
|
||||
steps = steps.then(async () => {
|
||||
const fromId = openingId ?? photo?.id;
|
||||
if (!fromId || !root) return;
|
||||
let from;
|
||||
let nextId = null;
|
||||
try {
|
||||
from = await getPhoto(fromId);
|
||||
if (from.sequenceId) nextId = await step(from, delta);
|
||||
} catch {
|
||||
// Not the end of the sequence, just no answer: the press is lost.
|
||||
return;
|
||||
}
|
||||
// Closed, or another photo was opened meanwhile.
|
||||
if ((openingId ?? photo?.id) !== fromId) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button && photo?.id === fromId) disable(button);
|
||||
return;
|
||||
}
|
||||
openingId = nextId;
|
||||
keepViewFor = nextId;
|
||||
onStep(nextId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Pointers on the panorama: one drags the view, two pinch the zoom. */
|
||||
const pointers = new Map();
|
||||
let pinch = null;
|
||||
|
||||
function pinchSpan() {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
if (pointers.size === 2) {
|
||||
drag = null;
|
||||
pinch = { span: pinchSpan(), fov: renderer.getView().fov };
|
||||
return;
|
||||
}
|
||||
drag = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
view: renderer.getView(),
|
||||
};
|
||||
canvas.classList.add('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (pointers.has(event.pointerId))
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pinch && pointers.size === 2 && renderer) {
|
||||
renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) });
|
||||
return;
|
||||
}
|
||||
if (!drag || event.pointerId !== drag.id || !renderer) return;
|
||||
const { fov } = drag.view;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const vertical = fov;
|
||||
const horizontal = fov * (width / height);
|
||||
renderer.setView({
|
||||
yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal,
|
||||
pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical,
|
||||
});
|
||||
emitSoon();
|
||||
}
|
||||
|
||||
function onPointerUp(event) {
|
||||
pointers.delete(event.pointerId);
|
||||
if (pointers.size < 2) pinch = null;
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
drag = null;
|
||||
canvas.classList.remove('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey
|
||||
)
|
||||
return;
|
||||
const pano = showing === 'pano' && renderer;
|
||||
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
|
||||
// Shift+← → look around a panorama; ← → step along the sequence.
|
||||
if (turn && event.shiftKey && pano) {
|
||||
event.preventDefault();
|
||||
const { yaw, fov } = renderer.getView();
|
||||
renderer.setView({ yaw: yaw + (turn * fov) / 4 });
|
||||
emitSoon();
|
||||
} else if (turn) {
|
||||
event.preventDefault();
|
||||
stepBy(turn);
|
||||
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) {
|
||||
event.preventDefault();
|
||||
const { pitch, fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6,
|
||||
});
|
||||
emitSoon();
|
||||
} else if (['+', '=', '-', '_'].includes(event.key) && pano) {
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
removeListeners.push(() =>
|
||||
target.removeEventListener(type, handler, options),
|
||||
);
|
||||
}
|
||||
|
||||
function build(host) {
|
||||
const doc = host.ownerDocument;
|
||||
documentOf = doc;
|
||||
root = doc.createElement('div');
|
||||
root.className = 'sl-kv-viewer';
|
||||
root.tabIndex = 0;
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-kv-photo';
|
||||
picture.alt = 'KartaView street-level photo';
|
||||
picture.draggable = false;
|
||||
picture.hidden = true;
|
||||
canvas = makeCanvas(doc);
|
||||
const nav = (label, glyph, delta) => {
|
||||
const button = doc.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `sl-kv-step sl-kv-step-${delta < 0 ? 'prev' : 'next'}`;
|
||||
button.textContent = glyph;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.disabled = true;
|
||||
listen(button, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
stepBy(delta);
|
||||
});
|
||||
return button;
|
||||
};
|
||||
prevButton = nav('Previous photo in this sequence (←)', '‹', -1);
|
||||
nextButton = nav('Next photo in this sequence (→)', '›', 1);
|
||||
root.append(picture, canvas, prevButton, nextButton);
|
||||
host.append(root);
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
pointers.clear();
|
||||
pinch = null;
|
||||
for (const remove of removeListeners) remove();
|
||||
removeListeners = [];
|
||||
if (poseFrame) cancelAnimationFrame(poseFrame);
|
||||
poseFrame = 0;
|
||||
drag = null;
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
root?.remove();
|
||||
root = picture = canvas = prevButton = nextButton = null;
|
||||
showing = null;
|
||||
photo = null;
|
||||
}
|
||||
|
||||
/** A sharper version once the first is on screen, if nothing replaced it. */
|
||||
async function upgrade(target, seq, kind) {
|
||||
// A 360° photo shown flat (no WebGL) keeps its front view.
|
||||
if (kind === 'flat' && target.isPano) return;
|
||||
const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE;
|
||||
if (kind === 'pano' && size[0] <= panoWidth) return;
|
||||
// A docked viewer shows the 1,280 px version in full: the 2,560 px one
|
||||
// (~0.6 MB a photo) waits until the viewer is bigger than that.
|
||||
if (kind === 'flat' && (flatSharp || flatPixels() <= FLAT_VIEW_SIZE[0]))
|
||||
return;
|
||||
const url =
|
||||
kind === 'pano'
|
||||
? resizedImageUrl(target.urls.pano, size)
|
||||
: resizedImageUrl(target.urls.full, size);
|
||||
if (!url) return;
|
||||
// Resizes come in bursts while the panel animates: one load at a time
|
||||
// for this photo, and one more look once it lands.
|
||||
if (upgrading?.seq === seq && upgrading.kind === kind) {
|
||||
upgradeAgain = true;
|
||||
return;
|
||||
}
|
||||
const pending = (upgrading = { seq, kind });
|
||||
upgradeAgain = false;
|
||||
let image = null;
|
||||
try {
|
||||
image = await load(url, { crossOrigin: kind === 'pano' });
|
||||
if (seq !== openSeq || photo !== target || showing !== kind) return;
|
||||
if (kind === 'pano') {
|
||||
if (!renderer || size[0] <= panoWidth) return;
|
||||
renderer.setImage(image);
|
||||
image = null;
|
||||
panoWidth = size[0];
|
||||
} else {
|
||||
picture.src = image.src;
|
||||
image = null;
|
||||
flatSharp = true;
|
||||
}
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays */
|
||||
} finally {
|
||||
release(image);
|
||||
if (upgrading === pending) {
|
||||
upgrading = null;
|
||||
if (upgradeAgain && seq === openSeq && photo === target) {
|
||||
upgradeAgain = false;
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Device pixels across the flat photo's frame. */
|
||||
function flatPixels() {
|
||||
const ratio = globalThis.devicePixelRatio || 1;
|
||||
return (
|
||||
Math.max(picture?.clientWidth || 0, picture?.clientHeight || 0) * ratio
|
||||
);
|
||||
}
|
||||
|
||||
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
|
||||
function panoSizeNow() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const width = (canvas?.clientWidth || 0) * ratio;
|
||||
return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG);
|
||||
}
|
||||
|
||||
async function showPano(target, seq, keepView) {
|
||||
// Only the proxy can shrink a 13,000 px panorama: if it fails, the
|
||||
// caller shows the front view rather than waiting on it twice.
|
||||
const image = await load(
|
||||
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||
{ crossOrigin: true },
|
||||
);
|
||||
if (seq !== openSeq) {
|
||||
release(image);
|
||||
return false;
|
||||
}
|
||||
// The context may have been lost while this loaded: build on the new
|
||||
// canvas, or let the caller show the front view.
|
||||
if (!ensureRenderer()) {
|
||||
release(image);
|
||||
throw new Error(LOAD_FAILED);
|
||||
}
|
||||
const restore = restoreView?.id === target.id ? restoreView.view : null;
|
||||
restoreView = null;
|
||||
if (restore) renderer.setView(restore);
|
||||
else if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
photo = target;
|
||||
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
|
||||
show('pano');
|
||||
renderer.setImage(image);
|
||||
panoWidth = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showFlat(target, seq) {
|
||||
// The proxy's 1,280 px version is a few hundred kB; KartaView's own
|
||||
// "large thumbnail" can be 1.7 MB. Either serves when the other fails.
|
||||
// A 360° photo's front view is only on storage.
|
||||
const proxied = target.isPano
|
||||
? null
|
||||
: resizedImageUrl(target.urls.full, FLAT_VIEW_SIZE);
|
||||
const sources = [proxied, target.urls.view].filter(Boolean);
|
||||
let image;
|
||||
let failure = null;
|
||||
for (const url of sources) {
|
||||
try {
|
||||
image = await load(url, { crossOrigin: false });
|
||||
break;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
if (seq !== openSeq) return false;
|
||||
}
|
||||
}
|
||||
if (!image) throw failure || new Error(LOAD_FAILED);
|
||||
if (seq !== openSeq) return false;
|
||||
picture.src = image.src;
|
||||
flatSharp = false;
|
||||
photo = target;
|
||||
show('flat');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openPhoto(id, seq, keepView) {
|
||||
const target = await getPhoto(id);
|
||||
if (seq !== openSeq) return;
|
||||
let shown = false;
|
||||
if (target.isPano && target.urls.pano && ensureRenderer()) {
|
||||
try {
|
||||
shown = await showPano(target, seq, keepView);
|
||||
} catch {
|
||||
// The panorama failed: its front view is better than nothing.
|
||||
if (seq !== openSeq) return;
|
||||
shown = await showFlat(target, seq);
|
||||
}
|
||||
} else shown = await showFlat(target, seq);
|
||||
if (!shown) return;
|
||||
emit();
|
||||
onShown(target);
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
if (root && root.parentNode === host) return;
|
||||
teardown();
|
||||
rendererFailed = false;
|
||||
build(host);
|
||||
},
|
||||
|
||||
/** Resolves once the photo is on screen and its pose was emitted. */
|
||||
async open(imageId) {
|
||||
if (!root) throw new Error('KartaView viewer is not mounted');
|
||||
const id = String(imageId);
|
||||
const seq = ++openSeq;
|
||||
const keepView = keepViewFor === id;
|
||||
keepViewFor = null;
|
||||
openingId = id;
|
||||
try {
|
||||
await openPhoto(id, seq, keepView);
|
||||
} finally {
|
||||
if (openingId === id && seq === openSeq) openingId = null;
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
openSeq++;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
photo = null;
|
||||
show(null);
|
||||
if (picture) picture.removeAttribute('src');
|
||||
renderer?.clear?.();
|
||||
},
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
teardown();
|
||||
},
|
||||
|
||||
resize() {
|
||||
renderer?.resize();
|
||||
// Expanded: a docked viewer's first version is now too soft.
|
||||
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
|
||||
upgrade(photo, openSeq, 'pano');
|
||||
if (showing === 'flat' && photo && !flatSharp)
|
||||
upgrade(photo, openSeq, 'flat');
|
||||
},
|
||||
|
||||
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
|
||||
supportsRenderModes: () => showing !== 'pano',
|
||||
|
||||
setRenderMode(mode) {
|
||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
applyRenderMode();
|
||||
},
|
||||
|
||||
/** The photo's direction became known (its drive loaded): pose again. */
|
||||
refreshPose() {
|
||||
emit();
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createKartaViewViewer, loadImage, panoSizeFor } from './viewer.js';
|
||||
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
|
||||
|
||||
const PHOTOS = {
|
||||
flat1: {
|
||||
id: 'flat1',
|
||||
sequenceId: 's1',
|
||||
sequenceIndex: 1,
|
||||
lon: -83.04,
|
||||
lat: 42.33,
|
||||
heading: 90,
|
||||
isPano: false,
|
||||
capturedAt: Date.UTC(2016, 7, 23),
|
||||
urls: {
|
||||
view: 'https://storage2.openstreetcam.org/lth/a.jpg',
|
||||
full: 'https://storage2.openstreetcam.org/proc/a.jpg',
|
||||
pano: null,
|
||||
},
|
||||
},
|
||||
flat2: {
|
||||
id: 'flat2',
|
||||
sequenceId: 's1',
|
||||
sequenceIndex: 2,
|
||||
lon: -83.0401,
|
||||
lat: 42.33,
|
||||
heading: 92,
|
||||
isPano: false,
|
||||
capturedAt: Date.UTC(2016, 7, 23),
|
||||
urls: {
|
||||
view: 'https://storage2.openstreetcam.org/lth/b.jpg',
|
||||
full: 'https://storage2.openstreetcam.org/proc/b.jpg',
|
||||
pano: null,
|
||||
},
|
||||
},
|
||||
pano1: {
|
||||
id: 'pano1',
|
||||
sequenceId: 's2',
|
||||
sequenceIndex: 7,
|
||||
lon: 106.84,
|
||||
lat: -6.19,
|
||||
heading: 150,
|
||||
isPano: true,
|
||||
capturedAt: Date.UTC(2022, 2, 21),
|
||||
urls: {
|
||||
view: 'https://storage13.openstreetcam.org/lth/p.jpg',
|
||||
full: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg',
|
||||
pano: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg',
|
||||
},
|
||||
},
|
||||
pano2: {
|
||||
id: 'pano2',
|
||||
sequenceId: 's2',
|
||||
sequenceIndex: 8,
|
||||
lon: 106.8401,
|
||||
lat: -6.19,
|
||||
heading: 152,
|
||||
isPano: true,
|
||||
capturedAt: Date.UTC(2022, 2, 21),
|
||||
urls: {
|
||||
view: 'https://storage13.openstreetcam.org/lth/q.jpg',
|
||||
full: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg',
|
||||
pano: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function fakeRenderer(canvas) {
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
return {
|
||||
canvas,
|
||||
images: [],
|
||||
hiddenAtSetImage: [],
|
||||
setImage(image) {
|
||||
this.images.push(image.src);
|
||||
// A hidden canvas measures 0×0: sizing it then gives a 1×1 buffer.
|
||||
this.hiddenAtSetImage.push(canvas?.hidden);
|
||||
},
|
||||
setView(next) {
|
||||
Object.assign(view, next);
|
||||
},
|
||||
getView: () => ({ ...view }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
PHOTOS.pano2 = {
|
||||
...PHOTOS.pano1,
|
||||
id: 'pano2',
|
||||
sequenceIndex: 8,
|
||||
urls: {
|
||||
view: 'https://storage13.openstreetcam.org/lth/p2.jpg',
|
||||
full: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg',
|
||||
pano: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg',
|
||||
},
|
||||
};
|
||||
|
||||
function setup({
|
||||
failLoads = [],
|
||||
rendererThrows = false,
|
||||
steps = {},
|
||||
failSteps = false,
|
||||
hold = () => false,
|
||||
} = {}) {
|
||||
const loads = [];
|
||||
const held = [];
|
||||
const closed = [];
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
const asked = [];
|
||||
const attempts = { renderer: 0 };
|
||||
const viewer = createKartaViewViewer({
|
||||
getPhoto: async (id) => {
|
||||
if (!PHOTOS[id]) throw new Error('no such photo');
|
||||
return PHOTOS[id];
|
||||
},
|
||||
step: async (photo, delta) => {
|
||||
asked.push(`${photo.id}:${delta}`);
|
||||
if (failSteps) throw new Error('KartaView did not answer in time');
|
||||
return steps[`${photo.id}:${delta}`] ?? null;
|
||||
},
|
||||
// The core opens what ‹ › chose; here the adapter is opened directly.
|
||||
onStep: (id) => {
|
||||
stepped.push(id);
|
||||
viewer.open(id).catch(() => {});
|
||||
},
|
||||
load: async (url, options) => {
|
||||
loads.push({ url, crossOrigin: options?.crossOrigin });
|
||||
if (failLoads.some((part) => url.includes(part)))
|
||||
throw new Error('The KartaView photo could not be loaded');
|
||||
// A decoded bitmap: close() frees it.
|
||||
const image = { src: url, close: () => closed.push(url) };
|
||||
if (!hold(url)) return image;
|
||||
return new Promise((resolve) => held.push(() => resolve(image)));
|
||||
},
|
||||
createRenderer: (canvas) => {
|
||||
attempts.renderer++;
|
||||
if (rendererThrows) throw new Error('WebGL is unavailable');
|
||||
const renderer = fakeRenderer(canvas);
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
const host = fakeHost();
|
||||
return {
|
||||
viewer,
|
||||
poses,
|
||||
host,
|
||||
loads,
|
||||
renderers,
|
||||
stepped,
|
||||
asked,
|
||||
attempts,
|
||||
held,
|
||||
closed,
|
||||
};
|
||||
}
|
||||
|
||||
const parts = (host) => {
|
||||
const root = host.children[0];
|
||||
const [picture, canvas, prev, next] = root.children;
|
||||
return { root, picture, canvas, prev, next };
|
||||
};
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('a flat photo shows its 1280 px version, then the sharp one, with a pose facing its heading', async () => {
|
||||
const { viewer, poses, host, loads } = setup({
|
||||
steps: { 'flat1:1': 'flat2', 'flat1:-1': 'flat0' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
const { picture, canvas, prev, next } = parts(host);
|
||||
picture.clientWidth = 1600; // expanded: the sharp version is wanted
|
||||
assert.equal(prev.disabled, true, 'nothing to step through yet');
|
||||
await viewer.open('flat1');
|
||||
assert.equal(picture.hidden, false);
|
||||
assert.equal(canvas.hidden, true);
|
||||
assert.equal(prev.disabled, false);
|
||||
assert.equal(next.disabled, false);
|
||||
const pose = poses.at(-1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
providerId: pose.providerId,
|
||||
imageId: pose.imageId,
|
||||
position: pose.position,
|
||||
bearing: pose.bearing,
|
||||
tilt: pose.tilt,
|
||||
isPano: pose.isPano,
|
||||
sequenceId: pose.sequenceId,
|
||||
capturedAt: pose.capturedAt,
|
||||
title: pose.title,
|
||||
},
|
||||
{
|
||||
providerId: 'kartaview',
|
||||
imageId: 'flat1',
|
||||
position: { lon: -83.04, lat: 42.33 },
|
||||
bearing: 90,
|
||||
tilt: 0,
|
||||
isPano: false,
|
||||
sequenceId: 's1',
|
||||
capturedAt: Date.UTC(2016, 7, 23),
|
||||
title: null,
|
||||
},
|
||||
);
|
||||
assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//);
|
||||
// The proxy's 1,280 px version first: KartaView's own can be 1.7 MB.
|
||||
assert.match(
|
||||
loads[0].url,
|
||||
/^https:\/\/cdn\.kartaview\.org\/.*rs:fit:1280:1280/,
|
||||
);
|
||||
assert.equal(loads[0].crossOrigin, false, 'an <img> needs no CORS');
|
||||
await settle();
|
||||
assert.match(
|
||||
loads[1].url,
|
||||
/^https:\/\/cdn\.kartaview\.org\/.*rs:fit:2560:2560/,
|
||||
);
|
||||
assert.equal(picture.src, loads[1].url, 'upgraded to the sharp version');
|
||||
assert.equal(viewer.supportsRenderModes(), true);
|
||||
viewer.setRenderMode('fill');
|
||||
assert.equal(picture.style.objectFit, 'cover');
|
||||
});
|
||||
|
||||
test('a failing image proxy falls back to the photo on KartaView storage', async () => {
|
||||
const { viewer, host, loads } = setup({ failLoads: ['cdn.kartaview.org'] });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
assert.equal(loads[1].url, PHOTOS.flat1.urls.view);
|
||||
assert.equal(parts(host).picture.hidden, false);
|
||||
assert.equal(parts(host).picture.src, PHOTOS.flat1.urls.view);
|
||||
});
|
||||
|
||||
test('a 360° photo shows on the panorama, preview first; dragging turns the bearing', async () => {
|
||||
const { viewer, poses, host, loads, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const { picture, canvas } = parts(host);
|
||||
assert.equal(canvas.hidden, false);
|
||||
assert.equal(picture.hidden, true);
|
||||
assert.match(loads[0].url, /rs:fit:1024:512/);
|
||||
assert.equal(loads[0].crossOrigin, true, 'WebGL textures need CORS');
|
||||
assert.equal(poses.at(-1).isPano, true);
|
||||
assert.equal(poses.at(-1).bearing, 150);
|
||||
assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit');
|
||||
await settle();
|
||||
// Docked (400 px at 80°), 2,048 px is all it can show.
|
||||
assert.match(renderers[0].images.at(-1), /rs:fit:2048:1024/);
|
||||
// Drag left by a quarter of the width: the view turns right.
|
||||
const frames = [];
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
};
|
||||
try {
|
||||
canvas.fire('pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: 200,
|
||||
clientY: 100,
|
||||
});
|
||||
canvas.fire('pointermove', { pointerId: 1, clientX: 100, clientY: 100 });
|
||||
canvas.fire('pointerup', { pointerId: 1 });
|
||||
frames.forEach((callback) => callback());
|
||||
} finally {
|
||||
delete globalThis.requestAnimationFrame;
|
||||
}
|
||||
const turned = poses.at(-1).bearing;
|
||||
assert.ok(turned > 150 && turned < 360, `bearing ${turned} turned right`);
|
||||
});
|
||||
|
||||
test('without WebGL, or when the panorama fails, a 360° photo shows its front view', async () => {
|
||||
const noGl = setup({ rendererThrows: true });
|
||||
await noGl.viewer.mount(noGl.host);
|
||||
await noGl.viewer.open('pano1');
|
||||
assert.equal(parts(noGl.host).picture.hidden, false);
|
||||
assert.equal(noGl.loads[0].url, PHOTOS.pano1.urls.view);
|
||||
assert.equal(noGl.poses.at(-1).isPano, false);
|
||||
await settle();
|
||||
assert.equal(
|
||||
noGl.loads.length,
|
||||
1,
|
||||
'the equirectangular image never replaces it',
|
||||
);
|
||||
|
||||
const broken = setup({ failLoads: ['cdn.kartaview.org'] });
|
||||
await broken.viewer.mount(broken.host);
|
||||
await broken.viewer.open('pano1');
|
||||
assert.equal(parts(broken.host).picture.hidden, false);
|
||||
assert.equal(broken.poses.at(-1).imageId, 'pano1');
|
||||
});
|
||||
|
||||
test('‹ › and the arrow keys step along the sequence; ends disable at once', async () => {
|
||||
const { viewer, poses, host, stepped, asked } = setup({
|
||||
steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { root, prev, next } = parts(host);
|
||||
assert.ok(
|
||||
await until(() => prev.disabled),
|
||||
'flat1 is the first photo: known up front',
|
||||
);
|
||||
assert.equal(next.disabled, false);
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => poses.at(-1).imageId === 'flat2'));
|
||||
assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it');
|
||||
assert.ok(await until(() => next.disabled), 'flat2 is the last photo');
|
||||
assert.equal(prev.disabled, false);
|
||||
// A modified arrow, then a plain → (at the end: it only asks). Presses run
|
||||
// in turn, so once → has asked, a stepping ⌘← would have asked first.
|
||||
const before = asked.length;
|
||||
root.fire('keydown', { key: 'ArrowLeft', metaKey: true });
|
||||
root.fire('keydown', { key: 'ArrowRight' });
|
||||
assert.ok(await until(() => asked.length > before));
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
asked.slice(before),
|
||||
['flat2:1'],
|
||||
'modified arrows are not ours',
|
||||
);
|
||||
assert.equal(poses.at(-1).imageId, 'flat2');
|
||||
root.fire('keydown', { key: 'ArrowLeft' });
|
||||
assert.ok(await until(() => poses.at(-1).imageId === 'flat1'));
|
||||
});
|
||||
|
||||
test('quick presses each step on from the photo the last one chose', async () => {
|
||||
const chain = { 'flat1:1': 'flat2', 'flat2:1': 'pano1' };
|
||||
const { viewer, host, stepped } = setup({
|
||||
steps: chain,
|
||||
rendererThrows: true,
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { next } = parts(host);
|
||||
next.fire('click');
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => stepped.length === 2), `stepped ${stepped}`);
|
||||
assert.deepEqual(stepped, ['flat2', 'pano1']);
|
||||
});
|
||||
|
||||
test('a newer open wins; close hides the photo; unmount tears down the panorama', async () => {
|
||||
const { viewer, poses, host, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
const first = viewer.open('pano1');
|
||||
const second = viewer.open('flat1');
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => pose.imageId),
|
||||
['flat1'],
|
||||
'the overtaken panorama never posed',
|
||||
);
|
||||
assert.equal(renderers.length, 0, 'nor built a WebGL context');
|
||||
await viewer.open('pano1');
|
||||
viewer.close();
|
||||
const { picture, canvas, root } = parts(host);
|
||||
assert.equal(canvas.hidden, true);
|
||||
assert.equal(picture.hidden, true);
|
||||
assert.equal(root.dataset.kind, '');
|
||||
await assert.rejects(viewer.open('nope'), /no such photo/);
|
||||
viewer.unmount();
|
||||
assert.equal(host.children.length, 0);
|
||||
assert.equal(renderers[0].destroyed, true);
|
||||
await assert.rejects(viewer.open('flat1'), /not mounted/);
|
||||
});
|
||||
|
||||
test('a panorama is put on screen before its image is sized into the canvas', async () => {
|
||||
const { viewer, host, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
await viewer.open('pano1');
|
||||
await settle();
|
||||
assert.deepEqual(renderers[0].hiddenAtSetImage, [false, false]);
|
||||
});
|
||||
|
||||
test('a step that gets no answer is not the end of the sequence', async () => {
|
||||
const { viewer, host, poses, asked } = setup({ failSteps: true });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { next } = parts(host);
|
||||
next.disabled = false;
|
||||
// The ends check asked once already; the press asks again and fails.
|
||||
const asks = () => asked.filter((ask) => ask === 'flat1:1').length;
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => asks() === 2), 'the press asked');
|
||||
await settle();
|
||||
assert.equal(next.disabled, false, 'still enabled: try again');
|
||||
assert.equal(poses.at(-1).imageId, 'flat1');
|
||||
});
|
||||
|
||||
test('the keyboard looks around a panorama: Shift+arrows turn, ↑ ↓ tilt, + − zoom', async (t) => {
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
t.after(() => delete globalThis.requestAnimationFrame);
|
||||
const { viewer, host, renderers, stepped, asked } = setup({
|
||||
steps: { 'pano1:1': 'flat1' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const { root } = parts(host);
|
||||
const view = () => renderers[0].getView();
|
||||
const before = asked.length;
|
||||
root.fire('keydown', { key: 'ArrowRight', shiftKey: true });
|
||||
assert.ok(view().yaw > 0, 'turned right');
|
||||
root.fire('keydown', { key: 'ArrowUp' });
|
||||
assert.ok(view().pitch > 0, 'looked up');
|
||||
const fov = view().fov;
|
||||
root.fire('keydown', { key: '+' });
|
||||
assert.ok(view().fov < fov, 'zoomed in');
|
||||
root.fire('keydown', { key: '-' });
|
||||
root.fire('keydown', { key: '-' });
|
||||
assert.ok(view().fov > fov, 'zoomed out');
|
||||
root.fire('keydown', { key: 'ArrowRight' });
|
||||
assert.ok(await until(() => stepped.length === 1));
|
||||
assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps');
|
||||
// Only that press asked, then flat1's ends check: a Shift+→ that stepped
|
||||
// would have sent the plain → on from flat1.
|
||||
assert.ok(await until(() => asked.slice(before).length >= 3));
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
asked.slice(before).sort(),
|
||||
['flat1:-1', 'flat1:1', 'pano1:1'],
|
||||
'none of the others stepped',
|
||||
);
|
||||
});
|
||||
|
||||
test('two fingers pinch the panorama’s zoom', async () => {
|
||||
const { viewer, host, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const { canvas } = parts(host);
|
||||
const before = renderers[0].getView().fov;
|
||||
canvas.fire('pointerdown', {
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
canvas.fire('pointerdown', {
|
||||
pointerId: 2,
|
||||
pointerType: 'touch',
|
||||
clientX: 140,
|
||||
clientY: 100,
|
||||
});
|
||||
canvas.fire('pointermove', {
|
||||
pointerId: 2,
|
||||
pointerType: 'touch',
|
||||
clientX: 180,
|
||||
clientY: 100,
|
||||
});
|
||||
assert.ok(renderers[0].getView().fov < before, 'spread zooms in');
|
||||
canvas.fire('pointerup', { pointerId: 1 });
|
||||
canvas.fire('pointerup', { pointerId: 2 });
|
||||
});
|
||||
|
||||
test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => {
|
||||
const { viewer, host, renderers, poses, stepped } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const lost = parts(host).canvas;
|
||||
lost.fire('webglcontextlost');
|
||||
assert.equal(renderers[0].destroyed, true);
|
||||
// A lost canvas only ever gives back its lost context: a new element.
|
||||
assert.notEqual(parts(host).canvas, lost);
|
||||
assert.deepEqual(stepped, ['pano1'], 'reopened through the core');
|
||||
assert.ok(await until(() => renderers.length === 2), 'a new renderer');
|
||||
assert.equal(renderers[1].canvas, parts(host).canvas);
|
||||
assert.equal(poses.at(-1).imageId, 'pano1');
|
||||
assert.equal(parts(host).canvas.hidden, false);
|
||||
});
|
||||
|
||||
test('a context lost while a newer photo opens leaves that open alone', async () => {
|
||||
const { viewer, host, stepped } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const opening = viewer.open('flat1');
|
||||
parts(host).canvas.fire('webglcontextlost');
|
||||
await opening;
|
||||
assert.deepEqual(stepped, [], 'no reopen of the old panorama');
|
||||
assert.equal(parts(host).picture.hidden, false);
|
||||
});
|
||||
|
||||
test('a WebGL failure only lasts until the viewer is mounted again', async () => {
|
||||
let fail = true;
|
||||
const built = [];
|
||||
const viewer = createKartaViewViewer({
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url) => ({ src: url }),
|
||||
createRenderer: (canvas) => {
|
||||
if (fail) throw new Error('WebGL is unavailable');
|
||||
const renderer = fakeRenderer(canvas);
|
||||
built.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
assert.equal(parts(host).picture.hidden, false, 'front view');
|
||||
viewer.unmount();
|
||||
fail = false;
|
||||
const again = fakeHost();
|
||||
await viewer.mount(again);
|
||||
await viewer.open('pano1');
|
||||
assert.equal(built.length, 1);
|
||||
assert.equal(parts(again).canvas.hidden, false);
|
||||
});
|
||||
|
||||
/** Flat photos a → d of one drive, for the tests that hold loads back. */
|
||||
const DRIVE = Object.fromEntries(
|
||||
['a', 'b', 'c', 'd'].map((id, index) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
sequenceId: 'drive',
|
||||
sequenceIndex: index,
|
||||
lon: 0.1 + index * 0.0001,
|
||||
lat: 0.1,
|
||||
heading: 0,
|
||||
isPano: false,
|
||||
capturedAt: 0,
|
||||
urls: {
|
||||
view: `https://storage2.openstreetcam.org/lth/${id}.jpg`,
|
||||
full: `https://storage2.openstreetcam.org/proc/${id}.jpg`,
|
||||
pano: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const DRIVE_ORDER = Object.keys(DRIVE);
|
||||
|
||||
/** Which drive photo, at which size, an image address shows. */
|
||||
function shows(src) {
|
||||
const proxied =
|
||||
/^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:(\d+):\d+\/([\w-]+)$/.exec(
|
||||
src || '',
|
||||
);
|
||||
const url = proxied ? Buffer.from(proxied[2], 'base64url').toString() : src;
|
||||
const id = /\/(\w+)\.jpg$/.exec(url || '')?.[1] ?? null;
|
||||
return proxied ? `${id}@${proxied[1]}` : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewer whose photo loads (and, with `slowSteps`, step answers) wait for
|
||||
* the test. ‹ › open through a stand-in core that, like the real one, awaits
|
||||
* a tick (the viewer mount) before it opens the photo.
|
||||
*/
|
||||
function heldSetup({ slowSteps = false } = {}) {
|
||||
const loads = [];
|
||||
const asks = [];
|
||||
const stepped = [];
|
||||
const shown = [];
|
||||
const viewer = createKartaViewViewer({
|
||||
getPhoto: async (id) => {
|
||||
if (!DRIVE[id]) throw new Error('no such photo');
|
||||
return DRIVE[id];
|
||||
},
|
||||
step: (photo, delta) => {
|
||||
const answer = DRIVE_ORDER[DRIVE_ORDER.indexOf(photo.id) + delta] ?? null;
|
||||
if (!slowSteps) return Promise.resolve(answer);
|
||||
return new Promise((resolve) =>
|
||||
asks.push({ key: `${photo.id}:${delta}`, resolve }),
|
||||
);
|
||||
},
|
||||
onStep: async (id) => {
|
||||
stepped.push(id);
|
||||
await settle();
|
||||
viewer.open(id).catch(() => {});
|
||||
},
|
||||
onShown: (photo) => shown.push(photo.id),
|
||||
load: (url) =>
|
||||
new Promise((resolve, reject) =>
|
||||
loads.push({
|
||||
url,
|
||||
shows: shows(url),
|
||||
resolve: () => resolve({ src: url }),
|
||||
reject: () =>
|
||||
reject(new Error('The KartaView photo could not be loaded')),
|
||||
}),
|
||||
),
|
||||
createRenderer: () => {
|
||||
throw new Error('no WebGL in Node');
|
||||
},
|
||||
});
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
const host = fakeHost();
|
||||
/** Let the load showing `what` (e.g. 'a@1280') finish. */
|
||||
const release = async (what) => {
|
||||
assert.ok(
|
||||
await until(() => loads.some((load) => load.shows === what)),
|
||||
`${what} is loading: ${loads.map((load) => load.shows)}`,
|
||||
);
|
||||
const at = loads.findIndex((load) => load.shows === what);
|
||||
loads.splice(at, 1)[0].resolve();
|
||||
await settle();
|
||||
};
|
||||
/** Answer the oldest pending step ask for `key` (e.g. 'a:1'). */
|
||||
const answer = async (key, value) => {
|
||||
assert.ok(await until(() => asks.some((ask) => ask.key === key)));
|
||||
const at = asks.findIndex((ask) => ask.key === key);
|
||||
asks.splice(at, 1)[0].resolve(value);
|
||||
await settle();
|
||||
};
|
||||
return { viewer, host, loads, asks, stepped, shown, poses, release, answer };
|
||||
}
|
||||
|
||||
test('quick presses while the next photo is still loading each step on from the last one chosen', async () => {
|
||||
const { viewer, host, stepped, release } = heldSetup();
|
||||
await viewer.mount(host);
|
||||
const opening = viewer.open('a');
|
||||
await release('a@1280');
|
||||
await opening;
|
||||
const { next } = parts(host);
|
||||
assert.ok(await until(() => !next.disabled));
|
||||
// b, c and d never load: each press steps on from the photo on its way.
|
||||
next.fire('click');
|
||||
next.fire('click');
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => stepped.length >= 3), `stepped ${stepped}`);
|
||||
await settle();
|
||||
assert.deepEqual(stepped, ['b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('a slow sharp version of an older photo never replaces the newer photo', async () => {
|
||||
const { viewer, host, shown, poses, release } = heldSetup();
|
||||
await viewer.mount(host);
|
||||
// An expanded viewer, so the sharp version is wanted.
|
||||
parts(host).picture.clientWidth = 1600;
|
||||
const first = viewer.open('a');
|
||||
await release('a@1280');
|
||||
await first;
|
||||
const second = viewer.open('b');
|
||||
await release('b@1280');
|
||||
await second;
|
||||
const { picture } = parts(host);
|
||||
assert.equal(shows(picture.src), 'b@1280');
|
||||
await release('a@2560'); // a's upgrade, overtaken
|
||||
assert.equal(shows(picture.src), 'b@1280', 'a never comes back');
|
||||
await release('b@2560');
|
||||
assert.equal(shows(picture.src), 'b@2560', 'b’s own upgrade lands');
|
||||
assert.deepEqual(shown, ['a', 'b']);
|
||||
assert.deepEqual(poses, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('an overtaken flat open never shows, nor tells the provider it did', async () => {
|
||||
const { viewer, host, shown, poses, loads, release } = heldSetup();
|
||||
await viewer.mount(host);
|
||||
const first = viewer.open('a');
|
||||
assert.ok(await until(() => loads.length === 1), 'a is loading');
|
||||
const second = viewer.open('b');
|
||||
await release('b@1280');
|
||||
await second;
|
||||
await release('a@1280');
|
||||
await first;
|
||||
const { picture } = parts(host);
|
||||
assert.equal(shows(picture.src), 'b@1280');
|
||||
assert.deepEqual(poses, ['b']);
|
||||
assert.deepEqual(shown, ['b'], 'onShown only for the photo on screen');
|
||||
});
|
||||
|
||||
test('a photo still loading when the viewer closes stays closed', async () => {
|
||||
const { viewer, host, shown, poses, loads, release } = heldSetup();
|
||||
await viewer.mount(host);
|
||||
const opening = viewer.open('a');
|
||||
assert.ok(await until(() => loads.length === 1), 'a is loading');
|
||||
viewer.close();
|
||||
await release('a@1280');
|
||||
await opening;
|
||||
const { picture, root } = parts(host);
|
||||
assert.equal(picture.hidden, true);
|
||||
assert.equal(root.dataset.kind, '');
|
||||
assert.deepEqual(poses, []);
|
||||
assert.deepEqual(shown, []);
|
||||
});
|
||||
|
||||
test('an ends check for an earlier photo does not disable › on the photo now showing', async () => {
|
||||
const { viewer, host, release, answer } = heldSetup({ slowSteps: true });
|
||||
await viewer.mount(host);
|
||||
const first = viewer.open('a');
|
||||
await release('a@1280');
|
||||
await first;
|
||||
const second = viewer.open('c');
|
||||
await release('c@1280');
|
||||
await second;
|
||||
const { prev, next } = parts(host);
|
||||
await answer('c:-1', 'b');
|
||||
await answer('c:1', 'd');
|
||||
// a's check answers last, saying nothing lies before or after it.
|
||||
await answer('a:-1', null);
|
||||
await answer('a:1', null);
|
||||
assert.equal(prev.disabled, false);
|
||||
assert.equal(next.disabled, false, 'c still has a next photo');
|
||||
});
|
||||
|
||||
test('a press whose answer comes after another photo was opened is dropped', async () => {
|
||||
const { viewer, host, stepped, release, answer, asks } = heldSetup({
|
||||
slowSteps: true,
|
||||
});
|
||||
await viewer.mount(host);
|
||||
const first = viewer.open('a');
|
||||
await release('a@1280');
|
||||
await first;
|
||||
await answer('a:-1', null);
|
||||
await answer('a:1', 'b');
|
||||
const { next } = parts(host);
|
||||
next.disabled = false;
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => asks.some((ask) => ask.key === 'a:1')));
|
||||
// The user picks another photo meanwhile.
|
||||
const other = viewer.open('d');
|
||||
await release('d@1280');
|
||||
await other;
|
||||
await answer('a:1', 'b');
|
||||
await settle();
|
||||
assert.deepEqual(stepped, [], 'the old press does not open b over d');
|
||||
});
|
||||
|
||||
test('a failed open does not become the photo ‹ › step from', async () => {
|
||||
const { viewer, host, stepped } = setup({ steps: { 'flat1:1': 'flat2' } });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
await assert.rejects(viewer.open('nope'), /no such photo/);
|
||||
parts(host).next.disabled = false;
|
||||
parts(host).next.fire('click');
|
||||
assert.ok(await until(() => stepped.length === 1));
|
||||
assert.deepEqual(stepped, ['flat2'], 'from the photo still showing');
|
||||
});
|
||||
|
||||
test('WebGL is tried once per mount, not again for every 360° photo', async () => {
|
||||
const { viewer, host, attempts } = setup({ rendererThrows: true });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
await viewer.open('pano2');
|
||||
assert.equal(attempts.renderer, 1);
|
||||
assert.equal(parts(host).picture.hidden, false, 'front views instead');
|
||||
});
|
||||
|
||||
test('a step button that disables under the keyboard hands focus to the viewer', async () => {
|
||||
const { viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { root, prev, next } = parts(host);
|
||||
let focused = 0;
|
||||
root.focus = () => focused++;
|
||||
next.ownerDocument = { activeElement: next };
|
||||
prev.ownerDocument = { activeElement: root };
|
||||
await viewer.open('flat1');
|
||||
assert.ok(await until(() => next.disabled && prev.disabled));
|
||||
assert.equal(focused, 1, 'only for the focused button');
|
||||
});
|
||||
|
||||
test('› to another 360° photo keeps the look direction; opening one afresh faces its heading', async (t) => {
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
t.after(() => delete globalThis.requestAnimationFrame);
|
||||
const { viewer, host, renderers, poses } = setup({
|
||||
steps: { 'pano1:1': 'pano2' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const { root, next } = parts(host);
|
||||
root.fire('keydown', { key: 'ArrowRight', shiftKey: true });
|
||||
const yaw = renderers[0].getView().yaw;
|
||||
assert.ok(yaw > 0);
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => poses.at(-1).imageId === 'pano2'));
|
||||
assert.equal(renderers[0].getView().yaw, yaw, 'still looking the same way');
|
||||
await viewer.open('pano1');
|
||||
assert.equal(renderers[0].getView().yaw, 0, 'a fresh open faces ahead');
|
||||
});
|
||||
|
||||
/** A viewer whose loads for `held` URLs wait until `release()`. */
|
||||
function heldPanoSetup(held) {
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
let release;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const viewer = createKartaViewViewer({
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep: (id) => {
|
||||
stepped.push(id);
|
||||
viewer.open(id).catch(() => {});
|
||||
},
|
||||
load: async (url) => {
|
||||
if (held.some((part) => url.includes(part))) await gate;
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: (canvas) => {
|
||||
const renderer = fakeRenderer(canvas);
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
return { viewer, renderers, stepped, poses, host: fakeHost(), release };
|
||||
}
|
||||
|
||||
const pano2Preview = Buffer.from(
|
||||
'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg',
|
||||
).toString('base64url');
|
||||
|
||||
test('a context lost while a newer panorama loads shows that one on a new canvas, not a black viewer', async () => {
|
||||
const { viewer, renderers, poses, host, release, stepped } = heldPanoSetup([
|
||||
pano2Preview,
|
||||
]);
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const opening = viewer.open('pano2');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
parts(host).canvas.fire('webglcontextlost');
|
||||
release();
|
||||
await opening;
|
||||
assert.equal(
|
||||
poses.at(-1).imageId,
|
||||
'pano2',
|
||||
'the newer panorama is on screen',
|
||||
);
|
||||
assert.equal(poses.at(-1).isPano, true);
|
||||
assert.deepEqual(stepped, [], 'its own open, not a reopen of the old one');
|
||||
assert.equal(renderers.length, 2, 'drawn by a new renderer');
|
||||
assert.equal(renderers[1].canvas, parts(host).canvas);
|
||||
assert.equal(parts(host).canvas.hidden, false);
|
||||
});
|
||||
|
||||
test('a second context loss, on the new canvas, reopens again; the new canvas still drags', async (t) => {
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
t.after(() => delete globalThis.requestAnimationFrame);
|
||||
const { viewer, host, renderers, stepped, poses } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
parts(host).canvas.fire('webglcontextlost');
|
||||
assert.ok(await until(() => renderers.length === 2));
|
||||
parts(host).canvas.fire('webglcontextlost');
|
||||
assert.ok(await until(() => renderers.length === 3), 'reopened twice');
|
||||
assert.deepEqual(stepped, ['pano1', 'pano1']);
|
||||
assert.equal(renderers[2].canvas, parts(host).canvas);
|
||||
const canvas = parts(host).canvas;
|
||||
const before = poses.at(-1).bearing;
|
||||
canvas.fire('pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 7,
|
||||
pointerType: 'mouse',
|
||||
clientX: 200,
|
||||
clientY: 100,
|
||||
});
|
||||
canvas.fire('pointermove', { pointerId: 7, clientX: 100, clientY: 100 });
|
||||
canvas.fire('pointerup', { pointerId: 7 });
|
||||
assert.ok(await until(() => poses.at(-1).bearing !== before), 'dragged');
|
||||
});
|
||||
|
||||
test('a context loss keeps where the user was looking', async () => {
|
||||
const { viewer, host, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
renderers[0].setView({ yaw: 70, pitch: 12, fov: 60 });
|
||||
parts(host).canvas.fire('webglcontextlost');
|
||||
assert.ok(await until(() => renderers.length === 2));
|
||||
assert.deepEqual(renderers[1].getView(), { yaw: 70, pitch: 12, fov: 60 });
|
||||
});
|
||||
|
||||
test('the panorama is as sharp as the viewer needs: 2,048 docked, 4,096 once expanded', async () => {
|
||||
assert.deepEqual(panoSizeFor(400, 80), [2048, 1024]);
|
||||
assert.deepEqual(panoSizeFor(1400, 80), [4096, 2048]);
|
||||
const { viewer, host, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
assert.ok(await until(() => renderers[0].images.length === 2));
|
||||
assert.match(renderers[0].images[1], /rs:fit:2048:1024/);
|
||||
// Expanded: the panel tells the viewer it resized.
|
||||
parts(host).canvas.clientWidth = 1400;
|
||||
viewer.resize();
|
||||
assert.ok(await until(() => renderers[0].images.length === 3));
|
||||
assert.match(renderers[0].images[2], /rs:fit:4096:2048/);
|
||||
viewer.resize();
|
||||
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(renderers[0].images.length, 3, 'not again once sharp');
|
||||
});
|
||||
|
||||
test('a CORS image is decoded off the main thread into an ImageBitmap where the browser can', async (t) => {
|
||||
const saved = {
|
||||
fetch: globalThis.fetch,
|
||||
bitmap: globalThis.createImageBitmap,
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = saved.fetch;
|
||||
globalThis.createImageBitmap = saved.bitmap;
|
||||
});
|
||||
const fetched = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
fetched.push({ url, mode: init.mode });
|
||||
return url.includes('gone')
|
||||
? { ok: false, status: 409 }
|
||||
: { ok: true, blob: async () => 'blob' };
|
||||
};
|
||||
globalThis.createImageBitmap = async (blob) => ({
|
||||
blob,
|
||||
width: 4096,
|
||||
height: 2048,
|
||||
});
|
||||
const bitmap = await loadImage('https://cdn.kartaview.org/a', {
|
||||
crossOrigin: true,
|
||||
});
|
||||
assert.deepEqual(bitmap, { blob: 'blob', width: 4096, height: 2048 });
|
||||
assert.deepEqual(fetched, [
|
||||
{ url: 'https://cdn.kartaview.org/a', mode: 'cors' },
|
||||
]);
|
||||
await assert.rejects(
|
||||
loadImage('https://cdn.kartaview.org/gone', { crossOrigin: true }),
|
||||
/could not be loaded/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px one', async () => {
|
||||
const { viewer, host, loads } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 1, 'no 2,560 px download for a 400 px frame');
|
||||
parts(host).picture.clientWidth = 1600;
|
||||
viewer.resize();
|
||||
assert.ok(await until(() => loads.length === 2));
|
||||
assert.match(loads[1].url, /rs:fit:2560:2560/);
|
||||
assert.equal(parts(host).picture.src, loads[1].url);
|
||||
viewer.resize();
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 2, 'once only');
|
||||
});
|
||||
|
||||
test('resizes while a sharper panorama loads start no second load; the last size wins once it lands', async () => {
|
||||
let first = true;
|
||||
const { viewer, host, loads, renderers, held } = setup({
|
||||
hold: (url) => {
|
||||
const holdIt = first && url.includes('rs:fit:2048');
|
||||
if (holdIt) first = false;
|
||||
return holdIt;
|
||||
},
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
assert.ok(await until(() => held.length === 1));
|
||||
// The panel animates open: a burst of resizes while 2,048 px loads.
|
||||
parts(host).canvas.clientWidth = 1400;
|
||||
for (let i = 0; i < 5; i++) viewer.resize();
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 2, 'the preview and the one 2,048 px load');
|
||||
held[0]();
|
||||
assert.ok(await until(() => renderers[0].images.length === 3));
|
||||
assert.match(renderers[0].images[1], /rs:fit:2048:1024/);
|
||||
assert.match(renderers[0].images[2], /rs:fit:4096:2048/);
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 3, 'one more load, for the size asked last');
|
||||
});
|
||||
|
||||
test('a preview or sharper version nobody will draw is freed, not left decoded', async () => {
|
||||
const { viewer, host, renderers, held, closed } = setup({
|
||||
hold: (url) => /rs:fit:(1024|2048):/.test(url),
|
||||
});
|
||||
await viewer.mount(host);
|
||||
// pano1's preview is still loading when flat1 opens over it.
|
||||
const overtaken = viewer.open('pano1');
|
||||
assert.ok(await until(() => held.length === 1));
|
||||
await viewer.open('flat1');
|
||||
held[0]();
|
||||
await overtaken;
|
||||
assert.deepEqual(closed.length, 1);
|
||||
assert.match(closed[0], /rs:fit:1024/, 'the overtaken preview');
|
||||
// pano1 shows; its sharp version lands after flat1 replaced it.
|
||||
const shown = viewer.open('pano1');
|
||||
assert.ok(await until(() => held.length === 2));
|
||||
held[1]();
|
||||
await shown;
|
||||
assert.ok(await until(() => held.length === 3));
|
||||
await viewer.open('flat1');
|
||||
held[2]();
|
||||
assert.ok(await until(() => closed.length === 2));
|
||||
assert.match(closed[1], /rs:fit:2048/, 'the overtaken sharp version');
|
||||
assert.equal(renderers[0].images.length, 1, 'only the preview was drawn');
|
||||
});
|
||||
@@ -1,21 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import {
|
||||
createHorizonCull,
|
||||
groundUnderCamera,
|
||||
metresBetween,
|
||||
viewFocus,
|
||||
visibleBbox,
|
||||
whenIdle,
|
||||
} from '../../view.js';
|
||||
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
|
||||
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
|
||||
createLineCoverage,
|
||||
sequenceIdFromPick as lineSequenceIdFromPick,
|
||||
} from '../../lineCoverage.js';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import {
|
||||
coverageZoomForHeight,
|
||||
overviewZoomForHeight,
|
||||
tileBounds,
|
||||
tilesForBbox,
|
||||
} from '../../tileMath.js';
|
||||
import {
|
||||
COLORS,
|
||||
@@ -33,885 +23,48 @@ import {
|
||||
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||
} from './policy.js';
|
||||
|
||||
/** Draped lines stay at most this long while a tile's cast lines build. */
|
||||
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. */
|
||||
const REMESH_STAGGER_MS = 120;
|
||||
/** Old-zoom tiles are kept at most this long after a zoom change. */
|
||||
const STALE_TILE_MAX_MS = 6000;
|
||||
/** Sequences per primitive: smaller batches build, and show, sooner. */
|
||||
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||||
export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js';
|
||||
|
||||
const PER_TILE_SEQUENCE_CAP = Math.floor(
|
||||
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
|
||||
);
|
||||
|
||||
/** Colour for a sequence: Mapillary green, GEV cyan while selected. */
|
||||
function sequenceColor({ selected = false } = {}) {
|
||||
return selected
|
||||
? Cesium.Color.fromCssColorString(COLORS.selected)
|
||||
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92);
|
||||
}
|
||||
|
||||
/** Separates a multi-part sequence's part index from its id in pick ids. */
|
||||
const PART_SEPARATOR = '~';
|
||||
|
||||
/** Pick ids for every part of a sequence; the first part is unsuffixed. */
|
||||
function partIds(sequence) {
|
||||
return sequence.parts.map((_, index) =>
|
||||
index
|
||||
? `${PICK_PREFIX.sequence}${sequence.id}${PART_SEPARATOR}${index}`
|
||||
: `${PICK_PREFIX.sequence}${sequence.id}`,
|
||||
);
|
||||
/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */
|
||||
export function mapillaryZoomFor(heightM) {
|
||||
const sequence = coverageZoomForHeight(heightM);
|
||||
if (sequence != null) return { zoom: sequence, kind: 'sequence' };
|
||||
const overview = overviewZoomForHeight(heightM);
|
||||
return overview != null ? { zoom: overview, kind: 'overview' } : null;
|
||||
}
|
||||
|
||||
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
|
||||
export function sequenceIdFromPick(pickId) {
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
|
||||
return null;
|
||||
const rest = pickId.slice(PICK_PREFIX.sequence.length);
|
||||
const cut = rest.indexOf(PART_SEPARATOR);
|
||||
return cut === -1 ? rest : rest.slice(0, cut);
|
||||
return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any point of `box` ({west, south, east, north}) is within the mesh
|
||||
* sampler's range of `centre`, the ground point under the camera.
|
||||
*/
|
||||
export function meshBoxInRange(box, centre) {
|
||||
// Move the camera's longitude to the box's side of the date line first:
|
||||
// clamping 179.999 into a box at -179.99 would pick its far edge.
|
||||
const lon =
|
||||
centre.lon +
|
||||
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
|
||||
const nearest = {
|
||||
lon: Math.min(box.east, Math.max(box.west, lon)),
|
||||
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
|
||||
};
|
||||
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
|
||||
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
|
||||
* first, then swapped for lines cast to the bare earth (draped lines would
|
||||
* land on roofs), so coverage never waits on terrain heights.
|
||||
*/
|
||||
/** Mapillary's vector-tile coverage through the server proxy. */
|
||||
export function createCoverage({ state, source }) {
|
||||
const { render } = state.services;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('mapillary-coverage');
|
||||
}
|
||||
|
||||
function ensureTerrainReady() {
|
||||
return (state.coverage.terrainReady ||=
|
||||
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
|
||||
}
|
||||
|
||||
function tileKey({ x, y, z }) {
|
||||
return `${z}/${x}/${y}`;
|
||||
}
|
||||
|
||||
/** The imagery filter, resolved now: "since N days" moves with the clock. */
|
||||
function filter() {
|
||||
return state.context.getFilter();
|
||||
}
|
||||
|
||||
function terrainMode() {
|
||||
return (
|
||||
state.context.getSurface?.() === 'terrain' &&
|
||||
Boolean(state.context.groundCaster)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile's sequences, filtered then capped: capping first could leave a
|
||||
* dense tile of newer flat captures with no 360° lines at all.
|
||||
*/
|
||||
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, current)) drawn.push(sequence);
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched primitives for a tile's sequence parts: cast polylines where
|
||||
* terrain heights are cached, draped ground lines otherwise. Each batch
|
||||
* records the selection its colours were built with.
|
||||
*/
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
|
||||
const selectedId = state.sequence.selectedId ?? null;
|
||||
const draped = [];
|
||||
const cast = [];
|
||||
for (const sequence of sequences) {
|
||||
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
sequenceColor({ selected: sequence.id === selectedId }),
|
||||
);
|
||||
const ids = partIds(sequence);
|
||||
sequence.parts.forEach((coordinates, index) => {
|
||||
const flat = ground?.castLine(coordinates, { meshAt });
|
||||
let positions;
|
||||
try {
|
||||
positions = flat
|
||||
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
|
||||
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (positions.length < 2) return;
|
||||
const geometry = flat
|
||||
? new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width: COVERAGE_LINE_WIDTH_PX,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
})
|
||||
: new Cesium.GroundPolylineGeometry({
|
||||
positions,
|
||||
width: COVERAGE_LINE_WIDTH_PX,
|
||||
});
|
||||
(flat ? cast : draped).push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
id: ids[index],
|
||||
attributes: { color },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
const primitives = [];
|
||||
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push({
|
||||
onGround: true,
|
||||
selectedId,
|
||||
primitive: new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||
appearance: new Cesium.PolylineColorAppearance(),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
asynchronous: true,
|
||||
allowPicking: true,
|
||||
}),
|
||||
});
|
||||
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push({
|
||||
onGround: false,
|
||||
selectedId,
|
||||
primitive: new Cesium.Primitive({
|
||||
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
asynchronous: true,
|
||||
allowPicking: true,
|
||||
}),
|
||||
});
|
||||
return { primitives, draped, cast: cast.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the terrain heights a tile's lines need, then redraw it cast.
|
||||
* `castRequested` stops the redraw from casting again at once; a refresh
|
||||
* `retry`s a tile whose lines are still draped (a failed or partial cast),
|
||||
* and it is redrawn only when more of its lines can be cast.
|
||||
*/
|
||||
function castTile(entry, { retry = false } = {}) {
|
||||
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
|
||||
return;
|
||||
entry.castRequested = true;
|
||||
entry.castAbort = new AbortController();
|
||||
const { signal } = entry.castAbort;
|
||||
const lines = [...entry.sequences.values()].flatMap(
|
||||
(sequence) => sequence.parts,
|
||||
);
|
||||
const caster = state.context.groundCaster;
|
||||
caster.prepareLines(lines, { signal }).then(() => {
|
||||
if (entry.castAbort?.signal === signal) entry.castAbort = null;
|
||||
const attached = [...state.coverage.tiles.values()].includes(entry);
|
||||
if (signal.aborted || !attached || !terrainMode()) return;
|
||||
// Nothing new to cast: keep what is drawn until the next refresh.
|
||||
// Counted like buildSequencePrimitives: a cast line needs two points.
|
||||
const castable = lines.filter(
|
||||
(coords) => caster.castLine(coords)?.length >= 6,
|
||||
).length;
|
||||
if (castable <= (entry.castLines || 0)) {
|
||||
entry.castRequested = (entry.castLines || 0) > 0;
|
||||
return;
|
||||
}
|
||||
// A remesh may have cleared the flag while this was in flight.
|
||||
entry.castRequested = true;
|
||||
redrawTile(entry);
|
||||
requestMesh(entry);
|
||||
});
|
||||
}
|
||||
|
||||
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
|
||||
function redrawTile(entry) {
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
attachPrimitive(entry);
|
||||
removeWhenReady(entry, previous);
|
||||
}
|
||||
|
||||
/** Lon/lat under the camera, where mesh range is measured from. */
|
||||
function meshCentre() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile's drawn lines with bounding boxes, built once per filter; mesh
|
||||
* points are densified the first time a line is in range.
|
||||
*/
|
||||
function meshParts(entry) {
|
||||
if (entry.meshParts) return entry.meshParts;
|
||||
entry.meshParts = [];
|
||||
for (const sequence of entry.sequences.values())
|
||||
for (const part of sequence.parts) {
|
||||
const box = {
|
||||
west: Infinity,
|
||||
south: Infinity,
|
||||
east: -Infinity,
|
||||
north: -Infinity,
|
||||
};
|
||||
for (const [lon, lat] of part) {
|
||||
if (lon < box.west) box.west = lon;
|
||||
if (lon > box.east) box.east = lon;
|
||||
if (lat < box.south) box.south = lat;
|
||||
if (lat > box.north) box.north = lat;
|
||||
}
|
||||
entry.meshParts.push({ part, box, points: null });
|
||||
}
|
||||
return entry.meshParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for mesh samples under a cast tile's lines within the sampler's range
|
||||
* of the camera; the rest are asked for when a camera move brings them in.
|
||||
*/
|
||||
function requestMesh(entry) {
|
||||
const sampler = state.context.meshSampler;
|
||||
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
|
||||
const centre = meshCentre();
|
||||
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
|
||||
return;
|
||||
const points = [];
|
||||
for (const line of meshParts(entry)) {
|
||||
if (!meshBoxInRange(line.box, centre)) continue;
|
||||
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
|
||||
for (const point of line.points) points.push(point);
|
||||
}
|
||||
if (points.length) sampler.request(points);
|
||||
}
|
||||
|
||||
/** Redraw the tiles new mesh samples fall in, throttled. */
|
||||
function onMeshSampled(batch) {
|
||||
if (!terrainMode() || !state.context.isActive()) return;
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
if (entry.kind !== 'sequence' || !entry.bounds) continue;
|
||||
const { west, south, east, north } = entry.bounds;
|
||||
if (
|
||||
batch.some(
|
||||
([lon, lat]) =>
|
||||
lon >= west && lon <= east && lat >= south && lat <= north,
|
||||
)
|
||||
)
|
||||
state.coverage.remeshDirty.add(entry);
|
||||
}
|
||||
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
|
||||
const wait = Math.max(
|
||||
0,
|
||||
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
|
||||
);
|
||||
state.coverage.remeshTimer = setTimeout(remesh, wait);
|
||||
}
|
||||
|
||||
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
|
||||
function remesh() {
|
||||
state.coverage.remeshTimer = null;
|
||||
state.coverage.remeshAt = Date.now();
|
||||
if (!terrainMode() || !state.context.isActive()) {
|
||||
state.coverage.remeshDirty.clear();
|
||||
return;
|
||||
}
|
||||
const attached = new Set(state.coverage.tiles.values());
|
||||
const [entry] = state.coverage.remeshDirty;
|
||||
if (!entry) return;
|
||||
state.coverage.remeshDirty.delete(entry);
|
||||
if (attached.has(entry)) {
|
||||
redrawTile(entry);
|
||||
requestRender();
|
||||
}
|
||||
if (state.coverage.remeshDirty.size)
|
||||
state.coverage.remeshTimer = setTimeout(
|
||||
() => whenIdle(remesh, 500),
|
||||
REMESH_STAGGER_MS,
|
||||
);
|
||||
}
|
||||
|
||||
state.coverage.remeshDirty = new Set();
|
||||
state.context.meshSampler?.onSampled(onMeshSampled);
|
||||
|
||||
/**
|
||||
* Remove a tile's previous primitives once all its current ones are ready
|
||||
* (or after a few seconds); detaching the tile removes them at once.
|
||||
*/
|
||||
function removeWhenReady(entry, old) {
|
||||
const scene = state.viewer?.scene;
|
||||
finishSwap(entry);
|
||||
if (!scene?.postRender) {
|
||||
removePrimitives(old);
|
||||
return;
|
||||
}
|
||||
const fresh = entry.primitives;
|
||||
const started = Date.now();
|
||||
const stop = scene.postRender.addEventListener(() => {
|
||||
const ready = fresh.every(
|
||||
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
|
||||
);
|
||||
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
finishSwap(entry);
|
||||
requestRender();
|
||||
});
|
||||
entry.swap = { old, stop };
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function finishSwap(entry) {
|
||||
if (!entry.swap) return;
|
||||
entry.swap.stop();
|
||||
removePrimitives(entry.swap.old);
|
||||
entry.swap = null;
|
||||
}
|
||||
|
||||
function buildOverviewCollection(points) {
|
||||
const collection = new Cesium.PointPrimitiveCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(
|
||||
0.85,
|
||||
);
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
for (const point of points) {
|
||||
if (!passesImageryFilter(point, current)) continue;
|
||||
drawn.push(
|
||||
collection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||||
color: green,
|
||||
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
|
||||
// Google 3D terrain and clouds must not hide the near side's dots;
|
||||
// the horizon cull hides the far side's, which this lets through.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { collection, points: drawn };
|
||||
}
|
||||
|
||||
/** Every overview point on the globe, current zoom and old alike. */
|
||||
function* overviewPoints() {
|
||||
for (const entry of drawnEntries())
|
||||
if (entry.overviewPoints) yield* entry.overviewPoints;
|
||||
}
|
||||
|
||||
// Overview points skip the depth test: hide the far hemisphere's.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: overviewPoints,
|
||||
onChange: requestRender,
|
||||
return createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
decodeCoverageTile(
|
||||
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
|
||||
tile,
|
||||
),
|
||||
zoomFor: mapillaryZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.sequence,
|
||||
renderKey: 'mapillary-coverage',
|
||||
tuning: {
|
||||
maxTiles: COVERAGE_MAX_TILES,
|
||||
overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES,
|
||||
maxSequences: COVERAGE_MAX_SEQUENCES,
|
||||
lineWidthPx: COVERAGE_LINE_WIDTH_PX,
|
||||
overviewPointPx: COVERAGE_OVERVIEW_POINT_PX,
|
||||
moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS,
|
||||
viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M,
|
||||
viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||
viewNearM: SEQUENCE_VIEW_NEAR_M,
|
||||
},
|
||||
messages: {
|
||||
keyRejected: KEY_REJECTED_MESSAGE,
|
||||
rateLimited: RATE_LIMITED_MESSAGE,
|
||||
},
|
||||
});
|
||||
|
||||
function attachPrimitive(entry) {
|
||||
const scene = state.viewer?.scene;
|
||||
if (!scene) return;
|
||||
if (entry.kind === 'sequence') {
|
||||
// Lookup, picking and counts follow what is drawn, not the whole tile.
|
||||
const sequences = drawnSequences(entry);
|
||||
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||||
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
|
||||
entry.primitives = primitives;
|
||||
entry.count = sequences.length;
|
||||
for (const { primitive, onGround } of primitives)
|
||||
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
|
||||
// Fewer lines cast than last time: the caster has dropped heights this
|
||||
// tile used (a full cache), so it must be cast again.
|
||||
if (cast < (entry.castLines || 0)) entry.castRequested = false;
|
||||
entry.castLines = cast;
|
||||
entry.drapedLines = draped.length;
|
||||
if (draped.length) castTile(entry);
|
||||
watchSelection();
|
||||
} else {
|
||||
const { collection, points } = buildOverviewCollection(entry.points);
|
||||
entry.primitive = collection;
|
||||
entry.overviewPoints = points;
|
||||
entry.count = points.length;
|
||||
scene.primitives.add(collection);
|
||||
// Cull the new points now, then all of them whenever the camera moves.
|
||||
horizon.update(points);
|
||||
}
|
||||
}
|
||||
|
||||
function removePrimitives(list) {
|
||||
const scene = state.viewer?.scene;
|
||||
for (const { primitive, onGround } of list || []) {
|
||||
try {
|
||||
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
|
||||
primitive,
|
||||
);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detachPrimitive(entry) {
|
||||
const scene = state.viewer?.scene;
|
||||
finishSwap(entry);
|
||||
removePrimitives(entry.primitives);
|
||||
entry.primitives = [];
|
||||
if (entry.primitive) {
|
||||
try {
|
||||
scene?.primitives?.remove(entry.primitive);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
entry.primitive = null;
|
||||
entry.overviewPoints = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
|
||||
function cancelCast(entry) {
|
||||
entry.castAbort?.abort();
|
||||
entry.castAbort = null;
|
||||
}
|
||||
|
||||
function removeTile(key) {
|
||||
const entry = state.coverage.tiles.get(key);
|
||||
if (!entry) return;
|
||||
cancelCast(entry);
|
||||
detachPrimitive(entry);
|
||||
state.coverage.tiles.delete(key);
|
||||
}
|
||||
|
||||
async function loadTile(tile, kind) {
|
||||
const key = tileKey(tile);
|
||||
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
state.coverage.pending.set(key, controller);
|
||||
notify();
|
||||
try {
|
||||
// Fetch and the one-time terrain-height table load run side by side,
|
||||
// both handled from the start so an early tile failure is not unhandled.
|
||||
const [bytes] = await Promise.all([
|
||||
source.getTile('coverage', tile.z, tile.x, tile.y, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
kind === 'sequence' ? ensureTerrainReady() : null,
|
||||
]);
|
||||
// Only an abort, a retire, a clear or a newer request for this tile
|
||||
// discards the bytes; a refresh that still wants the tile keeps them.
|
||||
const current = () =>
|
||||
state.coverage.pending.get(key) === controller &&
|
||||
!state.coverage.tiles.has(key);
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
!current() ||
|
||||
kind !== state.coverage.kind ||
|
||||
tile.z !== state.coverage.zoom
|
||||
)
|
||||
return;
|
||||
const decoded = decodeCoverageTile(bytes, tile);
|
||||
const entry = { kind, primitive: null, primitives: [], count: 0 };
|
||||
// Padded: vector tiles carry a small buffer past their edge.
|
||||
const bounds = tileBounds(tile.x, tile.y, tile.z);
|
||||
const pad = (bounds.east - bounds.west) * 0.05;
|
||||
entry.bounds = {
|
||||
west: bounds.west - pad,
|
||||
east: bounds.east + pad,
|
||||
south: bounds.south - pad,
|
||||
north: bounds.north + pad,
|
||||
};
|
||||
if (kind === 'sequence') {
|
||||
// Newest first; the per-tile cap applies to what passes the filter.
|
||||
entry.sequenceList = decoded.sequences.sort(
|
||||
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
|
||||
);
|
||||
entry.sequences = new Map();
|
||||
entry.total = decoded.sequences.length;
|
||||
} else {
|
||||
entry.points = decoded.overview;
|
||||
entry.sequences = new Map();
|
||||
entry.total = decoded.overview.length;
|
||||
}
|
||||
attachPrimitive(entry);
|
||||
state.coverage.tiles.set(key, entry);
|
||||
state.coverage.lastError = null;
|
||||
requestRender();
|
||||
} catch (error) {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
state.coverage.pending.get(key) !== controller
|
||||
)
|
||||
return;
|
||||
state.coverage.lastError = error?.message || 'Coverage tile failed';
|
||||
if (error?.keyRequired) state.keyRequired = true;
|
||||
if (error?.keyRejected) {
|
||||
// Every other tile would be refused too: stop asking.
|
||||
state.keyRejected = true;
|
||||
state.coverage.lastError = KEY_REJECTED_MESSAGE;
|
||||
}
|
||||
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 (which is what counts as loading).
|
||||
if (state.coverage.pending.get(key) === controller) {
|
||||
state.coverage.pending.delete(key);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
}
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapillary is rate-limiting: keep what is drawn, request nothing until the
|
||||
* wait is over, then refresh once.
|
||||
*/
|
||||
function holdFor(seconds) {
|
||||
clearTimeout(state.coverage.holdTimer);
|
||||
state.coverage.holdUntil = Date.now() + seconds * 1000;
|
||||
state.coverage.lastError = RATE_LIMITED_MESSAGE;
|
||||
state.coverage.holdTimer = setTimeout(() => {
|
||||
state.coverage.holdTimer = null;
|
||||
state.coverage.holdUntil = 0;
|
||||
if (state.coverage.lastError === RATE_LIMITED_MESSAGE)
|
||||
state.coverage.lastError = null;
|
||||
refresh();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
/** Forget refusals when the provider goes off, so the next run asks again. */
|
||||
function resetErrors() {
|
||||
clearTimeout(state.coverage.holdTimer);
|
||||
state.coverage.holdTimer = null;
|
||||
state.coverage.holdUntil = 0;
|
||||
state.coverage.lastError = null;
|
||||
state.keyRejected = false;
|
||||
}
|
||||
|
||||
/** Drop the previous zoom's tiles once the new ones are on screen. */
|
||||
function purgeStale() {
|
||||
clearTimeout(state.coverage.staleTimer);
|
||||
state.coverage.staleTimer = null;
|
||||
if (!state.coverage.stale.size) return;
|
||||
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
|
||||
state.coverage.stale.clear();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
|
||||
function retire() {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
for (const [key, entry] of state.coverage.tiles) {
|
||||
cancelCast(entry);
|
||||
const previous = state.coverage.stale.get(key);
|
||||
if (previous) detachPrimitive(previous);
|
||||
state.coverage.stale.set(key, entry);
|
||||
}
|
||||
state.coverage.tiles.clear();
|
||||
clearTimeout(state.coverage.staleTimer);
|
||||
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the screen centre meets the ground further ahead than the camera
|
||||
* is high (a view tilted above 45°), or misses it.
|
||||
*/
|
||||
function isTilted({ nadir, ahead }, height) {
|
||||
if (!ahead) return true;
|
||||
return metresBetween(nadir, ahead) > Math.max(1, height);
|
||||
}
|
||||
|
||||
/** Recompute the tile set for the current camera and reconcile primitives. */
|
||||
function refresh() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || !state.context.isActive() || state.keyRequired) return;
|
||||
if (state.statusKnown === false) return;
|
||||
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
|
||||
const ground = groundUnderCamera(viewer, {
|
||||
groundAt: state.context.groundCaster?.groundAt,
|
||||
});
|
||||
const cameraHeight = viewer.camera?.positionCartographic?.height;
|
||||
const height = Number.isFinite(cameraHeight)
|
||||
? cameraHeight - (ground ?? 0)
|
||||
: null;
|
||||
const sequenceZoom = coverageZoomForHeight(height);
|
||||
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
|
||||
const zoom = sequenceZoom ?? overviewZoom;
|
||||
const kind = sequenceZoom ? 'sequence' : 'overview';
|
||||
// At street zooms the rays meet the ground where it really is (1,600 m up
|
||||
// in Denver; Google 3D hides the globe) and stop short of the horizon.
|
||||
const ranged =
|
||||
kind === 'sequence'
|
||||
? {
|
||||
groundHeight: ground,
|
||||
maxRange: Math.max(
|
||||
SEQUENCE_VIEW_RANGE_MIN_M,
|
||||
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
// Tilted street views rank tiles along the line of sight, from the ground
|
||||
// under the camera to the ground at the centre of the screen.
|
||||
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
|
||||
let bbox = visibleBbox(viewer, {
|
||||
...ranged,
|
||||
// A tilted view's screen rows jump from the first metres to the horizon:
|
||||
// keep the ground around the camera too. Looking down needs no help.
|
||||
nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null,
|
||||
});
|
||||
if (kind === 'overview' && (!bbox || zoom <= 1))
|
||||
bbox = [-180, -85, 180, 85];
|
||||
if (zoom == null || !bbox) {
|
||||
state.coverage.hint =
|
||||
'Point the camera at the globe for street-level coverage';
|
||||
clear();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
state.coverage.hint = '';
|
||||
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
|
||||
retire();
|
||||
state.coverage.zoom = zoom;
|
||||
state.coverage.kind = kind;
|
||||
}
|
||||
const { tiles } = tilesForBbox(bbox, zoom, {
|
||||
limit:
|
||||
kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES,
|
||||
focus: focus && { from: focus.nadir, to: focus.ahead },
|
||||
});
|
||||
const wanted = new Set(tiles.map(tileKey));
|
||||
for (const key of [...state.coverage.tiles.keys()])
|
||||
if (!wanted.has(key)) removeTile(key);
|
||||
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 it: settle it here.
|
||||
controller.abort();
|
||||
state.coverage.pending.delete(key);
|
||||
}
|
||||
for (const tile of tiles) loadTile(tile, kind);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
// The camera moved: cells that were out of the sampler's range may not be,
|
||||
// and lines left draped by a failed, partial or dropped cast get another try.
|
||||
if (terrainMode())
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
if (entry.castRequested) requestMesh(entry);
|
||||
if (entry.drapedLines) castTile(entry, { retry: true });
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(state.coverage.debounceTimer);
|
||||
state.coverage.debounceTimer = setTimeout(
|
||||
refresh,
|
||||
COVERAGE_MOVE_DEBOUNCE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function attach(viewer) {
|
||||
detach();
|
||||
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
|
||||
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
|
||||
state.coverage.removeCameraListener = () => {
|
||||
remove();
|
||||
removeEnd();
|
||||
};
|
||||
refresh();
|
||||
}
|
||||
|
||||
function detach() {
|
||||
clearTimeout(state.coverage.debounceTimer);
|
||||
state.coverage.debounceTimer = null;
|
||||
state.coverage.removeCameraListener?.();
|
||||
state.coverage.removeCameraListener = null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
||||
purgeStale();
|
||||
state.coverage.zoom = null;
|
||||
state.coverage.kind = null;
|
||||
clearTimeout(state.coverage.remeshTimer);
|
||||
state.coverage.remeshTimer = null;
|
||||
state.coverage.remeshDirty.clear();
|
||||
stopSelectionWatch();
|
||||
horizon.stop();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
||||
function rebuild() {
|
||||
purgeStale();
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
cancelCast(entry);
|
||||
entry.castRequested = false;
|
||||
entry.meshParts = null;
|
||||
detachPrimitive(entry);
|
||||
attachPrimitive(entry);
|
||||
}
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
||||
* zoom, since the ground height under the camera may have changed.
|
||||
*/
|
||||
function setSurface() {
|
||||
if (!state.context.isActive()) return;
|
||||
rebuild();
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Recolour every part of a sequence in one ready primitive. */
|
||||
function recolorInstances(primitive, sequence, selected) {
|
||||
const value = sequenceColor({ selected });
|
||||
for (const instanceId of partIds(sequence)) {
|
||||
try {
|
||||
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
|
||||
if (attributes)
|
||||
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
value,
|
||||
attributes.color,
|
||||
);
|
||||
} catch {
|
||||
/* instance not in this primitive */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every tile on the globe: the current zoom's and the stale zoom's. */
|
||||
function drawnEntries() {
|
||||
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
||||
}
|
||||
|
||||
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
|
||||
function drawnRecords(entry) {
|
||||
const records = entry.primitives || [];
|
||||
return entry.swap ? [...records, ...entry.swap.old] : records;
|
||||
}
|
||||
|
||||
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||||
function recolorSequence(id, selected) {
|
||||
for (const entry of drawnEntries()) {
|
||||
const sequence = entry.sequences.get(id);
|
||||
if (!sequence) continue;
|
||||
for (const record of drawnRecords(entry)) {
|
||||
// Still building: `syncSelection` catches it up once it is ready.
|
||||
if (!record.primitive.ready) continue;
|
||||
recolorInstances(record.primitive, sequence, selected);
|
||||
if (selected) record.selectedId = id;
|
||||
else if (record.selectedId === id) record.selectedId = null;
|
||||
}
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a ready primitive's highlight up to the current selection, when
|
||||
* it was built with another one (or none). True when it changed.
|
||||
*/
|
||||
function syncSelection(entry, record) {
|
||||
const current = state.sequence.selectedId ?? null;
|
||||
if (record.selectedId === current || !record.primitive.ready) return false;
|
||||
const previous =
|
||||
record.selectedId && entry.sequences.get(record.selectedId);
|
||||
if (previous) recolorInstances(record.primitive, previous, false);
|
||||
const next = current && entry.sequences.get(current);
|
||||
if (next) recolorInstances(record.primitive, next, true);
|
||||
record.selectedId = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A primitive cannot be recoloured until it is ready, so while any is
|
||||
* building, catch each one up with the selection as it becomes ready.
|
||||
*/
|
||||
function watchSelection() {
|
||||
const scene = state.viewer?.scene;
|
||||
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
|
||||
const stop = scene.postRender.addEventListener(() => {
|
||||
let building = false;
|
||||
let changed = false;
|
||||
for (const entry of drawnEntries()) {
|
||||
for (const record of entry.primitives || []) {
|
||||
if (!record.primitive.ready) building = true;
|
||||
else if (syncSelection(entry, record)) changed = true;
|
||||
}
|
||||
// Lines a swap still shows were ready (or never show): catch up only.
|
||||
for (const record of entry.swap?.old || [])
|
||||
if (syncSelection(entry, record)) changed = true;
|
||||
}
|
||||
if (changed) requestRender();
|
||||
if (!building) stopSelectionWatch();
|
||||
});
|
||||
state.coverage.stopSelectionWatch = stop;
|
||||
}
|
||||
|
||||
function stopSelectionWatch() {
|
||||
state.coverage.stopSelectionWatch?.();
|
||||
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() {
|
||||
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 {
|
||||
attach,
|
||||
detach,
|
||||
refresh,
|
||||
clear,
|
||||
resetErrors,
|
||||
rebuild,
|
||||
setSurface,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
};
|
||||
}
|
||||
@@ -55,8 +55,6 @@ export const COVERAGE_LINE_WIDTH_PX = 2.5;
|
||||
|
||||
/** Per-sequence image cones after a sequence is selected. */
|
||||
export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||
export const IMAGE_CONE_SIZE_PX = 26;
|
||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
|
||||
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
|
||||
export const NEAREST_LIMIT = 8;
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { imageConeGlyph } from '../../glyphs.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { refineHeights } from '../../groundCast.js';
|
||||
import { createHorizonCull, metresBetween } from '../../view.js';
|
||||
import { meshCellKey } from '../../meshSampler.js';
|
||||
import {
|
||||
COLORS,
|
||||
IMAGE_CONE_MIN_SPACING_M,
|
||||
IMAGE_CONE_SIZE_PX,
|
||||
PICK_PREFIX,
|
||||
} from './policy.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { COLORS, PICK_PREFIX } from './policy.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:mapillary-cones';
|
||||
|
||||
/** How many recently viewed sequences keep their image list in memory. */
|
||||
const SEQUENCE_CACHE_SIZE = 40;
|
||||
export { thinImages } from '../../sequenceCones.js';
|
||||
|
||||
/** Normalize a graph image record into the shape the cones use. */
|
||||
export function normalizeSequenceImage(record) {
|
||||
@@ -33,290 +20,24 @@ export function normalizeSequenceImage(record) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop images closer than the spacing to the previous kept one. */
|
||||
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
const kept = [];
|
||||
let last = null;
|
||||
for (const image of images) {
|
||||
if (!last || metresBetween(last, image) >= spacingM) {
|
||||
kept.push(image);
|
||||
last = image;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Image cones for one selected sequence. */
|
||||
/** Image cones for the selected Mapillary sequence, its line highlighted. */
|
||||
export function createSequences({ state, source, parts }) {
|
||||
const { render, sprites } = state.services;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('mapillary-sequence');
|
||||
}
|
||||
|
||||
function notify() {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
|
||||
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
|
||||
let drawn = nothingDrawn();
|
||||
|
||||
// The cones skip the depth test: hide the ones behind the globe.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: () => drawn.cones.map((cone) => cone.billboard),
|
||||
onChange: requestRender,
|
||||
});
|
||||
|
||||
function ensureCollections(viewer) {
|
||||
if (state.sequence.collection) return;
|
||||
state.sequence.collection = new Cesium.BillboardCollection({
|
||||
scene: viewer.scene,
|
||||
});
|
||||
viewer.scene.primitives.add(state.sequence.collection);
|
||||
sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection);
|
||||
}
|
||||
|
||||
function clearCones() {
|
||||
state.sequence.collection?.removeAll();
|
||||
state.sequence.images = [];
|
||||
drawn = nothingDrawn();
|
||||
horizon.stop();
|
||||
}
|
||||
|
||||
/** Where a cone stands: at `height`, or clamped to the ground when null. */
|
||||
function placement(image, height) {
|
||||
return {
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
image.lon,
|
||||
image.lat,
|
||||
height ?? 0,
|
||||
),
|
||||
heightReference:
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
|
||||
* by mesh samples. Null when draped or while terrain heights are fetched.
|
||||
*/
|
||||
function coneHeights(images, { request = true } = {}) {
|
||||
const ground = state.context.groundCaster;
|
||||
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
|
||||
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
|
||||
if (dems.includes(null)) {
|
||||
ground
|
||||
.prepare(images.map((image) => [image.lon, image.lat]))
|
||||
.then((ready) => {
|
||||
if (ready && state.sequence.images === images) renderCones(images);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const sampler = state.context.meshSampler;
|
||||
if (request)
|
||||
sampler?.request(images.map((image) => [image.lon, image.lat]));
|
||||
return refineHeights(
|
||||
images.map((image, i) => ({
|
||||
dem: dems[i],
|
||||
mesh: sampler?.meshAt(image.lon, image.lat),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function renderCones(images) {
|
||||
const collection = state.sequence.collection;
|
||||
if (!collection) return;
|
||||
collection.removeAll();
|
||||
drawn = { ...nothingDrawn(), images };
|
||||
const heights = coneHeights(images);
|
||||
// One colour per source; 360° images keep the ring shape.
|
||||
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
|
||||
const ring = imageConeGlyph({
|
||||
size: 32,
|
||||
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, filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
const billboard = collection.add({
|
||||
id: `${PICK_PREFIX.image}${image.id}`,
|
||||
...placement(image, height),
|
||||
image: image.isPano ? ring : cone,
|
||||
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
|
||||
width: IMAGE_CONE_SIZE_PX,
|
||||
height: IMAGE_CONE_SIZE_PX,
|
||||
rotation: -Cesium.Math.toRadians(image.compassAngle),
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
||||
});
|
||||
drawn.cones.push({ index, billboard, height });
|
||||
drawn.cells.add(meshCellKey(image.lon, image.lat));
|
||||
});
|
||||
horizon.update();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-place cones whose height new mesh samples changed. A sample can move
|
||||
* a neighbour too, so every height is recomputed but only moved cones touched.
|
||||
*/
|
||||
function onMeshSampled(batch) {
|
||||
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
|
||||
return;
|
||||
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
|
||||
return;
|
||||
const { images, cones } = drawn;
|
||||
// Already asked for when drawn; a missing terrain cell redraws them all.
|
||||
const heights = coneHeights(images, { request: false });
|
||||
if (!heights) return;
|
||||
const moved = [];
|
||||
for (const cone of cones) {
|
||||
const height = heights[cone.index] ?? null;
|
||||
if (height === cone.height) continue;
|
||||
cone.height = height;
|
||||
const { position, heightReference } = placement(
|
||||
images[cone.index],
|
||||
height,
|
||||
);
|
||||
cone.billboard.heightReference = heightReference;
|
||||
cone.billboard.position = position;
|
||||
moved.push(cone.billboard);
|
||||
}
|
||||
if (!moved.length) return;
|
||||
horizon.update(moved);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function remember(sequenceId, images) {
|
||||
const { cache } = state.sequence;
|
||||
cache.delete(sequenceId);
|
||||
cache.set(sequenceId, images);
|
||||
while (cache.size > SEQUENCE_CACHE_SIZE)
|
||||
cache.delete(cache.keys().next().value);
|
||||
}
|
||||
|
||||
/** 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.abort)
|
||||
)
|
||||
return;
|
||||
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
||||
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.selectedId = sequenceId;
|
||||
parts.coverage.recolorSequence(sequenceId, true);
|
||||
const cached = state.sequence.cache.get(sequenceId);
|
||||
if (cached) {
|
||||
state.sequence.abort = null;
|
||||
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;
|
||||
notify();
|
||||
try {
|
||||
const records = await source.getSequenceImages(sequenceId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
const images = thinImages(
|
||||
return createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const records = await source.getSequenceImages(sequenceId, { signal });
|
||||
return thinImages(
|
||||
records
|
||||
.map(normalizeSequenceImage)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.capturedAt - b.capturedAt),
|
||||
);
|
||||
remember(sequenceId, images);
|
||||
state.sequence.images = images;
|
||||
renderCones(images);
|
||||
state.context.actions.reportError(null);
|
||||
} catch (error) {
|
||||
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;
|
||||
state.sequence.abort = null;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.abort = null;
|
||||
if (state.sequence.selectedId)
|
||||
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
||||
state.sequence.selectedId = null;
|
||||
state.sequence.loading = false;
|
||||
clearCones();
|
||||
// An error about the sequence goes with it.
|
||||
state.context.actions.reportError(null);
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Re-draw the current sequence's cones (after an imagery filter change). */
|
||||
function rerender() {
|
||||
if (state.sequence.images.length) renderCones(state.sequence.images);
|
||||
}
|
||||
|
||||
// New mesh samples may move the cones onto (or off) the road surface.
|
||||
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
|
||||
|
||||
function setVisible(visible) {
|
||||
if (state.sequence.collection) state.sequence.collection.show = visible;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function destroy(viewer) {
|
||||
state.sequence.abort?.abort();
|
||||
horizon.stop();
|
||||
drawn = nothingDrawn();
|
||||
stopMeshListener?.();
|
||||
const collection = state.sequence.collection;
|
||||
if (collection) {
|
||||
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||
viewer?.scene?.primitives?.remove(collection);
|
||||
state.sequence.collection = null;
|
||||
}
|
||||
state.sequence.images = [];
|
||||
state.sequence.selectedId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
ensureCollections,
|
||||
select,
|
||||
clearSelection,
|
||||
rerender,
|
||||
setVisible,
|
||||
destroy,
|
||||
};
|
||||
},
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:mapillary-cones',
|
||||
renderKey: 'mapillary-sequence',
|
||||
onHighlight: (sequenceId, selected) =>
|
||||
parts.coverage.recolorSequence(sequenceId, selected),
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export function filtersOf(provider) {
|
||||
* @property {boolean} [defaultOn] Default true.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one).
|
||||
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
@@ -32,7 +35,7 @@ export function filtersOf(provider) {
|
||||
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
|
||||
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
|
||||
* @property {() => void} notify Ask the core to publish a new UI snapshot.
|
||||
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
|
||||
* @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise<boolean>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, reportError: (message: string|null) => void}} actions
|
||||
*
|
||||
* @typedef {object} ProviderInstance
|
||||
* @property {() => Promise<{configured: boolean}>} status
|
||||
@@ -44,7 +47,7 @@ export function filtersOf(provider) {
|
||||
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
|
||||
* @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
|
||||
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
|
||||
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
|
||||
* @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground.
|
||||
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||
* @property {() => void} [clearSequence]
|
||||
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
|
||||
@@ -65,6 +68,7 @@ export function filtersOf(provider) {
|
||||
* @property {() => void} resize
|
||||
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
|
||||
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
|
||||
* @property {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide.
|
||||
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
|
||||
*
|
||||
* @typedef {object} StreetPose
|
||||
@@ -157,6 +161,14 @@ export function validateProviders(providers) {
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`,
|
||||
);
|
||||
if (
|
||||
provider.sinceBasis !== undefined &&
|
||||
provider.sinceBasis !== 'capture' &&
|
||||
provider.sinceBasis !== 'upload'
|
||||
)
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`,
|
||||
);
|
||||
ids.add(provider.id);
|
||||
prefixes.push(provider.pickPrefix);
|
||||
}
|
||||
|
||||
@@ -117,4 +117,9 @@ test('a provider may narrow the filters it honours and pick a view-cone marker',
|
||||
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
|
||||
/marker must be/,
|
||||
);
|
||||
validateProviders([def({ sinceBasis: 'upload' })]);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ sinceBasis: 'shot' })]),
|
||||
/sinceBasis must be 'capture' or 'upload'/,
|
||||
);
|
||||
});
|
||||
@@ -28,7 +28,24 @@ export function createSelection({ state, parts }) {
|
||||
}
|
||||
const entry = state.providers.get(route.providerId);
|
||||
if (!entry?.on) return;
|
||||
route.instance.handlePick(route.id);
|
||||
route.instance.handlePick(route.id, {
|
||||
groundPoint: () => groundPointAt(viewer, click.position),
|
||||
});
|
||||
}
|
||||
|
||||
/** Lon/lat of the ground under a screen point, or null (sky, no picker). */
|
||||
function groundPointAt(viewer, screenPosition) {
|
||||
const position = state.services.scenePick?.pickGroundPosition?.(
|
||||
viewer,
|
||||
screenPosition,
|
||||
);
|
||||
const carto = position && Cesium.Cartographic.fromCartesian(position);
|
||||
return carto
|
||||
? {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,12 @@ import { createSelection } from './selection.js';
|
||||
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
|
||||
* has a known timeline.
|
||||
*/
|
||||
function harness({ selected = false } = {}) {
|
||||
function harness({
|
||||
selected = false,
|
||||
router = null,
|
||||
providers = new Map(),
|
||||
services = {},
|
||||
} = {}) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
@@ -60,12 +65,16 @@ function harness({ selected = false } = {}) {
|
||||
viewer,
|
||||
enabled: true,
|
||||
clickHandler: null,
|
||||
services: { picking: null, input: null },
|
||||
providers,
|
||||
services: { picking: null, input: null, ...services },
|
||||
};
|
||||
const selection = createSelection({
|
||||
state,
|
||||
parts: {
|
||||
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
||||
router: router || {
|
||||
ownsPick: (id) => id === 'mly:seq:1',
|
||||
resolve: () => null,
|
||||
},
|
||||
hasSelectedSequence: () => sequences.selected,
|
||||
openAtGround: (position, picked) =>
|
||||
groundClicks.push({ position, picked }),
|
||||
@@ -282,3 +291,43 @@ test('a click on nothing of ours goes to the ground handler with the pick, which
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a click on a provider line hands it the ground under the click, measured only if asked', () => {
|
||||
const handled = [];
|
||||
const instance = {
|
||||
handlePick: (id, click) => handled.push({ id, click }),
|
||||
};
|
||||
const groundPicks = [];
|
||||
const h = harness({
|
||||
router: {
|
||||
ownsPick: () => true,
|
||||
resolve: (id) => ({ providerId: 'kartaview', instance, id }),
|
||||
},
|
||||
providers: new Map([['kartaview', { on: true }]]),
|
||||
services: {
|
||||
scenePick: {
|
||||
pickGroundPosition: (viewer, position) => {
|
||||
groundPicks.push(position);
|
||||
return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
h.scene.under = 'kv:line:14/1/2/3';
|
||||
h.click(40);
|
||||
assert.equal(handled.length, 1);
|
||||
assert.equal(handled[0].id, 'kv:line:14/1/2/3');
|
||||
assert.equal(
|
||||
groundPicks.length,
|
||||
0,
|
||||
'no depth read until the provider asks',
|
||||
);
|
||||
const point = handled[0].click.groundPoint();
|
||||
assert.ok(Math.abs(point.lon + 83.0458) < 1e-9);
|
||||
assert.ok(Math.abs(point.lat - 42.3314) < 1e-9);
|
||||
assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { imageConeGlyph } from './glyphs.js';
|
||||
import { passesImageryFilter } from './filter.js';
|
||||
import { refineHeights } from './groundCast.js';
|
||||
import { createHorizonCull, metresBetween } from './view.js';
|
||||
import { meshCellKey } from './meshSampler.js';
|
||||
|
||||
/** How many recently viewed sequences keep their image list in memory. */
|
||||
const SEQUENCE_CACHE_SIZE = 40;
|
||||
|
||||
/** Cones closer than this to the previous kept one are dropped. */
|
||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
export const IMAGE_CONE_SIZE_PX = 26;
|
||||
|
||||
/**
|
||||
* @typedef {object} ConeImage One image of a sequence, as the cones draw it.
|
||||
* @property {string} id
|
||||
* @property {number} lon
|
||||
* @property {number} lat
|
||||
* @property {number} compassAngle Degrees clockwise from north.
|
||||
* @property {number} capturedAt Epoch ms (0 when unknown).
|
||||
* @property {boolean} isPano
|
||||
*/
|
||||
|
||||
/** Drop images closer than the spacing to the previous kept one. */
|
||||
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
const kept = [];
|
||||
let last = null;
|
||||
for (const image of images) {
|
||||
if (!last || metresBetween(last, image) >= spacingM) {
|
||||
kept.push(image);
|
||||
last = image;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image cones for one selected sequence, any provider's. A cone's pick id is
|
||||
* `pickPrefix` + the image id; 360° images draw as rings.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`.
|
||||
* @param {(sequenceId: string, init: {signal: AbortSignal}) => Promise<Array<ConeImage>>} options.loadImages
|
||||
* The sequence's images in capture order, thinned (see `thinImages`).
|
||||
* @param {{coverage: string}} options.colors
|
||||
* @param {string} options.pickPrefix Pick-id prefix of the cones.
|
||||
* @param {string} options.spriteId Sprite-order id of the cone collection.
|
||||
* @param {string} options.renderKey Render-governor reason.
|
||||
* @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight]
|
||||
* Highlight (or stop highlighting) the sequence's coverage line.
|
||||
* @param {(image: ConeImage, filter: object) => boolean} [options.passes]
|
||||
* Whether the imagery filter keeps a cone.
|
||||
*/
|
||||
export function createSequenceCones({
|
||||
state,
|
||||
loadImages,
|
||||
colors,
|
||||
pickPrefix,
|
||||
spriteId,
|
||||
renderKey,
|
||||
onHighlight = () => {},
|
||||
passes = passesImageryFilter,
|
||||
}) {
|
||||
const { render, sprites } = state.services;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.(renderKey);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
|
||||
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
|
||||
let drawn = nothingDrawn();
|
||||
|
||||
// The cones skip the depth test: hide the ones behind the globe.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: () => drawn.cones.map((cone) => cone.billboard),
|
||||
onChange: requestRender,
|
||||
});
|
||||
|
||||
function ensureCollections(viewer) {
|
||||
if (state.sequence.collection) return;
|
||||
state.sequence.collection = new Cesium.BillboardCollection({
|
||||
scene: viewer.scene,
|
||||
});
|
||||
viewer.scene.primitives.add(state.sequence.collection);
|
||||
sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection);
|
||||
}
|
||||
|
||||
function clearCones() {
|
||||
state.sequence.collection?.removeAll();
|
||||
state.sequence.images = [];
|
||||
drawn = nothingDrawn();
|
||||
horizon.stop();
|
||||
}
|
||||
|
||||
/** Where a cone stands: at `height`, or clamped to the ground when null. */
|
||||
function placement(image, height) {
|
||||
return {
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
image.lon,
|
||||
image.lat,
|
||||
height ?? 0,
|
||||
),
|
||||
heightReference:
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
|
||||
* by mesh samples. Null when draped or while terrain heights are fetched.
|
||||
*/
|
||||
function coneHeights(images, { request = true } = {}) {
|
||||
const ground = state.context.groundCaster;
|
||||
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
|
||||
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
|
||||
if (dems.includes(null)) {
|
||||
ground
|
||||
.prepare(images.map((image) => [image.lon, image.lat]))
|
||||
.then((ready) => {
|
||||
if (ready && state.sequence.images === images) renderCones(images);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const sampler = state.context.meshSampler;
|
||||
if (request)
|
||||
sampler?.request(images.map((image) => [image.lon, image.lat]));
|
||||
return refineHeights(
|
||||
images.map((image, i) => ({
|
||||
dem: dems[i],
|
||||
mesh: sampler?.meshAt(image.lon, image.lat),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function renderCones(images) {
|
||||
const collection = state.sequence.collection;
|
||||
if (!collection) return;
|
||||
collection.removeAll();
|
||||
drawn = { ...nothingDrawn(), images };
|
||||
const heights = coneHeights(images);
|
||||
// One colour per source; 360° images keep the ring shape.
|
||||
const cone = imageConeGlyph({ size: 32, color: colors.coverage });
|
||||
const ring = imageConeGlyph({
|
||||
size: 32,
|
||||
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 (!passes(image, filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
const billboard = collection.add({
|
||||
id: `${pickPrefix}${image.id}`,
|
||||
...placement(image, height),
|
||||
image: image.isPano ? ring : cone,
|
||||
imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`,
|
||||
width: IMAGE_CONE_SIZE_PX,
|
||||
height: IMAGE_CONE_SIZE_PX,
|
||||
rotation: -Cesium.Math.toRadians(image.compassAngle),
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
||||
});
|
||||
drawn.cones.push({ index, billboard, height });
|
||||
drawn.cells.add(meshCellKey(image.lon, image.lat));
|
||||
});
|
||||
horizon.update();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-place cones whose height new mesh samples changed. A sample can move
|
||||
* a neighbour too, so every height is recomputed but only moved cones touched.
|
||||
*/
|
||||
function onMeshSampled(batch) {
|
||||
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
|
||||
return;
|
||||
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
|
||||
return;
|
||||
const { images, cones } = drawn;
|
||||
// Already asked for when drawn; a missing terrain cell redraws them all.
|
||||
const heights = coneHeights(images, { request: false });
|
||||
if (!heights) return;
|
||||
const moved = [];
|
||||
for (const cone of cones) {
|
||||
const height = heights[cone.index] ?? null;
|
||||
if (height === cone.height) continue;
|
||||
cone.height = height;
|
||||
const { position, heightReference } = placement(
|
||||
images[cone.index],
|
||||
height,
|
||||
);
|
||||
cone.billboard.heightReference = heightReference;
|
||||
cone.billboard.position = position;
|
||||
moved.push(cone.billboard);
|
||||
}
|
||||
if (!moved.length) return;
|
||||
horizon.update(moved);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function remember(sequenceId, images) {
|
||||
const { cache } = state.sequence;
|
||||
cache.delete(sequenceId);
|
||||
cache.set(sequenceId, images);
|
||||
while (cache.size > SEQUENCE_CACHE_SIZE)
|
||||
cache.delete(cache.keys().next().value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a sequence: highlight its line and load its image cones. `force`
|
||||
* reloads a selected one (a provider that loads part of a sequence moved on).
|
||||
*/
|
||||
async function select(sequenceId, { force = false } = {}) {
|
||||
if (!sequenceId || !state.viewer) return;
|
||||
// Already on screen, or already on its way.
|
||||
if (
|
||||
!force &&
|
||||
state.sequence.selectedId === sequenceId &&
|
||||
(state.sequence.images.length || state.sequence.abort)
|
||||
)
|
||||
return;
|
||||
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
||||
onHighlight(state.sequence.selectedId, false);
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.selectedId = sequenceId;
|
||||
onHighlight(sequenceId, true);
|
||||
const cached = force ? null : state.sequence.cache.get(sequenceId);
|
||||
if (cached) {
|
||||
state.sequence.abort = null;
|
||||
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;
|
||||
notify();
|
||||
try {
|
||||
const images = await loadImages(sequenceId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
remember(sequenceId, images);
|
||||
state.sequence.images = images;
|
||||
renderCones(images);
|
||||
state.context.actions.reportError(null);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
// Nothing to show: drop the highlight, so a click asks again.
|
||||
onHighlight(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;
|
||||
state.sequence.abort = null;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.abort = null;
|
||||
if (state.sequence.selectedId)
|
||||
onHighlight(state.sequence.selectedId, false);
|
||||
state.sequence.selectedId = null;
|
||||
state.sequence.loading = false;
|
||||
clearCones();
|
||||
// An error about the sequence goes with it.
|
||||
state.context.actions.reportError(null);
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Re-draw the current sequence's cones (after an imagery filter change). */
|
||||
function rerender() {
|
||||
if (state.sequence.images.length) renderCones(state.sequence.images);
|
||||
}
|
||||
|
||||
// New mesh samples may move the cones onto (or off) the road surface.
|
||||
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
|
||||
|
||||
function setVisible(visible) {
|
||||
if (state.sequence.collection) state.sequence.collection.show = visible;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function destroy(viewer) {
|
||||
state.sequence.abort?.abort();
|
||||
horizon.stop();
|
||||
drawn = nothingDrawn();
|
||||
stopMeshListener?.();
|
||||
const collection = state.sequence.collection;
|
||||
if (collection) {
|
||||
sprites?.unregisterSpriteCollection?.(spriteId, collection);
|
||||
viewer?.scene?.primitives?.remove(collection);
|
||||
state.sequence.collection = null;
|
||||
}
|
||||
state.sequence.images = [];
|
||||
state.sequence.selectedId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
ensureCollections,
|
||||
select,
|
||||
clearSelection,
|
||||
rerender,
|
||||
setVisible,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import { COLORS } from './policy.js';
|
||||
* @property {string} color The source's one colour.
|
||||
* @property {boolean} [groundClick]
|
||||
* @property {Array<'pano'|'since'>} [filters]
|
||||
* @property {{noun: string, action: string}|null} [coverageLabel]
|
||||
* @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected.
|
||||
* @property {'capture'|'upload'} [sinceBasis] What its SINCE compares.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -34,6 +37,8 @@ export function summarizeCoverage(providers) {
|
||||
? 'No street-level imagery providers configured'
|
||||
: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
|
||||
error: active.find((p) => p.error)?.error || null,
|
||||
// One source is lit at a time; its wording counts the lines.
|
||||
label: active.find((p) => p.coverageLabel)?.coverageLabel || null,
|
||||
keyRequired:
|
||||
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||
keyRejected:
|
||||
@@ -68,6 +73,18 @@ export function filterControls(providers) {
|
||||
return { pano: honoured('pano'), since: honoured('since') };
|
||||
}
|
||||
|
||||
/**
|
||||
* The note under SINCE when a lit provider dates its imagery by upload,
|
||||
* e.g. "KartaView: by upload date", or null.
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
*/
|
||||
export function sinceNote(providers) {
|
||||
const names = providers
|
||||
.filter((p) => p.on && p.sinceBasis === 'upload')
|
||||
.map((p) => p.name);
|
||||
return names.length ? `${names.join(', ')}: by upload date` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel snapshot from core state and provider snapshots. The legend has
|
||||
* one swatch per active source that draws coverage lines, then Selected.
|
||||
@@ -92,7 +109,13 @@ export function composeUIState({
|
||||
label: provider.name,
|
||||
color: provider.color,
|
||||
}));
|
||||
if (legend.length)
|
||||
// "Selected" only beside a source whose lines turn cyan when selected.
|
||||
if (
|
||||
active.some(
|
||||
(provider) =>
|
||||
provider.groundClick !== true && provider.highlightsSequence !== false,
|
||||
)
|
||||
)
|
||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||
return {
|
||||
enabled,
|
||||
@@ -102,6 +125,7 @@ export function composeUIState({
|
||||
keyRejected,
|
||||
filter: { ...filter },
|
||||
filterControls: filterControls(providers),
|
||||
sinceNote: sinceNote(providers),
|
||||
providers: providers.map((p) => ({ ...p })),
|
||||
coverage,
|
||||
legend,
|
||||
|
||||
@@ -128,6 +128,18 @@ test('the filter controls show only for a lit provider that honours them', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a lit provider that dates by upload puts a note under SINCE', () => {
|
||||
const kartaView = provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
sinceBasis: 'upload',
|
||||
});
|
||||
const note = (providers) => composeUIState({ ...base, providers }).sinceNote;
|
||||
assert.equal(note([provider()]), null, 'Mapillary: capture date');
|
||||
assert.equal(note([kartaView]), 'KartaView: by upload date');
|
||||
assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit');
|
||||
});
|
||||
|
||||
test('hint and error come from the first active provider that has one', () => {
|
||||
const ui = composeUIState({
|
||||
...base,
|
||||
@@ -154,6 +166,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
|
||||
loading: true,
|
||||
hint: 'Look down',
|
||||
error: 'boom',
|
||||
label: null,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
},
|
||||
@@ -235,3 +248,25 @@ test('a key-gated layer offers the switched-off sources that would draw with a k
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => {
|
||||
const kartaView = provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
color: '#635bff',
|
||||
highlightsSequence: false,
|
||||
coverageLabel: {
|
||||
noun: 'streets',
|
||||
action: 'click a line for its nearest photo',
|
||||
},
|
||||
});
|
||||
const ui = composeUIState({ ...base, providers: [kartaView] });
|
||||
assert.deepEqual(
|
||||
ui.legend.map((entry) => entry.key),
|
||||
['kartaview'],
|
||||
);
|
||||
assert.deepEqual(ui.coverage.label, {
|
||||
noun: 'streets',
|
||||
action: 'click a line for its nearest photo',
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,10 @@ export function createViewerHost({ state, parts }) {
|
||||
sequenceId: pose.sequenceId ?? null,
|
||||
creator: pose.creator || null,
|
||||
externalUrl: pose.externalUrl || null,
|
||||
// FIT / FILL, unless this image has no frame to fit (a panorama).
|
||||
renderModes:
|
||||
typeof active.adapter.setRenderMode === 'function' &&
|
||||
active.adapter.supportsRenderModes?.() !== false,
|
||||
});
|
||||
parts.marker.set(
|
||||
state.street.position,
|
||||
|
||||
@@ -659,3 +659,17 @@ test('FIT/FILL stays hidden for a second Street View panorama after a close', as
|
||||
await host.open('google', 'g-2');
|
||||
assert.equal(state.street.renderModes, false);
|
||||
});
|
||||
|
||||
test('an adapter that can frame but says this image has no frame offers no FIT / FILL', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
let frameable = false;
|
||||
adapter.setRenderMode = () => {};
|
||||
adapter.supportsRenderModes = () => frameable;
|
||||
const { state, host } = harness(adapter);
|
||||
await host.open('mapillary', 'pano-1');
|
||||
assert.equal(state.street.imageId, 'pano-1', 'the pose arrived');
|
||||
assert.equal(state.street.renderModes, false);
|
||||
frameable = true;
|
||||
await host.open('mapillary', 'flat-1');
|
||||
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
|
||||
});
|
||||
Reference in new issue
Block a user