mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor(street-level): draw the selected sequence as its own line; drop old-zoom tiles at once
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fd2a21c0c6
commit
200844998b
5 files changed
+120
-334
No files matched your search
@@ -17,8 +17,6 @@ import {
|
|||||||
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
/** 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. */
|
/** Sequences per primitive: smaller batches build, and show, sooner. */
|
||||||
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||||||
|
|
||||||
@@ -26,32 +24,48 @@ const PER_TILE_SEQUENCE_CAP = Math.floor(
|
|||||||
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
|
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Colour for a sequence: Mapillary green, GEV cyan while selected. */
|
/** Sequence id for a line's pick id (`mly:seq:<id>`), else null. */
|
||||||
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 any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
|
|
||||||
export function sequenceIdFromPick(pickId) {
|
export function sequenceIdFromPick(pickId) {
|
||||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
|
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
|
||||||
return null;
|
return null;
|
||||||
const rest = pickId.slice(PICK_PREFIX.sequence.length);
|
return pickId.slice(PICK_PREFIX.sequence.length);
|
||||||
const cut = rest.indexOf(PART_SEPARATOR);
|
}
|
||||||
return cut === -1 ? rest : rest.slice(0, cut);
|
|
||||||
|
/** Line instances for sequences' parts, every part picking its sequence. */
|
||||||
|
function lineInstances(sequences, color, width) {
|
||||||
|
const attributes = {
|
||||||
|
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
|
||||||
|
};
|
||||||
|
const instances = [];
|
||||||
|
for (const sequence of sequences)
|
||||||
|
for (const coordinates of sequence.parts) {
|
||||||
|
let positions;
|
||||||
|
try {
|
||||||
|
positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (positions.length < 2) continue;
|
||||||
|
instances.push(
|
||||||
|
new Cesium.GeometryInstance({
|
||||||
|
geometry: new Cesium.GroundPolylineGeometry({ positions, width }),
|
||||||
|
id: `${PICK_PREFIX.sequence}${sequence.id}`,
|
||||||
|
attributes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return instances;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draped lines that follow terrain and 3D tiles (Google 3D) alike. */
|
||||||
|
function linePrimitive(geometryInstances, asynchronous) {
|
||||||
|
return new Cesium.GroundPolylinePrimitive({
|
||||||
|
geometryInstances,
|
||||||
|
appearance: new Cesium.PolylineColorAppearance(),
|
||||||
|
classificationType: Cesium.ClassificationType.BOTH,
|
||||||
|
asynchronous,
|
||||||
|
allowPicking: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hint above the street-zoom ceiling, where no coverage is drawn. */
|
/** Hint above the street-zoom ceiling, where no coverage is drawn. */
|
||||||
@@ -99,54 +113,48 @@ export function createCoverage({ state, source }) {
|
|||||||
return drawn;
|
return drawn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Batched draped primitives for a tile's sequences. */
|
||||||
* Batched draped primitives for a tile's sequence parts. Each batch records
|
|
||||||
* the selection its colours were built with.
|
|
||||||
*/
|
|
||||||
function buildSequencePrimitives(sequences) {
|
function buildSequencePrimitives(sequences) {
|
||||||
const selectedId = state.sequence.selectedId ?? null;
|
const instances = lineInstances(
|
||||||
const instances = [];
|
sequences,
|
||||||
for (const sequence of sequences) {
|
Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
|
||||||
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
COVERAGE_LINE_WIDTH_PX,
|
||||||
sequenceColor({ selected: sequence.id === selectedId }),
|
);
|
||||||
);
|
|
||||||
const ids = partIds(sequence);
|
|
||||||
sequence.parts.forEach((coordinates, index) => {
|
|
||||||
let positions;
|
|
||||||
try {
|
|
||||||
positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (positions.length < 2) return;
|
|
||||||
instances.push(
|
|
||||||
new Cesium.GeometryInstance({
|
|
||||||
geometry: new Cesium.GroundPolylineGeometry({
|
|
||||||
positions,
|
|
||||||
width: COVERAGE_LINE_WIDTH_PX,
|
|
||||||
}),
|
|
||||||
id: ids[index],
|
|
||||||
attributes: { color },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const primitives = [];
|
const primitives = [];
|
||||||
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||||
primitives.push({
|
primitives.push(
|
||||||
selectedId,
|
linePrimitive(instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), true),
|
||||||
primitive: new Cesium.GroundPolylinePrimitive({
|
);
|
||||||
geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
|
||||||
appearance: new Cesium.PolylineColorAppearance(),
|
|
||||||
// Drapes on the globe and on 3D tiles (Google 3D) alike.
|
|
||||||
classificationType: Cesium.ClassificationType.BOTH,
|
|
||||||
asynchronous: true,
|
|
||||||
allowPicking: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return primitives;
|
return primitives;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the selected sequence again over the coverage, in the selection
|
||||||
|
* colour, from every loaded tile it crosses; none when nothing is selected.
|
||||||
|
* Built at once: its tiles loaded, so the terrain table has too.
|
||||||
|
*/
|
||||||
|
function highlight(sequenceId = state.sequence.selectedId) {
|
||||||
|
const scene = state.viewer?.scene;
|
||||||
|
if (state.coverage.highlight) {
|
||||||
|
scene?.groundPrimitives?.remove(state.coverage.highlight);
|
||||||
|
state.coverage.highlight = null;
|
||||||
|
}
|
||||||
|
if (!sequenceId || !scene) return;
|
||||||
|
const parts = [];
|
||||||
|
for (const entry of state.coverage.tiles.values())
|
||||||
|
parts.push(...(entry.sequences.get(sequenceId)?.parts || []));
|
||||||
|
const instances = lineInstances(
|
||||||
|
[{ id: sequenceId, parts }],
|
||||||
|
Cesium.Color.fromCssColorString(COLORS.selected),
|
||||||
|
COVERAGE_LINE_WIDTH_PX + 1,
|
||||||
|
);
|
||||||
|
if (!instances.length) return;
|
||||||
|
state.coverage.highlight = scene.groundPrimitives.add(
|
||||||
|
linePrimitive(instances, false),
|
||||||
|
);
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
function attachPrimitive(entry) {
|
function attachPrimitive(entry) {
|
||||||
const scene = state.viewer?.scene;
|
const scene = state.viewer?.scene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
@@ -155,14 +163,13 @@ export function createCoverage({ state, source }) {
|
|||||||
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||||||
entry.primitives = buildSequencePrimitives(sequences);
|
entry.primitives = buildSequencePrimitives(sequences);
|
||||||
entry.count = sequences.length;
|
entry.count = sequences.length;
|
||||||
for (const { primitive } of entry.primitives)
|
for (const primitive of entry.primitives)
|
||||||
scene.groundPrimitives.add(primitive);
|
scene.groundPrimitives.add(primitive);
|
||||||
watchSelection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detachPrimitive(entry) {
|
function detachPrimitive(entry) {
|
||||||
const scene = state.viewer?.scene;
|
const scene = state.viewer?.scene;
|
||||||
for (const { primitive } of entry.primitives || []) {
|
for (const primitive of entry.primitives || []) {
|
||||||
try {
|
try {
|
||||||
scene?.groundPrimitives?.remove(primitive);
|
scene?.groundPrimitives?.remove(primitive);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -195,7 +202,7 @@ export function createCoverage({ state, source }) {
|
|||||||
}),
|
}),
|
||||||
ensureTerrainReady(),
|
ensureTerrainReady(),
|
||||||
]);
|
]);
|
||||||
// Only an abort, a retire, a clear or a newer request for this tile
|
// Only an abort, a zoom change, a clear or a newer request for this tile
|
||||||
// discards the bytes; a refresh that still wants the tile keeps them.
|
// discards the bytes; a refresh that still wants the tile keeps them.
|
||||||
if (
|
if (
|
||||||
controller.signal.aborted ||
|
controller.signal.aborted ||
|
||||||
@@ -213,6 +220,8 @@ export function createCoverage({ state, source }) {
|
|||||||
attachPrimitive(entry);
|
attachPrimitive(entry);
|
||||||
state.coverage.tiles.set(key, entry);
|
state.coverage.tiles.set(key, entry);
|
||||||
state.coverage.lastError = null;
|
state.coverage.lastError = null;
|
||||||
|
// Over the new lines, and along any of the selection's parts they add.
|
||||||
|
if (entry.sequences.has(state.sequence.selectedId)) highlight();
|
||||||
requestRender();
|
requestRender();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -228,10 +237,8 @@ export function createCoverage({ state, source }) {
|
|||||||
} finally {
|
} finally {
|
||||||
// Only the request that still owns the key settles it: a superseded one
|
// 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).
|
// must not drop a newer request's entry (which is what counts as loading).
|
||||||
if (state.coverage.pending.get(key) === controller) {
|
if (state.coverage.pending.get(key) === controller)
|
||||||
state.coverage.pending.delete(key);
|
state.coverage.pending.delete(key);
|
||||||
if (!state.coverage.pending.size) purgeStale();
|
|
||||||
}
|
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,31 +282,6 @@ export function createCoverage({ state, source }) {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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) {
|
|
||||||
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() {
|
function notify() {
|
||||||
state.notify?.();
|
state.notify?.();
|
||||||
}
|
}
|
||||||
@@ -348,10 +330,7 @@ export function createCoverage({ state, source }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.coverage.hint = '';
|
state.coverage.hint = '';
|
||||||
if (zoom !== state.coverage.zoom) {
|
state.coverage.zoom = zoom;
|
||||||
retire();
|
|
||||||
state.coverage.zoom = zoom;
|
|
||||||
}
|
|
||||||
const wanted = new Set(tiles.map(tileKey));
|
const wanted = new Set(tiles.map(tileKey));
|
||||||
for (const key of [...state.coverage.tiles.keys()])
|
for (const key of [...state.coverage.tiles.keys()])
|
||||||
if (!wanted.has(key)) removeTile(key);
|
if (!wanted.has(key)) removeTile(key);
|
||||||
@@ -363,7 +342,6 @@ export function createCoverage({ state, source }) {
|
|||||||
state.coverage.pending.delete(key);
|
state.coverage.pending.delete(key);
|
||||||
}
|
}
|
||||||
for (const tile of tiles) loadTile(tile);
|
for (const tile of tiles) loadTile(tile);
|
||||||
if (!state.coverage.pending.size) purgeStale();
|
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,104 +376,22 @@ export function createCoverage({ state, source }) {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
state.coverage.pending.clear();
|
state.coverage.pending.clear();
|
||||||
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
||||||
purgeStale();
|
highlight(null);
|
||||||
state.coverage.zoom = null;
|
state.coverage.zoom = null;
|
||||||
stopSelectionWatch();
|
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
||||||
function rebuild() {
|
function rebuild() {
|
||||||
purgeStale();
|
|
||||||
for (const entry of state.coverage.tiles.values()) {
|
for (const entry of state.coverage.tiles.values()) {
|
||||||
detachPrimitive(entry);
|
detachPrimitive(entry);
|
||||||
attachPrimitive(entry);
|
attachPrimitive(entry);
|
||||||
}
|
}
|
||||||
|
highlight();
|
||||||
requestRender();
|
requestRender();
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 entry.primitives || []) {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) requestRender();
|
|
||||||
if (!building) stopSelectionWatch();
|
|
||||||
});
|
|
||||||
state.coverage.stopSelectionWatch = stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSelectionWatch() {
|
|
||||||
state.coverage.stopSelectionWatch?.();
|
|
||||||
state.coverage.stopSelectionWatch = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sequences drawn. A sequence that crosses a tile edge is in both tiles'
|
* Sequences drawn. A sequence that crosses a tile edge is in both tiles'
|
||||||
* lists, so sequences count once by id.
|
* lists, so sequences count once by id.
|
||||||
@@ -533,7 +429,7 @@ export function createCoverage({ state, source }) {
|
|||||||
unblock,
|
unblock,
|
||||||
setKeyStatus,
|
setKeyStatus,
|
||||||
rebuild,
|
rebuild,
|
||||||
recolorSequence,
|
highlight,
|
||||||
sequenceCount,
|
sequenceCount,
|
||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { freshCoverage } from '../../state.js';
|
|||||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
|
||||||
COVERAGE_MAX_SEQUENCES,
|
COVERAGE_MAX_SEQUENCES,
|
||||||
COVERAGE_MAX_TILES,
|
COVERAGE_MAX_TILES,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
@@ -48,29 +47,13 @@ function tileCentre(lon, lat) {
|
|||||||
|
|
||||||
/** A viewer looking straight down on a 0.004° square around `view`. */
|
/** A viewer looking straight down on a 0.004° square around `view`. */
|
||||||
function fakeViewer(view) {
|
function fakeViewer(view) {
|
||||||
const postRender = new Set();
|
|
||||||
const preRender = new Set();
|
|
||||||
return {
|
return {
|
||||||
view,
|
view,
|
||||||
postRender,
|
|
||||||
preRender,
|
|
||||||
scene: {
|
scene: {
|
||||||
canvas: { clientWidth: 100, clientHeight: 100 },
|
canvas: { clientWidth: 100, clientHeight: 100 },
|
||||||
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||||
groundPrimitives: collection(),
|
groundPrimitives: collection(),
|
||||||
primitives: collection(),
|
primitives: collection(),
|
||||||
postRender: {
|
|
||||||
addEventListener(listener) {
|
|
||||||
postRender.add(listener);
|
|
||||||
return () => postRender.delete(listener);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preRender: {
|
|
||||||
addEventListener(listener) {
|
|
||||||
preRender.add(listener);
|
|
||||||
return () => preRender.delete(listener);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
get positionCartographic() {
|
get positionCartographic() {
|
||||||
@@ -144,11 +127,6 @@ function setup() {
|
|||||||
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
|
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run one frame's listeners, as Cesium's render loop would. */
|
|
||||||
function frame(listeners) {
|
|
||||||
for (const listener of [...listeners]) listener();
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a superseded tile request cannot strand lines on the globe', async () => {
|
test('a superseded tile request cannot strand lines on the globe', async () => {
|
||||||
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
||||||
const forTile = () => source.calls.filter((call) => call.key === tileKey);
|
const forTile = () => source.calls.filter((call) => call.key === tileKey);
|
||||||
@@ -345,7 +323,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
|
|||||||
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
|
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
|
||||||
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
|
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
|
||||||
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
|
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
|
||||||
// Picking and recolouring look sequences up among the drawn lines.
|
// Picking and the highlight look sequences up among the drawn lines.
|
||||||
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
|
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
|
||||||
assert.equal(entry.sequences.has('flat-699'), false);
|
assert.equal(entry.sequences.has('flat-699'), false);
|
||||||
state.filter = { pano: 'all', sinceDays: 0 };
|
state.filter = { pano: 'all', sinceDays: 0 };
|
||||||
@@ -356,56 +334,6 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
|
|||||||
coverage.clear();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The colour attribute value of a line, selected or not. */
|
|
||||||
function colourValue(selected) {
|
|
||||||
return Array.from(
|
|
||||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
|
||||||
selected
|
|
||||||
? Cesium.Color.fromCssColorString(COLORS.selected)
|
|
||||||
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Take over a drawn primitive's readiness and seq-1's colour attribute. */
|
|
||||||
function controlled(record, ready) {
|
|
||||||
const control = { ready, attributes: { color: undefined } };
|
|
||||||
Object.defineProperty(record.primitive, 'ready', {
|
|
||||||
get: () => control.ready,
|
|
||||||
});
|
|
||||||
record.primitive.getGeometryInstanceAttributes = (id) =>
|
|
||||||
id === 'mly:seq:seq-1' ? control.attributes : undefined;
|
|
||||||
return control;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a selection made or cleared while a tile builds is applied once it is ready', async () => {
|
|
||||||
const { viewer, source, state, coverage, bytes } = setup();
|
|
||||||
coverage.refresh();
|
|
||||||
source.calls[0].resolve(bytes);
|
|
||||||
await settle();
|
|
||||||
const [entry] = state.coverage.tiles.values();
|
|
||||||
// Selected while its line is still building: nothing to recolour yet...
|
|
||||||
const first = controlled(entry.primitives[0], false);
|
|
||||||
state.sequence.selectedId = 'seq-1';
|
|
||||||
coverage.recolorSequence('seq-1', true);
|
|
||||||
assert.equal(first.attributes.color, undefined);
|
|
||||||
// ...so the highlight lands once it is ready.
|
|
||||||
first.ready = true;
|
|
||||||
frame(viewer.postRender);
|
|
||||||
assert.deepEqual(Array.from(first.attributes.color), colourValue(true));
|
|
||||||
|
|
||||||
// Rebuilt with the highlight baked in, then cleared mid-build: not stuck.
|
|
||||||
coverage.rebuild();
|
|
||||||
const second = controlled(entry.primitives[0], false);
|
|
||||||
coverage.recolorSequence('seq-1', false);
|
|
||||||
state.sequence.selectedId = null;
|
|
||||||
second.ready = true;
|
|
||||||
frame(viewer.postRender);
|
|
||||||
assert.deepEqual(Array.from(second.attributes.color), colourValue(false));
|
|
||||||
coverage.clear();
|
|
||||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a sequence that crosses a tile edge is counted once', async () => {
|
test('a sequence that crosses a tile edge is counted once', async () => {
|
||||||
const { viewer, source, coverage, centre } = setup();
|
const { viewer, source, coverage, centre } = setup();
|
||||||
// Look straight down on the edge between this tile and its east neighbour.
|
// Look straight down on the edge between this tile and its east neighbour.
|
||||||
@@ -517,56 +445,39 @@ test('a street view toward the horizon asks only for the ground within range', (
|
|||||||
coverage.clear();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
|
test('the selected sequence is drawn over the coverage, and goes with the selection', async () => {
|
||||||
const { viewer, source, state, coverage, bytes } = setup();
|
const { viewer, source, state, coverage, bytes } = setup();
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
source.calls[0].resolve(bytes);
|
source.calls[0].resolve(bytes);
|
||||||
await settle();
|
await settle();
|
||||||
const [entry] = state.coverage.tiles.values();
|
const lines = () => [...viewer.scene.groundPrimitives.items];
|
||||||
const line = controlled(entry.primitives[0], true);
|
assert.equal(lines().length, 1, 'the coverage');
|
||||||
state.sequence.selectedId = 'seq-1';
|
coverage.highlight('seq-1');
|
||||||
coverage.recolorSequence('seq-1', true);
|
assert.equal(lines().length, 2);
|
||||||
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
|
const highlight = lines().at(-1);
|
||||||
// Zoom out: the street tile stays on screen while the new zoom loads...
|
assert.equal(state.coverage.highlight, highlight, 'drawn last, on top');
|
||||||
viewer.view.height = 3000;
|
assert.equal(highlight.classificationType, Cesium.ClassificationType.BOTH);
|
||||||
coverage.refresh();
|
coverage.highlight('not-drawn');
|
||||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
|
assert.equal(lines().length, 1, 'nothing to highlight off the map');
|
||||||
// ...and the selection is cleared meanwhile.
|
coverage.highlight(null);
|
||||||
state.sequence.selectedId = null;
|
assert.equal(state.coverage.highlight, null);
|
||||||
coverage.recolorSequence('seq-1', false);
|
|
||||||
assert.deepEqual(
|
|
||||||
Array.from(line.attributes.color),
|
|
||||||
colourValue(false),
|
|
||||||
'the retained line is not left cyan',
|
|
||||||
);
|
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
|
assert.equal(lines().length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a selection made while the old zoom is still building is applied once it is ready', async () => {
|
test('a tile that lands with the selected sequence draws the highlight again, on top', async () => {
|
||||||
const { viewer, source, state, coverage, bytes } = setup();
|
const { viewer, source, state, coverage, bytes } = setup();
|
||||||
|
state.sequence.selectedId = 'seq-1';
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
|
assert.equal(state.coverage.highlight, null, 'nothing loaded yet');
|
||||||
source.calls[0].resolve(bytes);
|
source.calls[0].resolve(bytes);
|
||||||
await settle();
|
await settle();
|
||||||
const [entry] = state.coverage.tiles.values();
|
assert.ok(state.coverage.highlight, 'highlighted once its tile is in');
|
||||||
const line = controlled(entry.primitives[0], false);
|
assert.equal([...viewer.scene.groundPrimitives.items].at(-1), state.coverage.highlight);
|
||||||
viewer.view.height = 3000;
|
|
||||||
coverage.refresh();
|
|
||||||
assert.equal(state.coverage.stale.size, 1);
|
|
||||||
frame(viewer.postRender);
|
|
||||||
state.sequence.selectedId = 'seq-1';
|
|
||||||
coverage.recolorSequence('seq-1', true);
|
|
||||||
line.ready = true;
|
|
||||||
frame(viewer.postRender);
|
|
||||||
assert.deepEqual(
|
|
||||||
line.attributes.color && Array.from(line.attributes.color),
|
|
||||||
colourValue(true),
|
|
||||||
'the highlight lands on the retained line',
|
|
||||||
);
|
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
|
test('a zoom change drops the old zoom\'s lines', async () => {
|
||||||
const { viewer, source, state, coverage, bytes } = setup();
|
const { viewer, source, state, coverage, bytes } = setup();
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
source.calls[0].resolve(bytes);
|
source.calls[0].resolve(bytes);
|
||||||
@@ -576,23 +487,8 @@ test('the old zoom goes as soon as the new zoom has loaded, not after the stale
|
|||||||
viewer.view.height = 3000; // zoom out to z13
|
viewer.view.height = 3000; // zoom out to z13
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
assert.equal(state.coverage.zoom, 13);
|
assert.equal(state.coverage.zoom, 13);
|
||||||
const fresh = source.calls.slice(1);
|
|
||||||
assert.ok(fresh.length > 0, 'the new zoom is loading');
|
|
||||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile');
|
|
||||||
assert.ok(viewer.scene.groundPrimitives.items.has(old));
|
|
||||||
for (const call of fresh) {
|
|
||||||
assert.ok(
|
|
||||||
viewer.scene.groundPrimitives.items.has(old),
|
|
||||||
'kept while a new tile is still loading',
|
|
||||||
);
|
|
||||||
call.resolve(new Uint8Array());
|
|
||||||
await settle();
|
|
||||||
}
|
|
||||||
// The last new tile is in: the old lines go now, long before the wait.
|
|
||||||
assert.equal(state.coverage.pending.size, 0);
|
|
||||||
assert.equal(state.coverage.stale.size, 0);
|
|
||||||
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
|
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
|
||||||
assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled');
|
assert.ok(state.coverage.pending.size > 0, 'the new zoom is loading');
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -132,11 +132,9 @@ export function createSequences({ state, source, parts }) {
|
|||||||
(state.sequence.images.length || state.sequence.abort)
|
(state.sequence.images.length || state.sequence.abort)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
|
||||||
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
|
||||||
state.sequence.abort?.abort();
|
state.sequence.abort?.abort();
|
||||||
state.sequence.selectedId = sequenceId;
|
state.sequence.selectedId = sequenceId;
|
||||||
parts.coverage.recolorSequence(sequenceId, true);
|
parts.coverage.highlight(sequenceId);
|
||||||
const cached = state.sequence.cache.get(sequenceId);
|
const cached = state.sequence.cache.get(sequenceId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
state.sequence.abort = null;
|
state.sequence.abort = null;
|
||||||
@@ -173,7 +171,7 @@ export function createSequences({ state, source, parts }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
// Nothing to show: drop the highlight, so a click asks again.
|
// Nothing to show: drop the highlight, so a click asks again.
|
||||||
parts.coverage.recolorSequence(sequenceId, false);
|
parts.coverage.highlight(null);
|
||||||
state.sequence.selectedId = null;
|
state.sequence.selectedId = null;
|
||||||
state.sequence.error = error?.message || 'Sequence images unavailable';
|
state.sequence.error = error?.message || 'Sequence images unavailable';
|
||||||
}
|
}
|
||||||
@@ -189,8 +187,7 @@ export function createSequences({ state, source, parts }) {
|
|||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
state.sequence.abort?.abort();
|
state.sequence.abort?.abort();
|
||||||
state.sequence.abort = null;
|
state.sequence.abort = null;
|
||||||
if (state.sequence.selectedId)
|
if (state.sequence.selectedId) parts.coverage.highlight(null);
|
||||||
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
|
||||||
state.sequence.selectedId = null;
|
state.sequence.selectedId = null;
|
||||||
state.sequence.loading = false;
|
state.sequence.loading = false;
|
||||||
clearCones();
|
clearCones();
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ function records(sequenceId, count = 2) {
|
|||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const source = deferredSource();
|
const source = deferredSource();
|
||||||
const colours = [];
|
/** Every highlight asked of the coverage: a sequence id, or null for none. */
|
||||||
|
const highlights = [];
|
||||||
const state = {
|
const state = {
|
||||||
services: {},
|
services: {},
|
||||||
viewer: {},
|
viewer: {},
|
||||||
@@ -75,17 +76,17 @@ function setup() {
|
|||||||
};
|
};
|
||||||
const parts = {
|
const parts = {
|
||||||
coverage: {
|
coverage: {
|
||||||
recolorSequence: (id, selected) => colours.push([id, selected]),
|
highlight: (id) => highlights.push(id),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sequences = createSequences({ state, source, parts });
|
const sequences = createSequences({ state, source, parts });
|
||||||
const drawn = () =>
|
const drawn = () =>
|
||||||
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
|
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
|
||||||
return { state, source, sequences, colours, drawn };
|
return { state, source, sequences, highlights, drawn };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a failed sequence load leaves no stale cones and can be retried', async () => {
|
test('a failed sequence load leaves no stale cones and can be retried', async () => {
|
||||||
const { state, source, sequences, colours, drawn } = setup();
|
const { state, source, sequences, highlights, drawn } = setup();
|
||||||
sequences.select('A');
|
sequences.select('A');
|
||||||
source.last('A').resolve(records('A'));
|
source.last('A').resolve(records('A'));
|
||||||
await settle();
|
await settle();
|
||||||
@@ -98,7 +99,7 @@ test('a failed sequence load leaves no stale cones and can be retried', async ()
|
|||||||
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
|
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
|
||||||
assert.equal(state.sequence.loading, false);
|
assert.equal(state.sequence.loading, false);
|
||||||
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
|
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
|
||||||
assert.deepEqual(colours.at(-1), ['B', false]);
|
assert.equal(highlights.at(-1), null);
|
||||||
assert.equal(state.sequence.error, 'Sequence images unavailable');
|
assert.equal(state.sequence.error, 'Sequence images unavailable');
|
||||||
|
|
||||||
// Clicking B again asks again.
|
// Clicking B again asks again.
|
||||||
@@ -149,15 +150,12 @@ test('a cached sequence is re-selected without a lookup', async () => {
|
|||||||
assert.equal(state.sequence.selectedId, 'A');
|
assert.equal(state.sequence.selectedId, 'A');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
|
test('clearSelection cancels the lookup and drops the highlight', async () => {
|
||||||
const { state, source, sequences, colours, drawn } = setup();
|
const { state, source, sequences, highlights, drawn } = setup();
|
||||||
sequences.select('A');
|
sequences.select('A');
|
||||||
sequences.clearSelection();
|
sequences.clearSelection();
|
||||||
assert.equal(source.last('A').signal.aborted, true);
|
assert.equal(source.last('A').signal.aborted, true);
|
||||||
assert.deepEqual(colours, [
|
assert.deepEqual(highlights, ['A', null]);
|
||||||
['A', true],
|
|
||||||
['A', false],
|
|
||||||
]);
|
|
||||||
assert.equal(state.sequence.selectedId, null);
|
assert.equal(state.sequence.selectedId, null);
|
||||||
assert.equal(state.sequence.loading, false);
|
assert.equal(state.sequence.loading, false);
|
||||||
assert.equal(state.sequence.error, null, 'an error about it goes too');
|
assert.equal(state.sequence.error, null, 'an error about it goes too');
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ export function freshCoverage() {
|
|||||||
zoom: null,
|
zoom: null,
|
||||||
/** Current zoom's tiles by `z/x/y` key. */
|
/** Current zoom's tiles by `z/x/y` key. */
|
||||||
tiles: new Map(),
|
tiles: new Map(),
|
||||||
/** Previous zoom's tiles, kept on screen until replacements land. */
|
/** The selected sequence drawn over the coverage, or null. */
|
||||||
stale: new Map(),
|
highlight: null,
|
||||||
staleTimer: null,
|
|
||||||
/** Tile key → its request's controller; any entry means LOADING. */
|
/** Tile key → its request's controller; any entry means LOADING. */
|
||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
|||||||
Reference in new issue
Block a user