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:
German KhokhlovandClaude Opus 5.5 committed 2026-10-08 15:06:17 +04:00
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');
+2 -3
View File
@@ -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,