mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(street-level): KartaView as a third, keyless imagery provider
Coverage lines from KartaView's z13-14 JSON tiles (OSM ways with photos, chained end to start), the photo nearest a clicked line, cones along its sequence, and a viewer for flat photos and 360° panoramas (a small WebGL equirectangular renderer, no new dependency) with ‹ › along the sequence. Mapillary's camera-driven line coverage and sequence cones move to the provider-neutral core (lineCoverage.js, sequenceCones.js) with the tile fetch, zoom ladder, colours and pick prefixes injected; Mapillary keeps thin wrappers and its tests pass unchanged. KartaView quirks handled: api.kartaview.org (the documented api.openstreetcam.org serves empty tiles), photo URLs that name storage1 for photos stored elsewhere, and a photo API that answers 429 to more than about three requests at once (two in flight, retried with backoff). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9beadbda63
commit
cb01014e36
33 files changed
+4046
-1196
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
|
||||
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
@@ -10,12 +11,16 @@ import * as render from '../../renderGovernor.js';
|
||||
/** The browser Maps key the scene was started with (src/app/scene.js). */
|
||||
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
|
||||
|
||||
/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */
|
||||
/**
|
||||
* Mapillary, Google Street View and KartaView, once the Mapillary source is
|
||||
* supplied; else none.
|
||||
*/
|
||||
function defaultProviders(sources) {
|
||||
if (sources.mapillary == null) return [];
|
||||
return [
|
||||
createMapillaryProvider({ source: sources.mapillary }),
|
||||
createGoogleProvider({ getApiKey: googleApiKey }),
|
||||
createKartaViewProvider(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it
|
||||
);
|
||||
});
|
||||
|
||||
test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => {
|
||||
test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => {
|
||||
const { createDefaultLayerState } = await import('../../data/layerState.js');
|
||||
const mapillary = {
|
||||
getStatus() {},
|
||||
@@ -37,7 +37,7 @@ test('the defaults are Mapillary (given its source) and Street View, which start
|
||||
nearestImages() {},
|
||||
};
|
||||
const layer = createApplicationStreetLevel({ sources: { mapillary } });
|
||||
assert.deepEqual(layer.providerIds, ['mapillary', 'google']);
|
||||
assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']);
|
||||
// A fresh page and a restored default agree: Street View is billed, so off.
|
||||
layer.setParams(createDefaultLayerState().options['street-level']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
@@ -45,7 +45,12 @@ test('the defaults are Mapillary (given its source) and Street View, which start
|
||||
// A link that lit Street View carries it in the shared switch field.
|
||||
layer.setParams({ providerSwitches: 'google-1' });
|
||||
assert.equal(layer.getParams().google, true);
|
||||
assert.equal(layer.getParams().providerSwitches, 'google-1');
|
||||
assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0');
|
||||
// KartaView is keyless and free, but one source shows at a time.
|
||||
layer.setParams({ providerSwitches: 'kartaview-1' });
|
||||
assert.equal(layer.getParams().kartaview, true);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
@@ -109,6 +109,12 @@ export function createStreetLevelLayer({
|
||||
notify,
|
||||
actions: {
|
||||
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||
/** 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.
|
||||
@@ -407,6 +413,7 @@ export function createStreetLevelLayer({
|
||||
hint: hintFor(entry, stats),
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
coverageLabel: entry.def.coverageLabel || null,
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,926 @@
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
setSurface,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
};
|
||||
}
|
||||
@@ -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,55 @@
|
||||
/**
|
||||
* Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into
|
||||
* line records for the shared coverage. The tile's lines are OSM way
|
||||
* segments with photos; segments that meet end to start are chained, which
|
||||
* cuts the line count about fourfold. Lines carry no sequence, date or
|
||||
* projection, so nothing can filter them.
|
||||
* @param {object} payload
|
||||
* @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 decodeCoverageTile(payload, { x, y, z }) {
|
||||
const segments = [];
|
||||
for (const line of Array.isArray(payload?.lines) ? payload.lines : []) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
const sequences = [];
|
||||
segments.forEach((segment, index) => {
|
||||
if (used[index]) return;
|
||||
used[index] = 1;
|
||||
const chain = [...segment];
|
||||
for (;;) {
|
||||
const next = startingAt
|
||||
.get(key(chain[chain.length - 1]))
|
||||
?.find((candidate) => !used[candidate]);
|
||||
if (next === undefined) 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: [] };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { decodeCoverageTile } 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, []);
|
||||
assert.deepEqual(decodeCoverageTile(null, TILE).sequences, []);
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { createLineCoverage } from '../../lineCoverage.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { createKartaViewSource } from './source.js';
|
||||
import { createKartaViewViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_MAX_HEIGHT_M,
|
||||
COVERAGE_NOUN,
|
||||
COVERAGE_STREET_ZOOM_BELOW_M,
|
||||
COVERAGE_TUNING,
|
||||
KARTAVIEW_CREDIT_HTML,
|
||||
KARTAVIEW_LABEL,
|
||||
KARTAVIEW_NAME,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
LINE_CLICK_HINT,
|
||||
NEAREST_RADIUS_M,
|
||||
PICK_PREFIX,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
SEQUENCE_PAGE_SIZE,
|
||||
TOO_HIGH_HINT,
|
||||
normalizePhoto,
|
||||
} 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 = 8;
|
||||
|
||||
/** 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(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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}} [options]
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createKartaViewProvider({
|
||||
source = createKartaViewSource(),
|
||||
viewerOptions = {},
|
||||
} = {}) {
|
||||
return Object.freeze({
|
||||
id: KARTAVIEW_PROVIDER_ID,
|
||||
name: KARTAVIEW_NAME,
|
||||
label: KARTAVIEW_LABEL,
|
||||
requiresKeyId: null,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }),
|
||||
defaultOn: false,
|
||||
// Coverage lines carry no date or projection: no filter could thin them.
|
||||
filters: [],
|
||||
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;
|
||||
|
||||
function remember(record) {
|
||||
const photo = normalizePhoto(record);
|
||||
if (photo) 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 }) => ({
|
||||
page,
|
||||
hasMore,
|
||||
photos: records
|
||||
.map(remember)
|
||||
.filter(Boolean)
|
||||
.sort(
|
||||
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
|
||||
),
|
||||
}));
|
||||
pages.set(key, entry);
|
||||
entry.catch(() => pages.delete(key));
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The page holding `photo` (an earlier one when photos are missing). */
|
||||
async function windowOf(photo) {
|
||||
let page = pageOf(photo.sequenceIndex ?? 0);
|
||||
for (let tries = 0; tries < 3; tries++) {
|
||||
const window = await loadPage(photo.sequenceId, page);
|
||||
const first = window.photos[0];
|
||||
if (
|
||||
window.photos.some((p) => p.id === photo.id) ||
|
||||
page === 1 ||
|
||||
!first ||
|
||||
(first.sequenceIndex ?? 0) <= (photo.sequenceIndex ?? 0)
|
||||
)
|
||||
return window;
|
||||
page--;
|
||||
}
|
||||
return loadPage(photo.sequenceId, page);
|
||||
}
|
||||
|
||||
async function step(photo, delta) {
|
||||
if (!photo?.sequenceId) return null;
|
||||
const window = await windowOf(photo);
|
||||
const list = window.photos;
|
||||
let at = list.findIndex((p) => p.id === photo.id);
|
||||
if (at === -1)
|
||||
at = list.findIndex(
|
||||
(p) => (p.sequenceIndex ?? 0) >= (photo.sequenceIndex ?? 0),
|
||||
);
|
||||
const next = at === -1 ? -1 : at + delta;
|
||||
if (next >= 0 && next < list.length) return list[next].id;
|
||||
if (next < 0 && window.page > 1) {
|
||||
const before = await loadPage(photo.sequenceId, window.page - 1);
|
||||
return before.photos.at(-1)?.id ?? null;
|
||||
}
|
||||
if (next >= list.length && window.hasMore) {
|
||||
const after = await loadPage(photo.sequenceId, window.page + 1);
|
||||
return after.photos[0]?.id ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
zoomFor: kartaViewZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.line,
|
||||
renderKey: 'kartaview-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT },
|
||||
passes: () => true,
|
||||
});
|
||||
|
||||
const cones = createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const anchor = anchors.get(sequenceId);
|
||||
if (!anchor) return [];
|
||||
const window = await windowOf(anchor);
|
||||
signal?.throwIfAborted();
|
||||
drawnPage = { sequenceId, page: window.page };
|
||||
return thinImages(
|
||||
window.photos.map((photo) => ({
|
||||
id: photo.id,
|
||||
lon: photo.lon,
|
||||
lat: photo.lat,
|
||||
compassAngle: photo.heading ?? 0,
|
||||
capturedAt: photo.capturedAt ?? 0,
|
||||
isPano: photo.isPano,
|
||||
})),
|
||||
);
|
||||
},
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:kartaview-cones',
|
||||
renderKey: 'kartaview-sequence',
|
||||
});
|
||||
|
||||
/** Re-centre the cones when ‹ › walked off the page they show. */
|
||||
async function followStep(photoId) {
|
||||
const photo = photos.get(photoId);
|
||||
if (!photo?.sequenceId) return;
|
||||
anchors.set(photo.sequenceId, photo);
|
||||
if (drawnPage?.sequenceId !== photo.sequenceId) return;
|
||||
const window = await windowOf(photo).catch(() => null);
|
||||
if (window && window.page !== drawnPage.page)
|
||||
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,
|
||||
onStepped: followStep,
|
||||
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;
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
cones.destroy(cesiumViewer);
|
||||
},
|
||||
|
||||
refreshCoverage: () => coverage.refresh(),
|
||||
|
||||
/** No filter applies (see `filters`). */
|
||||
setFilter() {},
|
||||
|
||||
setSurface() {
|
||||
coverage.setSurface();
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
coverageStats() {
|
||||
const { hint } = state.coverage;
|
||||
const count = coverage.sequenceCount();
|
||||
return {
|
||||
count,
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading: state.coverage.pending.size > 0,
|
||||
hint: 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;
|
||||
},
|
||||
|
||||
selectSequence: (sequenceId) => cones.select(sequenceId),
|
||||
clearSequence: () => {
|
||||
cones.clearSelection();
|
||||
drawnPage = null;
|
||||
},
|
||||
sequenceStats: () => ({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
}),
|
||||
|
||||
/** The nearest photo within the search radius, or null. */
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const records = await source.nearestPhotos({ lat, lon }, { signal });
|
||||
let best = null;
|
||||
for (const record of records) {
|
||||
const photo = remember(record);
|
||||
if (!photo) continue;
|
||||
const metres = metresBetween({ lat, lon }, photo);
|
||||
if (metres <= NEAREST_RADIUS_M && (!best || metres < best.metres))
|
||||
best = { photo, metres };
|
||||
}
|
||||
return best ? best.photo.id : null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
function resize() {
|
||||
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;
|
||||
}
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
frame = 0;
|
||||
if (gl.isContextLost()) return;
|
||||
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);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
|
||||
// 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;
|
||||
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,37 @@
|
||||
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,
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
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 z13 tile holds ~1,300.
|
||||
maxSequences: 9_000,
|
||||
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 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;
|
||||
export const TILE_TIMEOUT_MS = 20_000;
|
||||
export const PHOTO_API_TIMEOUT_MS = 15_000;
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** 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 kB each, so a page is ~2 MB; the cones and prev/next use it.
|
||||
*/
|
||||
export const SEQUENCE_PAGE_SIZE = 1000;
|
||||
|
||||
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
|
||||
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
/** A flat photo's sharp version, after the 1280 px one is on screen. */
|
||||
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 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;
|
||||
}
|
||||
|
||||
/** 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 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*`.
|
||||
*/
|
||||
function fileUrl(record, pathKey, urlKey) {
|
||||
const path = record[pathKey];
|
||||
if (STORAGE_HOST.test(record.storage || '') && path)
|
||||
return `https://${record.storage}.openstreetcam.org/files/photo/${path}`;
|
||||
return record[urlKey] || null;
|
||||
}
|
||||
|
||||
/** A storage URL with its `{{sizeprefix}}` placeholder filled. */
|
||||
function sized(record, prefix) {
|
||||
const url = fileUrl(record, 'filepath', 'fileurl') || '';
|
||||
return url.includes('{{sizeprefix}}')
|
||||
? url.replace('{{sizeprefix}}', prefix)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const heading =
|
||||
record.heading === '' || record.heading == null
|
||||
? NaN
|
||||
: Number(record.heading);
|
||||
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),
|
||||
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 on
|
||||
* KartaView's own storage.
|
||||
*/
|
||||
export function kartaViewPhotoUrl(photo) {
|
||||
return photo?.urls?.full || photo?.urls?.view || 'https://kartaview.org';
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
kartaViewPhotoUrl,
|
||||
kartaViewTimeMs,
|
||||
normalizePhoto,
|
||||
resizedImageUrl,
|
||||
} 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',
|
||||
);
|
||||
assert.equal(kartaViewPhotoUrl(photo), photo.urls.full);
|
||||
// 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);
|
||||
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);
|
||||
assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createKartaViewProvider, kartaViewZoomFor } from './index.js';
|
||||
import { SEQUENCE_PAGE_SIZE } from './policy.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs';
|
||||
|
||||
/** A photo record `metres` east of (42.3314, -83.0458), in sequence `seq` at `index`. */
|
||||
function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) {
|
||||
const lon = -83.0458 + metres / 82_300;
|
||||
return {
|
||||
id: String(id),
|
||||
sequenceId: seq,
|
||||
sequenceIndex: String(index),
|
||||
lat: '42.3314',
|
||||
lng: String(lon),
|
||||
heading: '90',
|
||||
projection: 'PLANE',
|
||||
fieldOfView: '0',
|
||||
shotDate: '2016-08-23 20:17:47.000',
|
||||
storage: 'storage2',
|
||||
filepathLTh: `2016/8/24/lth/${id}.jpg`,
|
||||
filepathProc: `2016/8/24/proc/${id}.jpg`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A source over `sequences` (id → indexes present), recording calls. */
|
||||
function fakeSource({ nearest = [], sequences = {} } = {}) {
|
||||
const calls = { nearest: 0, photo: [], pages: [] };
|
||||
return {
|
||||
calls,
|
||||
getTile: async () => ({ lines: [] }),
|
||||
nearestPhotos: async () => {
|
||||
calls.nearest++;
|
||||
return nearest;
|
||||
},
|
||||
getPhoto: async (id) => {
|
||||
calls.photo.push(id);
|
||||
for (const [seq, indexes] of Object.entries(sequences))
|
||||
for (const index of indexes)
|
||||
if (`${seq}-${index}` === id) return photoRecord(id, { seq, index });
|
||||
throw new Error('KartaView has no such photo');
|
||||
},
|
||||
getSequencePhotos: async (seq, { page, itemsPerPage }) => {
|
||||
calls.pages.push(`${seq}:${page}`);
|
||||
const indexes = sequences[seq] || [];
|
||||
const rows = indexes.slice(
|
||||
(page - 1) * itemsPerPage,
|
||||
page * itemsPerPage,
|
||||
);
|
||||
return {
|
||||
photos: rows.map((index) =>
|
||||
photoRecord(`${seq}-${index}`, { seq, index }),
|
||||
),
|
||||
hasMore: page * itemsPerPage < indexes.length,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeContext() {
|
||||
const actions = { opened: [], nearest: [] };
|
||||
return {
|
||||
actions,
|
||||
context: {
|
||||
services: {},
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
isActive: () => true,
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
getSurface: () => 'draped',
|
||||
notify() {},
|
||||
actions: {
|
||||
openImage: (id) => actions.opened.push(id),
|
||||
openNearest: (point) => actions.nearest.push(point),
|
||||
reportError() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(sourceOptions) {
|
||||
const source = fakeSource(sourceOptions);
|
||||
const loads = [];
|
||||
const definition = createKartaViewProvider({
|
||||
source,
|
||||
viewerOptions: {
|
||||
load: async (url) => {
|
||||
loads.push(url);
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => {
|
||||
throw new Error('no WebGL in Node');
|
||||
},
|
||||
},
|
||||
});
|
||||
const { context, actions } = fakeContext();
|
||||
const instance = definition.create(context);
|
||||
return { definition, instance, source, actions, loads };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('KartaView registers keyless, off by default, with no filters and its own wording', () => {
|
||||
const { definition } = setup();
|
||||
assert.equal(definition.id, 'kartaview');
|
||||
assert.equal(definition.requiresKeyId, null);
|
||||
assert.equal(definition.defaultOn, false);
|
||||
assert.deepEqual(definition.filters, []);
|
||||
assert.match(
|
||||
definition.credit.html,
|
||||
/KartaView<\/a> contributors, CC BY-SA 4\.0/,
|
||||
);
|
||||
assert.equal(definition.colors.coverage, '#635bff');
|
||||
assert.deepEqual(definition.coverageLabel, {
|
||||
noun: 'streets',
|
||||
action: 'click a line for its nearest photo',
|
||||
});
|
||||
// Its prefix fits beside the others.
|
||||
validateProviders([
|
||||
{ ...definition, id: 'mapillary', pickPrefix: 'mly:' },
|
||||
{ ...definition, id: 'google', pickPrefix: 'gsv:' },
|
||||
definition,
|
||||
]);
|
||||
});
|
||||
|
||||
test('coverage is drawn from z14 near the street and z13 a little higher; never from orbit', () => {
|
||||
assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' });
|
||||
assert.equal(kartaViewZoomFor(6_001), null);
|
||||
assert.equal(kartaViewZoomFor(NaN), null);
|
||||
});
|
||||
|
||||
test('the nearest photo is the closest within 50 m, and opening it asks nothing more', async () => {
|
||||
const { instance, source, loads } = setup({
|
||||
nearest: [
|
||||
photoRecord('far', { metres: 80 }),
|
||||
photoRecord('mid', { metres: 30 }),
|
||||
photoRecord('near', { metres: 12 }),
|
||||
],
|
||||
});
|
||||
const id = await instance.nearestImage({ lat: 42.3314, lon: -83.0458 });
|
||||
assert.equal(id, 'near');
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
await instance.viewer.open(id);
|
||||
assert.deepEqual(source.calls.photo, [], 'the lookup filled the cache');
|
||||
assert.equal(
|
||||
loads[0],
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/near.jpg',
|
||||
);
|
||||
const none = setup({ nearest: [photoRecord('far', { metres: 80 })] });
|
||||
assert.equal(
|
||||
await none.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a cone opens its photo; a line opens the photo nearest the click', () => {
|
||||
const { instance, actions } = setup();
|
||||
assert.equal(instance.handlePick('kv:img:123'), true);
|
||||
assert.deepEqual(actions.opened, ['123']);
|
||||
const point = { lon: -83.0458, lat: 42.3314 };
|
||||
assert.equal(
|
||||
instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => point }),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(actions.nearest, [point]);
|
||||
// Over the sky the line is still ours, but there is nowhere to look.
|
||||
assert.equal(
|
||||
instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => null }),
|
||||
true,
|
||||
);
|
||||
assert.equal(actions.nearest.length, 1);
|
||||
assert.equal(instance.handlePick('mly:seq:1'), false);
|
||||
});
|
||||
|
||||
test('coverage stats report KartaView as configured and key-free', async () => {
|
||||
const { instance } = setup();
|
||||
assert.deepEqual(await instance.status(), { configured: true });
|
||||
const stats = instance.coverageStats();
|
||||
assert.equal(stats.keyRequired, false);
|
||||
assert.equal(stats.keyRejected, false);
|
||||
assert.equal(stats.count, 0);
|
||||
});
|
||||
|
||||
test('› walks across sequence pages; ‹ walks back; the ends stop', async () => {
|
||||
const size = SEQUENCE_PAGE_SIZE;
|
||||
const indexes = Array.from({ length: size + 5 }, (_, i) => i);
|
||||
const { instance, source } = setup({ sequences: { s: indexes } });
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
const poses = [];
|
||||
instance.viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
const [, , prev, next] = host.children[0].children;
|
||||
const press = async (button) => {
|
||||
button.fire('click');
|
||||
for (let i = 0; i < 6; i++) await settle();
|
||||
};
|
||||
await instance.viewer.open(`s-${size - 1}`);
|
||||
await press(next);
|
||||
assert.equal(poses.at(-1), `s-${size}`, 'onto the second page');
|
||||
assert.deepEqual(source.calls.pages, ['s:1', 's:2']);
|
||||
await press(prev);
|
||||
assert.equal(poses.at(-1), `s-${size - 1}`);
|
||||
await instance.viewer.open('s-0');
|
||||
await press(prev);
|
||||
assert.equal(poses.at(-1), 's-0', 'nothing before the first photo');
|
||||
assert.equal(prev.disabled, true);
|
||||
});
|
||||
|
||||
test('a page with missing photos is found by stepping back a page', async () => {
|
||||
const size = SEQUENCE_PAGE_SIZE;
|
||||
// Index `size + 100` sits on page 1 when 200 earlier photos are gone.
|
||||
const indexes = Array.from({ length: size + 300 }, (_, i) => i).filter(
|
||||
(i) => i < 100 || i >= 300,
|
||||
);
|
||||
const { instance, source } = setup({ sequences: { s: indexes } });
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
const poses = [];
|
||||
instance.viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
const target = size + 100;
|
||||
await instance.viewer.open(`s-${target}`);
|
||||
const [, , , next] = host.children[0].children;
|
||||
next.fire('click');
|
||||
for (let i = 0; i < 8; i++) await settle();
|
||||
assert.equal(poses.at(-1), `s-${target + 1}`);
|
||||
assert.deepEqual(source.calls.pages.slice(0, 2), ['s:2', 's:1']);
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
KARTAVIEW_API_HOST,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PHOTO_API_CONCURRENCY,
|
||||
PHOTO_API_RETRIES,
|
||||
PHOTO_API_RETRY_BASE_MS,
|
||||
PHOTO_API_TIMEOUT_MS,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */
|
||||
export class KartaViewSourceError extends Error {
|
||||
constructor(message, { status = 0, retryAfterSec = null } = {}) {
|
||||
super(message);
|
||||
this.name = 'KartaViewSourceError';
|
||||
this.status = status;
|
||||
this.retryAfterSec = retryAfterSec;
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer — check the network and try again';
|
||||
|
||||
/** 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 first-in, first-out gate letting at most `limit` tasks run at once. */
|
||||
export function createLimiter(limit) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = 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).
|
||||
* Coverage tiles are not rate-limited; photo lookups are, so they queue two
|
||||
* at a time and retry a 429.
|
||||
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options]
|
||||
*/
|
||||
export function createKartaViewSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
apiHost = KARTAVIEW_API_HOST,
|
||||
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function getJson(url, { signal, timeoutMs }) {
|
||||
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)),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
const 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 },
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
if (error instanceof KartaViewSourceError) throw error;
|
||||
throw new KartaViewSourceError(NO_ANSWER);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/** A photo-API call through the limiter, retrying 429s with backoff. */
|
||||
async function photoApi(path, params, { signal } = {}) {
|
||||
const query = params ? `?${new URLSearchParams(params)}` : '';
|
||||
const url = `${apiHost}${path}${query}`;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const payload = await limiter.run(
|
||||
() => getJson(url, { signal, timeoutMs: PHOTO_API_TIMEOUT_MS }),
|
||||
{ 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(
|
||||
String(
|
||||
payload?.status?.apiMessage || 'KartaView refused the request',
|
||||
).trim(),
|
||||
{ status: Number(payload?.status?.httpCode) || 0 },
|
||||
);
|
||||
return payload?.result ?? {};
|
||||
} catch (error) {
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) throw error;
|
||||
await wait(
|
||||
Math.max(
|
||||
retryBaseMs * 2 ** attempt,
|
||||
(error.retryAfterSec ?? 0) * 1000,
|
||||
),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
return getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||
signal,
|
||||
timeoutMs: TILE_TIMEOUT_MS,
|
||||
});
|
||||
},
|
||||
|
||||
/** Photo records around a point, nearest first. */
|
||||
async nearestPhotos({ lat, lon }, { 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),
|
||||
},
|
||||
{ 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;
|
||||
},
|
||||
|
||||
/** 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 },
|
||||
);
|
||||
return {
|
||||
photos: Array.isArray(result.data) ? result.data : [],
|
||||
hasMore: result.hasMoreData === true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
KartaViewSourceError,
|
||||
createKartaViewSource,
|
||||
createLimiter,
|
||||
} from './source.js';
|
||||
import { PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES } from './policy.js';
|
||||
|
||||
const ok = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
json: async () => body,
|
||||
});
|
||||
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);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls, PHOTO_API_RETRIES + 1);
|
||||
});
|
||||
|
||||
test('an API refusal inside a 200 is an error with KartaView’s message; 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'), /out of range/);
|
||||
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/);
|
||||
const offline = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(offline.getPhoto('1'), /did not answer/);
|
||||
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 }),
|
||||
/did not answer/,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
createPanoramaRenderer,
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
IMAGE_TIMEOUT_MS,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
PANO_FULL_SIZE,
|
||||
PANO_PREVIEW_SIZE,
|
||||
kartaViewPhotoUrl,
|
||||
resizedImageUrl,
|
||||
} from './policy.js';
|
||||
|
||||
const LOAD_FAILED = 'The KartaView photo could not be loaded';
|
||||
|
||||
/** Load an image (CORS-clean, for WebGL) within the timeout. */
|
||||
export function loadImage(
|
||||
url,
|
||||
{ crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {},
|
||||
) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.onStepped] A photo opened from ‹ ›, after its pose.
|
||||
* @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,
|
||||
onStepped = () => {},
|
||||
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 = [];
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('kartaview-viewer');
|
||||
}
|
||||
|
||||
function emit() {
|
||||
if (!photo || !showing) return;
|
||||
const view = showing === 'pano' ? renderer.getView() : null;
|
||||
const heading = photo.heading;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Step along the sequence from the open photo. */
|
||||
async function stepBy(delta) {
|
||||
const from = photo;
|
||||
if (!from?.sequenceId || !showing) return;
|
||||
const seq = openSeq;
|
||||
let nextId = null;
|
||||
try {
|
||||
nextId = await step(from, delta);
|
||||
} catch {
|
||||
nextId = null;
|
||||
}
|
||||
if (seq !== openSeq || photo !== from) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button) button.disabled = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adapter.open(nextId, { keepView: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (photo?.id === nextId) onStepped(nextId);
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (showing !== 'pano' || event.button !== 0) return;
|
||||
drag = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
view: renderer.getView(),
|
||||
};
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
canvas.classList.add('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!drag || event.pointerId !== drag.id) 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) {
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
drag = null;
|
||||
canvas.classList.remove('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
if (showing !== 'pano') 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;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
stepBy(event.key === 'ArrowLeft' ? -1 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
root = doc.createElement('div');
|
||||
root.className = 'sl-kv-viewer';
|
||||
root.tabIndex = 0;
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView photo. Drag to look around a 360° photo; ← and → step along the sequence',
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-kv-photo';
|
||||
picture.alt = 'KartaView street-level photo';
|
||||
picture.draggable = false;
|
||||
picture.hidden = true;
|
||||
canvas = doc.createElement('canvas');
|
||||
canvas.className = 'sl-kv-pano';
|
||||
canvas.hidden = true;
|
||||
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(canvas, 'pointerdown', onPointerDown);
|
||||
listen(canvas, 'pointermove', onPointerMove);
|
||||
listen(canvas, 'pointerup', onPointerUp);
|
||||
listen(canvas, 'pointercancel', onPointerUp);
|
||||
listen(canvas, 'wheel', onWheel, { passive: false });
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
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 url =
|
||||
kind === 'pano'
|
||||
? resizedImageUrl(target.urls.pano, PANO_FULL_SIZE)
|
||||
: resizedImageUrl(target.urls.full, FLAT_FULL_SIZE);
|
||||
if (!url) return;
|
||||
try {
|
||||
const image = await load(url, { crossOrigin: kind === 'pano' });
|
||||
if (seq !== openSeq || photo !== target || showing !== kind) return;
|
||||
if (kind === 'pano') renderer.setImage(image);
|
||||
else picture.src = image.src;
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays */
|
||||
}
|
||||
}
|
||||
|
||||
async function showPano(target, seq, keepView) {
|
||||
const preview = resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE);
|
||||
let image;
|
||||
try {
|
||||
image = await load(preview, { crossOrigin: true });
|
||||
} catch {
|
||||
image = await load(resizedImageUrl(target.urls.pano, PANO_FULL_SIZE), {
|
||||
crossOrigin: true,
|
||||
});
|
||||
}
|
||||
if (seq !== openSeq) return false;
|
||||
if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
renderer.setImage(image);
|
||||
photo = target;
|
||||
show('pano');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showFlat(target, seq) {
|
||||
let image;
|
||||
try {
|
||||
image = await load(target.urls.view, { crossOrigin: false });
|
||||
} catch (error) {
|
||||
// No 1280 px rendition: the proxy can shrink the full-size photo.
|
||||
const fallback = resizedImageUrl(target.urls.full, FLAT_FULL_SIZE);
|
||||
if (target.isPano || !fallback || seq !== openSeq) throw error;
|
||||
image = await load(fallback, { crossOrigin: false });
|
||||
}
|
||||
if (seq !== openSeq) return false;
|
||||
picture.src = image.src;
|
||||
photo = target;
|
||||
show('flat');
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
if (root && root.parentNode === host) return;
|
||||
teardown();
|
||||
build(host);
|
||||
},
|
||||
|
||||
/** Resolves once the photo is on screen and its pose was emitted. */
|
||||
async open(imageId, { keepView = false } = {}) {
|
||||
if (!root) throw new Error('KartaView viewer is not mounted');
|
||||
const seq = ++openSeq;
|
||||
const target = await getPhoto(String(imageId));
|
||||
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();
|
||||
upgrade(target, seq, showing);
|
||||
},
|
||||
|
||||
close() {
|
||||
openSeq++;
|
||||
photo = null;
|
||||
show(null);
|
||||
if (picture) picture.removeAttribute('src');
|
||||
renderer?.clear?.();
|
||||
},
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
teardown();
|
||||
},
|
||||
|
||||
resize() {
|
||||
renderer?.resize();
|
||||
},
|
||||
|
||||
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
|
||||
supportsRenderModes: () => showing !== 'pano',
|
||||
|
||||
setRenderMode(mode) {
|
||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
applyRenderMode();
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createKartaViewViewer } from './viewer.js';
|
||||
import { fakeHost } 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function fakeRenderer() {
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
return {
|
||||
images: [],
|
||||
setImage(image) {
|
||||
this.images.push(image.src);
|
||||
},
|
||||
setView(next) {
|
||||
Object.assign(view, next);
|
||||
},
|
||||
getView: () => ({ ...view }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
const loads = [];
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
const viewer = createKartaViewViewer({
|
||||
getPhoto: async (id) => {
|
||||
if (!PHOTOS[id]) throw new Error('no such photo');
|
||||
return PHOTOS[id];
|
||||
},
|
||||
step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null,
|
||||
onStepped: (id) => stepped.push(id),
|
||||
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');
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => {
|
||||
if (rendererThrows) throw new Error('WebGL is unavailable');
|
||||
const renderer = fakeRenderer();
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
const host = fakeHost();
|
||||
return { viewer, poses, host, loads, renderers, stepped };
|
||||
}
|
||||
|
||||
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();
|
||||
await viewer.mount(host);
|
||||
const { picture, canvas, prev, next } = parts(host);
|
||||
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.equal(pose.externalUrl, PHOTOS.flat1.urls.full);
|
||||
assert.equal(loads[0].url, PHOTOS.flat1.urls.view);
|
||||
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 missing 1280 px rendition falls back to the proxy-shrunk full photo', async () => {
|
||||
const { viewer, host, loads } = setup({ failLoads: ['/lth/'] });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
assert.match(loads[1].url, /rs:fit:2560:2560/);
|
||||
assert.equal(parts(host).picture.hidden, false);
|
||||
});
|
||||
|
||||
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();
|
||||
assert.match(renderers[0].images.at(-1), /rs:fit:4096:2048/);
|
||||
// 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; the end disables the button', async () => {
|
||||
const { viewer, poses, host, stepped } = setup({
|
||||
steps: { 'flat1:1': 'flat2' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { root, prev, next } = parts(host);
|
||||
next.fire('click');
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'flat2');
|
||||
assert.deepEqual(stepped, ['flat2']);
|
||||
root.fire('keydown', { key: 'ArrowRight' });
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(next.disabled, true, 'flat2 is the last photo');
|
||||
root.fire('keydown', { key: 'ArrowLeft', metaKey: true });
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours');
|
||||
assert.equal(prev.disabled, false);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
@@ -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,7 @@ 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 {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
@@ -32,7 +33,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) => Promise<void>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, reportError: (message: string|null) => void}} actions
|
||||
*
|
||||
* @typedef {object} ProviderInstance
|
||||
* @property {() => Promise<{configured: boolean}>} status
|
||||
@@ -44,7 +45,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]
|
||||
|
||||
@@ -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,331 @@
|
||||
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.
|
||||
*/
|
||||
export function createSequenceCones({
|
||||
state,
|
||||
loadImages,
|
||||
colors,
|
||||
pickPrefix,
|
||||
spriteId,
|
||||
renderKey,
|
||||
onHighlight = () => {},
|
||||
}) {
|
||||
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 (!passesImageryFilter(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,7 @@ 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]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,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:
|
||||
|
||||
@@ -154,6 +154,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
|
||||
loading: true,
|
||||
hint: 'Look down',
|
||||
error: 'boom',
|
||||
label: null,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// DOM stand-ins for the KartaView viewer's tests (no browser in Node).
|
||||
|
||||
/** A DOM element with what the viewer touches: children, events, attributes. */
|
||||
export function fakeElement(tag) {
|
||||
const listeners = new Map();
|
||||
const element = {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [],
|
||||
parentNode: null,
|
||||
style: {},
|
||||
dataset: {},
|
||||
attributes: {},
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
clientWidth: 400,
|
||||
clientHeight: 250,
|
||||
classList: {
|
||||
values: new Set(),
|
||||
add: (name) => element.classList.values.add(name),
|
||||
remove: (name) => element.classList.values.delete(name),
|
||||
},
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
node.parentNode = element;
|
||||
element.children.push(node);
|
||||
}
|
||||
},
|
||||
remove() {
|
||||
const list = element.parentNode?.children;
|
||||
if (list) list.splice(list.indexOf(element), 1);
|
||||
element.parentNode = null;
|
||||
},
|
||||
setAttribute: (name, value) => (element.attributes[name] = value),
|
||||
removeAttribute: (name) => {
|
||||
delete element.attributes[name];
|
||||
if (name === 'src') element.src = '';
|
||||
},
|
||||
addEventListener: (type, handler) => listeners.set(type, handler),
|
||||
removeEventListener: (type) => listeners.delete(type),
|
||||
setPointerCapture() {},
|
||||
/** Fire an event at the element's listener. */
|
||||
fire(type, event = {}) {
|
||||
return listeners.get(type)?.({
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
stopPropagation() {},
|
||||
...event,
|
||||
});
|
||||
},
|
||||
};
|
||||
return element;
|
||||
}
|
||||
|
||||
export function fakeHost() {
|
||||
const doc = { createElement: (tag) => fakeElement(tag) };
|
||||
const host = fakeElement('div');
|
||||
host.ownerDocument = doc;
|
||||
return host;
|
||||
}
|
||||
@@ -15,7 +15,8 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
|
||||
/**
|
||||
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
|
||||
* terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its
|
||||
* coverage tiles come through the app's server). Everything from the app's
|
||||
* coverage tiles come through the app's server), Google Street View and
|
||||
* KartaView. Everything from the app's
|
||||
* own server comes through the MCP server.
|
||||
*/
|
||||
const PROVIDER_ORIGINS = Object.freeze([
|
||||
@@ -44,6 +45,10 @@ const PROVIDER_ORIGINS = Object.freeze([
|
||||
'https://*.ggpht.com',
|
||||
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
|
||||
'https://*.xx.fbcdn.net',
|
||||
// KartaView's API, its image proxy and its photo storage (storage1…storage13).
|
||||
'https://api.kartaview.org',
|
||||
'https://cdn.kartaview.org',
|
||||
'https://*.openstreetcam.org',
|
||||
]);
|
||||
|
||||
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
|
||||
|
||||
@@ -193,7 +193,7 @@ function presentMeta(state) {
|
||||
const hint = state.coverage.hint || '';
|
||||
if (state.coverage.count > 0)
|
||||
return [
|
||||
`${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`,
|
||||
`${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`,
|
||||
hint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -613,6 +613,86 @@ button.sl-status:focus-visible {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* ── KartaView photo viewer ──────────────────────────────────────────────── */
|
||||
.sl-kv-viewer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sl-kv-viewer:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--sl-kartaview, #635bff);
|
||||
}
|
||||
|
||||
.sl-kv-photo,
|
||||
.sl-kv-pano {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sl-kv-photo {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sl-kv-pano {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sl-kv-pano.sl-kv-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sl-kv-photo[hidden],
|
||||
.sl-kv-pano[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-kv-step {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
width: 34px;
|
||||
height: 44px;
|
||||
margin-top: -22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 10, 15, 0.62);
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sl-kv-step-prev {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.sl-kv-step-next {
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.sl-kv-step:hover:not(:disabled),
|
||||
.sl-kv-step:focus-visible {
|
||||
background: rgba(99, 91, 255, 0.85);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sl-kv-step:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Nothing to step through until a photo shows. */
|
||||
.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Short viewports ─────────────────────────────────────────────────────── */
|
||||
@media (max-height: 640px) {
|
||||
.street-level-panel-inner {
|
||||
|
||||
Reference in new issue
Block a user