mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- i18n scanner: lastError sink, no false sinks on defaults and ternaries - shared src/i18n/marker.js for modules that only mark strings - wind field messages move to wind/fields.js; CITY_POIS imports from cityPois.js - drop review IDs, change history and stale references from comments and docs Co-Authored-By: Claude Opus 5.5 <[email protected]>
1077 lines
38 KiB
JavaScript
1077 lines
38 KiB
JavaScript
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 { msg } from '../../i18n/index.js';
|
|
import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js';
|
|
import { tileBounds, tilesForBbox } from './tileMath.js';
|
|
|
|
/** A tile's shared point cap must move by this much to redraw it. */
|
|
const SHARE_HYSTERESIS = 0.1;
|
|
/** 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.
|
|
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
|
|
* @property {number} [maxPoints] Line points drawn at once, over every tile (default: no limit).
|
|
*/
|
|
|
|
/**
|
|
* 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.
|
|
* @param {(point: object) => number} [options.overviewPointSize] An overview point's size in pixels (default `tuning.overviewPointPx`).
|
|
*/
|
|
export function createLineCoverage({
|
|
state,
|
|
fetchTile,
|
|
zoomFor,
|
|
colors,
|
|
pickPrefix,
|
|
renderKey,
|
|
tuning,
|
|
messages,
|
|
passes = passesImageryFilter,
|
|
overviewPointSize = null,
|
|
}) {
|
|
const { render } = state.services;
|
|
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
|
|
const perTilePointCap = tuning.maxPoints
|
|
? Math.floor(tuning.maxPoints / tuning.maxTiles)
|
|
: Infinity;
|
|
|
|
/** 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. `measure`
|
|
* only records what the lines need, for a draw that follows.
|
|
*/
|
|
function drawnSequences(entry, { measure = false } = {}) {
|
|
const drawn = [];
|
|
const current = filter();
|
|
const selectedId = state.sequence.selectedId ?? null;
|
|
const cap = entry.pointCap ?? perTilePointCap;
|
|
let points = 0;
|
|
let demand = 0;
|
|
for (const sequence of entry.sequenceList) {
|
|
if (!passes(sequence, current)) continue;
|
|
const size = pointsOf(sequence);
|
|
demand += size;
|
|
// The selected sequence is always drawn: its highlight is the answer
|
|
// to a click, whatever the caps leave out. It is outside the cap, so
|
|
// it is not counted against it.
|
|
if (sequence.id === selectedId) {
|
|
drawn.push(sequence);
|
|
continue;
|
|
}
|
|
if (drawn.length >= perTileSequenceCap) continue;
|
|
// A line past the point budget is skipped; a shorter one may still fit.
|
|
if (points + size > cap) continue;
|
|
points += size;
|
|
drawn.push(sequence);
|
|
}
|
|
// What every passing line would take: the budget is shared by it.
|
|
entry.pointDemand = demand;
|
|
if (!measure) {
|
|
entry.pointsDrawn = points;
|
|
// The cap this draw used: a share is judged against it, not against
|
|
// later shares that redrew nothing (small steps add up).
|
|
entry.drawnCap = cap;
|
|
}
|
|
return drawn;
|
|
}
|
|
|
|
/**
|
|
* Share the point budget across the tiles in view (water-filling): a
|
|
* sparse tile keeps what it needs and the rest goes to the dense ones,
|
|
* so a city centre is not drawn thinner than its suburbs. Sets each
|
|
* tile's cap; returns the tiles whose cap changes what they draw.
|
|
*/
|
|
function shareCaps() {
|
|
const changed = [];
|
|
if (!tuning.maxPoints) return changed;
|
|
const entries = [...state.coverage.tiles.values()].filter(
|
|
(entry) => entry.kind === 'sequence',
|
|
);
|
|
const byDemand = [...entries].sort(
|
|
(a, b) => (a.pointDemand || 0) - (b.pointDemand || 0),
|
|
);
|
|
let left = tuning.maxPoints;
|
|
byDemand.forEach((entry, i) => {
|
|
const share = left / (byDemand.length - i);
|
|
const cap = Math.floor(Math.min(entry.pointDemand || 0, share));
|
|
left -= cap;
|
|
const before = entry.drawnCap ?? perTilePointCap;
|
|
entry.pointCap = cap;
|
|
// Only a change that alters what is drawn, by enough to be worth a
|
|
// rebuild: more room for a tile that had lines left out, or well
|
|
// under what it draws now (a small overshoot stays until the next
|
|
// natural redraw).
|
|
const grows =
|
|
cap > before * (1 + SHARE_HYSTERESIS) &&
|
|
entry.pointsDrawn < entry.pointDemand;
|
|
const shrinks = cap < entry.pointsDrawn * (1 - SHARE_HYSTERESIS);
|
|
if (grows || shrinks) changed.push(entry);
|
|
});
|
|
return changed;
|
|
}
|
|
|
|
/**
|
|
* Once loading settles: redraw the tiles the shared budget changes,
|
|
* keeping their old lines until the new ones are ready (no blink).
|
|
*/
|
|
function shareBudget() {
|
|
const changed = shareCaps();
|
|
for (const entry of changed) redrawEntry(entry, { swap: true });
|
|
if (changed.length) {
|
|
requestRender();
|
|
notify();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw one tile again from its decoded lines (filter, caps or selection
|
|
* changed). With `swap`, its old lines stay until the new ones are ready.
|
|
*/
|
|
function redrawEntry(entry, { swap = false } = {}) {
|
|
cancelCast(entry);
|
|
entry.castRequested = false;
|
|
entry.meshParts = null;
|
|
if (swap && entry.kind === 'sequence' && entry.primitives?.length) {
|
|
redrawTile(entry);
|
|
return;
|
|
}
|
|
detachPrimitive(entry);
|
|
attachPrimitive(entry);
|
|
}
|
|
|
|
function pointsOf(sequence) {
|
|
let count = 0;
|
|
for (const part of sequence.parts) count += part.length;
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
let previous = entry.primitives;
|
|
if (entry.swap) {
|
|
// A swap still building: what is on screen is its old lines and
|
|
// whichever new ones are ready. Those stay; the rest never showed.
|
|
const shown = [...entry.swap.old];
|
|
const building = [];
|
|
for (const record of previous)
|
|
(record.primitive.ready && !record.primitive.isDestroyed?.()
|
|
? shown
|
|
: building
|
|
).push(record);
|
|
entry.swap.stop();
|
|
entry.swap = null;
|
|
removePrimitives(building);
|
|
previous = shown;
|
|
}
|
|
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: overviewPointSize?.(point) ?? 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, unless the provider ordered them itself; the
|
|
// per-tile cap applies to what passes the filter.
|
|
entry.sequenceList = tuning.keepOrder
|
|
? decoded.sequences
|
|
: 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 || msg('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();
|
|
shareBudget();
|
|
}
|
|
}
|
|
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
|
|
: msg('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();
|
|
shareBudget();
|
|
}
|
|
// 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();
|
|
// What each tile's lines need under the new filter, then the shares,
|
|
// then one redraw each.
|
|
for (const entry of state.coverage.tiles.values())
|
|
if (entry.kind === 'sequence') drawnSequences(entry, { measure: true });
|
|
shareCaps();
|
|
for (const entry of state.coverage.tiles.values()) redrawEntry(entry);
|
|
requestRender();
|
|
notify();
|
|
}
|
|
|
|
/**
|
|
* Load every tile again (a source that filters on its server had its
|
|
* filter changed); the lines drawn now show until the new ones are in.
|
|
*/
|
|
function reload() {
|
|
retire();
|
|
refresh();
|
|
}
|
|
|
|
/**
|
|
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
|
* zoom, since the ground height under the camera may have changed.
|
|
*/
|
|
function setSurface() {
|
|
if (!state.context.isActive()) return;
|
|
rebuild();
|
|
refresh();
|
|
}
|
|
|
|
/** Recolour every part of a sequence in one ready primitive. */
|
|
function recolorInstances(primitive, sequence, selected) {
|
|
const value = sequenceColor({ selected });
|
|
for (const instanceId of partIds(sequence, pickPrefix)) {
|
|
try {
|
|
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
|
|
if (attributes)
|
|
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
|
value,
|
|
attributes.color,
|
|
);
|
|
} catch {
|
|
/* instance not in this primitive */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Every tile on the globe: the current zoom's and the stale zoom's. */
|
|
function drawnEntries() {
|
|
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
|
}
|
|
|
|
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
|
|
function drawnRecords(entry) {
|
|
const records = entry.primitives || [];
|
|
return entry.swap ? [...records, ...entry.swap.old] : records;
|
|
}
|
|
|
|
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
|
function recolorSequence(id, selected) {
|
|
// Selected but left out by a cap: draw it (it is always admitted).
|
|
if (selected && (tuning.maxPoints || 0) > 0)
|
|
for (const entry of state.coverage.tiles.values())
|
|
if (
|
|
entry.kind === 'sequence' &&
|
|
!entry.sequences.has(id) &&
|
|
entry.sequenceList.some(
|
|
(sequence) => sequence.id === id && passes(sequence, filter()),
|
|
)
|
|
)
|
|
redrawEntry(entry, { swap: true });
|
|
for (const entry of drawnEntries()) {
|
|
const sequence = entry.sequences.get(id);
|
|
if (!sequence) continue;
|
|
for (const record of drawnRecords(entry)) {
|
|
// Still building: `syncSelection` catches it up once it is ready.
|
|
if (!record.primitive.ready) continue;
|
|
recolorInstances(record.primitive, sequence, selected);
|
|
if (selected) record.selectedId = id;
|
|
else if (record.selectedId === id) record.selectedId = null;
|
|
}
|
|
}
|
|
requestRender();
|
|
}
|
|
|
|
/**
|
|
* Bring a ready primitive's highlight up to the current selection, when
|
|
* it was built with another one (or none). True when it changed.
|
|
*/
|
|
function syncSelection(entry, record) {
|
|
const current = state.sequence.selectedId ?? null;
|
|
if (record.selectedId === current || !record.primitive.ready) return false;
|
|
const previous =
|
|
record.selectedId && entry.sequences.get(record.selectedId);
|
|
if (previous) recolorInstances(record.primitive, previous, false);
|
|
const next = current && entry.sequences.get(current);
|
|
if (next) recolorInstances(record.primitive, next, true);
|
|
record.selectedId = current;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* A primitive cannot be recoloured until it is ready, so while any is
|
|
* building, catch each one up with the selection as it becomes ready.
|
|
*/
|
|
function watchSelection() {
|
|
const scene = state.viewer?.scene;
|
|
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
|
|
const stop = scene.postRender.addEventListener(() => {
|
|
let building = false;
|
|
let changed = false;
|
|
for (const entry of drawnEntries()) {
|
|
for (const record of entry.primitives || []) {
|
|
if (!record.primitive.ready) building = true;
|
|
else if (syncSelection(entry, record)) changed = true;
|
|
}
|
|
// Lines a swap still shows were ready (or never show): catch up only.
|
|
for (const record of entry.swap?.old || [])
|
|
if (syncSelection(entry, record)) changed = true;
|
|
}
|
|
if (changed) requestRender();
|
|
if (!building) stopSelectionWatch();
|
|
});
|
|
state.coverage.stopSelectionWatch = stop;
|
|
}
|
|
|
|
function stopSelectionWatch() {
|
|
state.coverage.stopSelectionWatch?.();
|
|
state.coverage.stopSelectionWatch = null;
|
|
}
|
|
|
|
/**
|
|
* Sequences (or overview points) drawn. A sequence that crosses a tile
|
|
* edge is in both tiles' lists, so sequences count once by id.
|
|
*/
|
|
function sequenceCount() {
|
|
const ids = new Set();
|
|
let points = 0;
|
|
for (const entry of state.coverage.tiles.values())
|
|
if (entry.kind === 'sequence')
|
|
for (const id of entry.sequences.keys()) ids.add(id);
|
|
else points += entry.count;
|
|
return ids.size + points;
|
|
}
|
|
|
|
return {
|
|
attach,
|
|
detach,
|
|
refresh,
|
|
clear,
|
|
resetErrors,
|
|
rebuild,
|
|
reload,
|
|
setSurface,
|
|
recolorSequence,
|
|
sequenceCount,
|
|
};
|
|
}
|