mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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,
|
||||
} 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. */
|
||||
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||||
|
||||
@@ -26,32 +24,48 @@ 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 any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
|
||||
/** Sequence id for a line's pick id (`mly:seq:<id>`), else null. */
|
||||
export function sequenceIdFromPick(pickId) {
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
|
||||
return null;
|
||||
const rest = pickId.slice(PICK_PREFIX.sequence.length);
|
||||
const cut = rest.indexOf(PART_SEPARATOR);
|
||||
return cut === -1 ? rest : rest.slice(0, cut);
|
||||
return pickId.slice(PICK_PREFIX.sequence.length);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -99,54 +113,48 @@ export function createCoverage({ state, source }) {
|
||||
return drawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched draped primitives for a tile's sequence parts. Each batch records
|
||||
* the selection its colours were built with.
|
||||
*/
|
||||
/** Batched draped primitives for a tile's sequences. */
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const selectedId = state.sequence.selectedId ?? null;
|
||||
const instances = [];
|
||||
for (const sequence of sequences) {
|
||||
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
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 instances = lineInstances(
|
||||
sequences,
|
||||
Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
|
||||
COVERAGE_LINE_WIDTH_PX,
|
||||
);
|
||||
const primitives = [];
|
||||
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push({
|
||||
selectedId,
|
||||
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,
|
||||
}),
|
||||
});
|
||||
primitives.push(
|
||||
linePrimitive(instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), true),
|
||||
);
|
||||
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) {
|
||||
const scene = state.viewer?.scene;
|
||||
if (!scene) return;
|
||||
@@ -155,14 +163,13 @@ export function createCoverage({ state, source }) {
|
||||
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||||
entry.primitives = buildSequencePrimitives(sequences);
|
||||
entry.count = sequences.length;
|
||||
for (const { primitive } of entry.primitives)
|
||||
for (const primitive of entry.primitives)
|
||||
scene.groundPrimitives.add(primitive);
|
||||
watchSelection();
|
||||
}
|
||||
|
||||
function detachPrimitive(entry) {
|
||||
const scene = state.viewer?.scene;
|
||||
for (const { primitive } of entry.primitives || []) {
|
||||
for (const primitive of entry.primitives || []) {
|
||||
try {
|
||||
scene?.groundPrimitives?.remove(primitive);
|
||||
} catch {
|
||||
@@ -195,7 +202,7 @@ export function createCoverage({ state, source }) {
|
||||
}),
|
||||
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.
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
@@ -213,6 +220,8 @@ export function createCoverage({ state, source }) {
|
||||
attachPrimitive(entry);
|
||||
state.coverage.tiles.set(key, entry);
|
||||
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();
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -228,10 +237,8 @@ export function createCoverage({ state, source }) {
|
||||
} 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) {
|
||||
if (state.coverage.pending.get(key) === controller)
|
||||
state.coverage.pending.delete(key);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
}
|
||||
notify();
|
||||
}
|
||||
}
|
||||
@@ -275,31 +282,6 @@ export function createCoverage({ state, source }) {
|
||||
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() {
|
||||
state.notify?.();
|
||||
}
|
||||
@@ -348,10 +330,7 @@ export function createCoverage({ state, source }) {
|
||||
return;
|
||||
}
|
||||
state.coverage.hint = '';
|
||||
if (zoom !== state.coverage.zoom) {
|
||||
retire();
|
||||
state.coverage.zoom = zoom;
|
||||
}
|
||||
state.coverage.zoom = zoom;
|
||||
const wanted = new Set(tiles.map(tileKey));
|
||||
for (const key of [...state.coverage.tiles.keys()])
|
||||
if (!wanted.has(key)) removeTile(key);
|
||||
@@ -363,7 +342,6 @@ export function createCoverage({ state, source }) {
|
||||
state.coverage.pending.delete(key);
|
||||
}
|
||||
for (const tile of tiles) loadTile(tile);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -398,104 +376,22 @@ export function createCoverage({ state, source }) {
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
||||
purgeStale();
|
||||
highlight(null);
|
||||
state.coverage.zoom = null;
|
||||
stopSelectionWatch();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
||||
function rebuild() {
|
||||
purgeStale();
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
detachPrimitive(entry);
|
||||
attachPrimitive(entry);
|
||||
}
|
||||
highlight();
|
||||
requestRender();
|
||||
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'
|
||||
* lists, so sequences count once by id.
|
||||
@@ -533,7 +429,7 @@ export function createCoverage({ state, source }) {
|
||||
unblock,
|
||||
setKeyStatus,
|
||||
rebuild,
|
||||
recolorSequence,
|
||||
highlight,
|
||||
sequenceCount,
|
||||
stats,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { freshCoverage } from '../../state.js';
|
||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_MAX_SEQUENCES,
|
||||
COVERAGE_MAX_TILES,
|
||||
} from './policy.js';
|
||||
@@ -48,29 +47,13 @@ function tileCentre(lon, lat) {
|
||||
|
||||
/** A viewer looking straight down on a 0.004° square around `view`. */
|
||||
function fakeViewer(view) {
|
||||
const postRender = new Set();
|
||||
const preRender = new Set();
|
||||
return {
|
||||
view,
|
||||
postRender,
|
||||
preRender,
|
||||
scene: {
|
||||
canvas: { clientWidth: 100, clientHeight: 100 },
|
||||
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
groundPrimitives: collection(),
|
||||
primitives: collection(),
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
postRender.add(listener);
|
||||
return () => postRender.delete(listener);
|
||||
},
|
||||
},
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
get positionCartographic() {
|
||||
@@ -144,11 +127,6 @@ function setup() {
|
||||
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 () => {
|
||||
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
||||
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);
|
||||
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
|
||||
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.equal(entry.sequences.has('flat-699'), false);
|
||||
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();
|
||||
});
|
||||
|
||||
/** 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 () => {
|
||||
const { viewer, source, coverage, centre } = setup();
|
||||
// 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();
|
||||
});
|
||||
|
||||
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();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const line = controlled(entry.primitives[0], true);
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.recolorSequence('seq-1', true);
|
||||
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
|
||||
// Zoom out: the street tile stays on screen while the new zoom loads...
|
||||
viewer.view.height = 3000;
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
|
||||
// ...and the selection is cleared meanwhile.
|
||||
state.sequence.selectedId = null;
|
||||
coverage.recolorSequence('seq-1', false);
|
||||
assert.deepEqual(
|
||||
Array.from(line.attributes.color),
|
||||
colourValue(false),
|
||||
'the retained line is not left cyan',
|
||||
);
|
||||
const lines = () => [...viewer.scene.groundPrimitives.items];
|
||||
assert.equal(lines().length, 1, 'the coverage');
|
||||
coverage.highlight('seq-1');
|
||||
assert.equal(lines().length, 2);
|
||||
const highlight = lines().at(-1);
|
||||
assert.equal(state.coverage.highlight, highlight, 'drawn last, on top');
|
||||
assert.equal(highlight.classificationType, Cesium.ClassificationType.BOTH);
|
||||
coverage.highlight('not-drawn');
|
||||
assert.equal(lines().length, 1, 'nothing to highlight off the map');
|
||||
coverage.highlight(null);
|
||||
assert.equal(state.coverage.highlight, null);
|
||||
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();
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.highlight, null, 'nothing loaded yet');
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const line = controlled(entry.primitives[0], false);
|
||||
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',
|
||||
);
|
||||
assert.ok(state.coverage.highlight, 'highlighted once its tile is in');
|
||||
assert.equal([...viewer.scene.groundPrimitives.items].at(-1), state.coverage.highlight);
|
||||
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();
|
||||
coverage.refresh();
|
||||
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
|
||||
coverage.refresh();
|
||||
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(state.coverage.staleTimer, null, 'the stale wait is cancelled');
|
||||
assert.ok(state.coverage.pending.size > 0, 'the new zoom is loading');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
|
||||
@@ -132,11 +132,9 @@ export function createSequences({ state, source, parts }) {
|
||||
(state.sequence.images.length || state.sequence.abort)
|
||||
)
|
||||
return;
|
||||
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
||||
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.selectedId = sequenceId;
|
||||
parts.coverage.recolorSequence(sequenceId, true);
|
||||
parts.coverage.highlight(sequenceId);
|
||||
const cached = state.sequence.cache.get(sequenceId);
|
||||
if (cached) {
|
||||
state.sequence.abort = null;
|
||||
@@ -173,7 +171,7 @@ export function createSequences({ state, source, parts }) {
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
// 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.error = error?.message || 'Sequence images unavailable';
|
||||
}
|
||||
@@ -189,8 +187,7 @@ export function createSequences({ state, source, parts }) {
|
||||
function clearSelection() {
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.abort = null;
|
||||
if (state.sequence.selectedId)
|
||||
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
||||
if (state.sequence.selectedId) parts.coverage.highlight(null);
|
||||
state.sequence.selectedId = null;
|
||||
state.sequence.loading = false;
|
||||
clearCones();
|
||||
|
||||
@@ -57,7 +57,8 @@ function records(sequenceId, count = 2) {
|
||||
|
||||
function setup() {
|
||||
const source = deferredSource();
|
||||
const colours = [];
|
||||
/** Every highlight asked of the coverage: a sequence id, or null for none. */
|
||||
const highlights = [];
|
||||
const state = {
|
||||
services: {},
|
||||
viewer: {},
|
||||
@@ -75,17 +76,17 @@ function setup() {
|
||||
};
|
||||
const parts = {
|
||||
coverage: {
|
||||
recolorSequence: (id, selected) => colours.push([id, selected]),
|
||||
highlight: (id) => highlights.push(id),
|
||||
},
|
||||
};
|
||||
const sequences = createSequences({ state, source, parts });
|
||||
const drawn = () =>
|
||||
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 () => {
|
||||
const { state, source, sequences, colours, drawn } = setup();
|
||||
const { state, source, sequences, highlights, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
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.equal(state.sequence.loading, false);
|
||||
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');
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
|
||||
const { state, source, sequences, colours, drawn } = setup();
|
||||
test('clearSelection cancels the lookup and drops the highlight', async () => {
|
||||
const { state, source, sequences, highlights, drawn } = setup();
|
||||
sequences.select('A');
|
||||
sequences.clearSelection();
|
||||
assert.equal(source.last('A').signal.aborted, true);
|
||||
assert.deepEqual(colours, [
|
||||
['A', true],
|
||||
['A', false],
|
||||
]);
|
||||
assert.deepEqual(highlights, ['A', null]);
|
||||
assert.equal(state.sequence.selectedId, null);
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(state.sequence.error, null, 'an error about it goes too');
|
||||
|
||||
@@ -27,9 +27,8 @@ export function freshCoverage() {
|
||||
zoom: null,
|
||||
/** Current zoom's tiles by `z/x/y` key. */
|
||||
tiles: new Map(),
|
||||
/** Previous zoom's tiles, kept on screen until replacements land. */
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
/** The selected sequence drawn over the coverage, or null. */
|
||||
highlight: null,
|
||||
/** Tile key → its request's controller; any entry means LOADING. */
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
|
||||
Reference in new issue
Block a user