mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Tilted views: the visible box came from screen rays against the bare
ellipsoid and the 9 tiles kept were the ones nearest its centre, so a view
over high ground (Denver, 1,600 m up) or near the horizon dropped the
streets in front of the camera. At street zooms the rays now meet an
ellipsoid raised to the ground under the camera, hits beyond a range tied
to camera height are ignored, tilted views also keep the ground around the
camera, and tiles are ranked along the line of sight (nadir to screen
centre). Simulated with real Cesium ray maths over Sacramento and Denver
from 2 m to 1,000 m: the nearest visible ground and the screen centre are
always loaded.
Rejected token: Mapillary's 401/403 reached the panel as a raw "Mapillary
tiles HTTP 403" while the pill said ON, and every pan asked Mapillary again.
The proxy now answers a refusal as {keyRejected: true} and holds it for that
token (5 min), and a 429 with its Retry-After (held until then). The panel
reads KEY REJECTED, names MAPILLARY_CLIENT_TOKEN and Provider Settings,
gates its controls like KEY REQUIRED, requests nothing more, and clears the
message when the layer goes off. A rate limit keeps the drawn tiles and
refreshes once the wait is over.
Found in the IC8 review of #768 (P1 tile selection, P2 rejected token).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
770 lines
26 KiB
JavaScript
770 lines
26 KiB
JavaScript
import * as Cesium from 'cesium';
|
||
import { decodeCoverageTile } from './decode.js';
|
||
import { passesImageryFilter } from '../../filter.js';
|
||
import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js';
|
||
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
|
||
import { MESH_CELL_DEG } from '../../meshSampler.js';
|
||
import {
|
||
coverageZoomForHeight,
|
||
overviewZoomForHeight,
|
||
tileBounds,
|
||
tilesForBbox,
|
||
} from '../../tileMath.js';
|
||
import {
|
||
COLORS,
|
||
COVERAGE_LINE_WIDTH_PX,
|
||
COVERAGE_MAX_SEQUENCES,
|
||
COVERAGE_MAX_TILES,
|
||
COVERAGE_MOVE_DEBOUNCE_MS,
|
||
COVERAGE_OVERVIEW_MAX_TILES,
|
||
COVERAGE_OVERVIEW_POINT_PX,
|
||
KEY_REJECTED_MESSAGE,
|
||
PICK_PREFIX,
|
||
RATE_LIMITED_MESSAGE,
|
||
SEQUENCE_VIEW_NEAR_M,
|
||
SEQUENCE_VIEW_RANGE_MIN_M,
|
||
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||
} from './policy.js';
|
||
|
||
/** Draped lines stay at most this long while a tile's cast lines build. */
|
||
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 ground primitive. Ground polyline geometry is built on a
|
||
* worker in proportion to the instance count, so smaller batches put the
|
||
* first lines on screen sooner instead of one big batch arriving late.
|
||
*/
|
||
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||
|
||
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}`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Sequence id for a picked line, whichever part was hit: `mly:seq:<id>` and
|
||
* `mly:seq:<id>~<part>` both give `<id>`; null for any other pick.
|
||
* @param {string} pickId
|
||
* @returns {string|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);
|
||
}
|
||
|
||
/**
|
||
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
|
||
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
|
||
* tiles are kept so the imagery filter can rebuild without refetching.
|
||
*
|
||
* In the core's terrain surface mode (Google 3D at street zoom) a tile's
|
||
* lines are cast to the bare earth instead: draped lines would land on roofs
|
||
* and tree tops. A tile is drawn draped first and swapped for its cast lines
|
||
* once the terrain heights are in, so coverage never waits on the terrain.
|
||
*/
|
||
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}`;
|
||
}
|
||
|
||
function filter() {
|
||
return state.filter;
|
||
}
|
||
|
||
function terrainMode() {
|
||
return (
|
||
state.context.getSurface?.() === 'terrain' &&
|
||
Boolean(state.context.groundCaster)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Primitives for a tile's sequences, in draw batches: ground primitives
|
||
* draped on the globe, plus (in terrain mode) plain polylines at the cast
|
||
* heights for every part whose heights are cached. Each part of a sequence
|
||
* with a capture gap is its own line.
|
||
*/
|
||
function buildSequencePrimitives(sequences) {
|
||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
|
||
const draped = [];
|
||
const cast = [];
|
||
let drawn = 0;
|
||
for (const sequence of sequences) {
|
||
if (!passesImageryFilter(sequence, filter())) continue;
|
||
drawn++;
|
||
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||
sequenceColor({ selected: sequence.id === state.sequence.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,
|
||
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,
|
||
primitive: new Cesium.Primitive({
|
||
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||
asynchronous: true,
|
||
allowPicking: true,
|
||
}),
|
||
});
|
||
return { primitives, count: drawn, draped };
|
||
}
|
||
|
||
/**
|
||
* Fetch the terrain heights a tile's lines need, then redraw the tile cast.
|
||
* Runs once per tile entry; unresolved lines stay draped.
|
||
*/
|
||
function castTile(entry) {
|
||
if (entry.castRequested || !terrainMode()) return;
|
||
entry.castRequested = true;
|
||
entry.castAbort = new AbortController();
|
||
const { signal } = entry.castAbort;
|
||
const lines = entry.sequenceList
|
||
.filter((sequence) => passesImageryFilter(sequence, filter()))
|
||
.flatMap((sequence) => sequence.parts);
|
||
const caster = state.context.groundCaster;
|
||
caster.prepareLines(lines, { signal }).then(() => {
|
||
const attached = [...state.coverage.tiles.values()].includes(entry);
|
||
if (signal.aborted || !attached || !terrainMode()) return;
|
||
// Nothing new to cast (terrain proxy down, tile too big): keep it draped.
|
||
if (!lines.some((coords) => caster.castLine(coords))) return;
|
||
// Keep the draped lines until the cast ones are built, so nothing blinks.
|
||
const previous = entry.primitives;
|
||
entry.primitives = [];
|
||
attachPrimitive(entry);
|
||
removeWhenReady(entry, previous);
|
||
requestMesh(entry);
|
||
});
|
||
}
|
||
|
||
/** The mesh cells under a tile's lines, built once per tile. */
|
||
function meshCells(entry) {
|
||
if (entry.meshCells) return entry.meshCells;
|
||
const cells = new Map();
|
||
for (const sequence of entry.sequenceList)
|
||
for (const [lon, lat] of sequence.parts.flatMap((part) =>
|
||
densifyLine(part, MESH_DENSIFY_DEG),
|
||
))
|
||
cells.set(
|
||
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`,
|
||
[lon, lat],
|
||
);
|
||
entry.meshCells = [...cells.values()];
|
||
return entry.meshCells;
|
||
}
|
||
|
||
/** Ask for mesh samples under a cast tile's lines (the sampler keeps the near ones). */
|
||
function requestMesh(entry) {
|
||
const sampler = state.context.meshSampler;
|
||
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
|
||
sampler.request(meshCells(entry));
|
||
}
|
||
|
||
/**
|
||
* New mesh samples landed: redraw the tiles they fall in, throttled, with
|
||
* the same no-blink swap as the bare-earth cast.
|
||
*/
|
||
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)) {
|
||
const previous = entry.primitives;
|
||
entry.primitives = [];
|
||
attachPrimitive(entry);
|
||
removeWhenReady(entry, previous);
|
||
requestRender();
|
||
}
|
||
if (state.coverage.remeshDirty.size)
|
||
state.coverage.remeshTimer = setTimeout(
|
||
() => idleTask(remesh),
|
||
REMESH_STAGGER_MS,
|
||
);
|
||
}
|
||
|
||
function idleTask(task) {
|
||
if (typeof globalThis.requestIdleCallback === 'function')
|
||
globalThis.requestIdleCallback(() => task(), { timeout: 500 });
|
||
else task();
|
||
}
|
||
|
||
state.coverage.remeshDirty = new Set();
|
||
state.context.meshSampler?.onSampled(onMeshSampled);
|
||
|
||
/**
|
||
* 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,
|
||
);
|
||
let count = 0;
|
||
for (const point of points) {
|
||
if (!passesImageryFilter(point, filter())) continue;
|
||
collection.add({
|
||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||
color: green,
|
||
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
|
||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||
});
|
||
count++;
|
||
}
|
||
return { collection, count };
|
||
}
|
||
|
||
function attachPrimitive(entry) {
|
||
const scene = state.viewer?.scene;
|
||
if (!scene) return;
|
||
if (entry.kind === 'sequence') {
|
||
const { primitives, count, draped } = buildSequencePrimitives(
|
||
entry.sequenceList,
|
||
);
|
||
entry.primitives = primitives;
|
||
entry.count = count;
|
||
for (const { primitive, onGround } of primitives)
|
||
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
|
||
if (draped.length) castTile(entry);
|
||
} else {
|
||
const { collection, count } = buildOverviewCollection(entry.points);
|
||
entry.primitive = collection;
|
||
entry.count = count;
|
||
scene.primitives.add(collection);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** 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);
|
||
state.coverage.loading++;
|
||
notify();
|
||
try {
|
||
// Fetch and the one-time terrain-height table load run side by side.
|
||
const fetching = source.getTile('coverage', tile.z, tile.x, tile.y, {
|
||
signal: controller.signal,
|
||
});
|
||
if (kind === 'sequence') await ensureTerrainReady();
|
||
const bytes = await fetching;
|
||
// A refresh that still wants this tile must not throw the bytes away;
|
||
// only an abort (tile no longer wanted, or zoom changed) does.
|
||
// A retire, clear or newer request for this tile owns it now.
|
||
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, capped per tile so a dense city stays within budget.
|
||
const sequences = decoded.sequences
|
||
.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0))
|
||
.slice(0, PER_TILE_SEQUENCE_CAP);
|
||
entry.sequenceList = sequences;
|
||
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||
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 or its loading count.
|
||
if (state.coverage.pending.get(key) === controller) {
|
||
state.coverage.pending.delete(key);
|
||
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
|
||
if (!state.coverage.pending.size) purgeStale();
|
||
}
|
||
notify();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 every loaded tile to the stale set instead of removing it, so the
|
||
* old zoom stays visible while the new zoom streams in (no blank globe).
|
||
*/
|
||
function retire() {
|
||
for (const controller of state.coverage.pending.values())
|
||
controller.abort();
|
||
state.coverage.pending.clear();
|
||
state.coverage.loading = 0;
|
||
for (const [key, entry] of state.coverage.tiles) {
|
||
cancelCast(entry);
|
||
const previous = state.coverage.stale.get(key);
|
||
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;
|
||
const dLat = (ahead.lat - nadir.lat) * 111_320;
|
||
const dLon =
|
||
(ahead.lon - nadir.lon) * 111_320 * Math.cos((nadir.lat * Math.PI) / 180);
|
||
return Math.hypot(dLat, dLon) > Math.max(1, height);
|
||
}
|
||
|
||
/** 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.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 the count: settle it here.
|
||
controller.abort();
|
||
state.coverage.pending.delete(key);
|
||
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
|
||
}
|
||
for (const tile of tiles) loadTile(tile, kind);
|
||
if (!state.coverage.pending.size) purgeStale();
|
||
// The camera moved: cells that were out of the sampler's range may not be.
|
||
if (terrainMode())
|
||
for (const entry of state.coverage.tiles.values())
|
||
if (entry.castRequested) requestMesh(entry);
|
||
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();
|
||
state.coverage.loading = 0;
|
||
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();
|
||
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;
|
||
detachPrimitive(entry);
|
||
attachPrimitive(entry);
|
||
}
|
||
requestRender();
|
||
notify();
|
||
}
|
||
|
||
/**
|
||
* Redraw loaded tiles draped or cast after the core's surface mode changes,
|
||
* then re-pick the zoom: the bare-earth height under the camera that the
|
||
* change brings in can move it (a high city reads much closer to the street).
|
||
*/
|
||
function setSurface() {
|
||
if (!state.context.isActive()) return;
|
||
rebuild();
|
||
refresh();
|
||
}
|
||
|
||
/** Look a sequence up across loaded tiles. */
|
||
function findSequence(id) {
|
||
for (const entry of state.coverage.tiles.values()) {
|
||
const hit = entry.sequences.get(id);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||
function recolorSequence(id, selected) {
|
||
const value = sequenceColor({ selected });
|
||
for (const entry of state.coverage.tiles.values()) {
|
||
const sequence = entry.sequences.get(id);
|
||
if (!sequence) continue;
|
||
for (const instanceId of partIds(sequence))
|
||
for (const { primitive } of entry.primitives || []) {
|
||
if (!primitive.ready) continue;
|
||
try {
|
||
const attributes =
|
||
primitive.getGeometryInstanceAttributes(instanceId);
|
||
if (attributes)
|
||
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
||
value,
|
||
attributes.color,
|
||
);
|
||
} catch {
|
||
/* instance not in this primitive */
|
||
}
|
||
}
|
||
}
|
||
requestRender();
|
||
}
|
||
|
||
function sequenceCount() {
|
||
let count = 0;
|
||
for (const entry of state.coverage.tiles.values()) count += entry.count;
|
||
return count;
|
||
}
|
||
|
||
return {
|
||
attach,
|
||
detach,
|
||
refresh,
|
||
clear,
|
||
resetErrors,
|
||
rebuild,
|
||
setSurface,
|
||
findSequence,
|
||
recolorSequence,
|
||
sequenceCount,
|
||
};
|
||
}
|