diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index e1fea56df..6ada951d9 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -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:[~]`), else null. */ +/** Sequence id for a line's pick id (`mly:seq:`), 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, }; diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 370b4fed7..250f5e99f 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -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(); }); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index d71f24ee1..a8166efb8 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -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(); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs index 5e2b8e0c7..3fd0f6f19 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs @@ -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'); diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index fad616c1e..a62d8fecc 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -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,