From 3ae367d0dd8dba30cb71935ab33404d205dda5a6 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:38:53 +0100 Subject: [PATCH] =?UTF-8?q?fix(street-level):=20Panoramax=20IC8=20round=20?= =?UTF-8?q?2=20=E2=80=94=20shared=20point=20budget,=20selected=20line,=20c?= =?UTF-8?q?one=20cache,=20DP=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the round-2 verification (approve with follow-ups), all follow-ups: - The point budget is shared across the tiles in view once loading settles (water-filling): a sparse tile keeps what it has, a dense city centre takes the rest instead of drawing thinner than its suburbs. The selected sequence is always drawn, and selecting one a cap had left out draws it. - The cones' picture-list cache keeps only finished lists: a re-centre while the cones loaded inherited the aborted load's tiles and drew none in them. - Douglas–Peucker measures to the segment, so dead ends and U-turns keep their far end. - Switching Panoramax off lets its decoded tiles and lists go. - Picture downloads are counted while they stream (a host sending no length cannot fill memory before the cap). - Comments on the via caption and the simplification tolerance match the code. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 11 ++- src/layers/streetLevel/lineCoverage.js | 99 ++++++++++++++++--- src/layers/streetLevel/lineCoverage.test.mjs | 93 ++++++++++++++++- src/layers/streetLevel/photoViewer.js | 32 +++++- src/layers/streetLevel/photoViewer.test.mjs | 14 +++ .../streetLevel/providers/panoramax/decode.js | 18 ++-- .../providers/panoramax/decode.test.mjs | 7 ++ .../streetLevel/providers/panoramax/index.js | 12 ++- .../streetLevel/providers/panoramax/policy.js | 8 +- .../providers/panoramax/provider.test.mjs | 80 ++++++++++++++- src/layers/streetLevel/tileCache.js | 2 + 11 files changed, 343 insertions(+), 33 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9fc1f5fb..15a80c743 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3256,7 +3256,16 @@ IGN, MapComplete…; IGN's redirect to its OVH object storage). Its chip vector tiles (`/api/map/{z}/{x}/{y}.mvt`, an hour's HTTP cache): sequence lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km, z11 to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is -~2.1 MB and 8,600 sequences; each tile draws its 1,600 longest), and above +~2.1 MB and 8,600 sequences, 750,000 points). `decode.js` simplifies each +line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a +pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in +view draw their longest lines within 90,000 points (`maxPoints`, +`keepOrder`), shared out once loading settles so a sparse tile keeps all +it has and a dense one (a city centre) takes the rest; the selected +sequence is always drawn. Every point becomes ~8 ground-line vertices: +unsimplified, the lines hung the tab. Decoded tiles are kept while the +provider is on (16, ten minutes, `tileCache.js`, shared with KartaView), +and above 60 km the `grid` layer's 0.1° density cells, binned to at most 96×96 points a tile (a z0 tile holds ~70,000 cells) and not counted as sequences: the panel says to zoom in. Both diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 2eb634c9c..84204ae83 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -155,19 +155,85 @@ export function createLineCoverage({ function drawnSequences(entry) { const drawn = []; const current = filter(); + const selectedId = state.sequence.selectedId ?? null; + const cap = entry.pointCap ?? perTilePointCap; let points = 0; + let demand = 0; for (const sequence of entry.sequenceList) { - if (drawn.length >= perTileSequenceCap) break; if (!passes(sequence, current)) continue; - // A line past the point budget is skipped; a shorter one may still fit. const size = pointsOf(sequence); - if (points + size > perTilePointCap) continue; + demand += size; + // The selected sequence is always drawn: its highlight is the answer + // to a click, whatever the caps leave out. + if (sequence.id === selectedId) { + points += size; + drawn.push(sequence); + continue; + } + if (drawn.length >= perTileSequenceCap) continue; + // A line past the point budget is skipped; a shorter one may still fit. + if (points + size > cap) continue; points += size; drawn.push(sequence); } + // What every passing line would take: the budget is shared by it. + entry.pointDemand = demand; + entry.pointsDrawn = points; return drawn; } + /** + * Share the point budget across the tiles in view (water-filling): a + * sparse tile keeps what it needs and the rest goes to the dense ones, + * so a city centre is not drawn thinner than its suburbs. Sets each + * tile's cap; returns the tiles whose cap changes what they draw. + */ + function shareCaps() { + const changed = []; + if (!tuning.maxPoints) return changed; + const entries = [...state.coverage.tiles.values()].filter( + (entry) => entry.kind === 'sequence', + ); + const byDemand = [...entries].sort( + (a, b) => (a.pointDemand || 0) - (b.pointDemand || 0), + ); + let left = tuning.maxPoints; + byDemand.forEach((entry, i) => { + const share = left / (byDemand.length - i); + const cap = Math.floor(Math.min(entry.pointDemand || 0, share)); + left -= cap; + const before = entry.pointCap ?? perTilePointCap; + entry.pointCap = cap; + // Only a change that alters what is drawn: more room for a tile that + // had lines left out, or less than it draws now. + if ( + (cap > before && entry.pointsDrawn < entry.pointDemand) || + cap < entry.pointsDrawn + ) + changed.push(entry); + }); + return changed; + } + + /** Once loading settles: redraw the tiles the shared budget changes. */ + function shareBudget() { + const changed = shareCaps(); + for (const entry of changed) redrawEntry(entry); + if (changed.length) { + requestRender(); + notify(); + } + } + + /** Draw one tile again from its decoded lines (caps or selection changed). */ + function redrawEntry(entry) { + cancelCast(entry); + entry.castRequested = false; + entry.meshParts = null; + detachPrimitive(entry); + attachPrimitive(entry); + } + function pointsOf(sequence) { let count = 0; for (const part of sequence.parts) count += part.length; @@ -616,7 +682,10 @@ export function createLineCoverage({ // must not drop a newer request's entry (which is what counts as loading). if (state.coverage.pending.get(key) === controller) { state.coverage.pending.delete(key); - if (!state.coverage.pending.size) purgeStale(); + if (!state.coverage.pending.size) { + purgeStale(); + shareBudget(); + } } notify(); } @@ -809,13 +878,12 @@ export function createLineCoverage({ /** Rebuild every loaded tile from its decoded cache (after a filter change). */ function rebuild() { purgeStale(); - for (const entry of state.coverage.tiles.values()) { - cancelCast(entry); - entry.castRequested = false; - entry.meshParts = null; - detachPrimitive(entry); - attachPrimitive(entry); - } + // What each tile's lines need under the new filter, then the shares, + // then one redraw each. + for (const entry of state.coverage.tiles.values()) + if (entry.kind === 'sequence') drawnSequences(entry); + shareCaps(); + for (const entry of state.coverage.tiles.values()) redrawEntry(entry); requestRender(); notify(); } @@ -860,6 +928,15 @@ export function createLineCoverage({ /** Recolour one sequence, every part of it, in place (selection highlight). */ function recolorSequence(id, selected) { + // Selected but left out by a cap: draw it (it is always admitted). + if (selected && (tuning.maxPoints || 0) > 0) + for (const entry of state.coverage.tiles.values()) + if ( + entry.kind === 'sequence' && + !entry.sequences.has(id) && + entry.sequenceList.some((sequence) => sequence.id === id) + ) + redrawEntry(entry); for (const entry of drawnEntries()) { const sequence = entry.sequences.get(id); if (!sequence) continue; diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 51ac366c3..e7b8b693e 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -76,7 +76,7 @@ function setup( fetchTile: async (tile) => { asked.push(tile); if (fail) throw fail; - if (decoded) return decoded(centre); + if (decoded) return decoded(centre, tile); return { sequences: [ { @@ -247,3 +247,94 @@ test('a point budget skips lines past it, and a shorter one later may still fit' const [tile] = state.coverage.tiles.values(); assert.deepEqual([...tile.sequences.keys()], ['three', 'two']); }); + +/** A line of `n` points, its id unique to the tile. */ +function pointsLine(id, n, tile, centre) { + return { + id, + parts: [ + Array.from({ length: n }, (_, i) => [ + centre.lon - 0.001 + i * 0.00001, + centre.lat, + ]), + ], + capturedAt: 0, + isPano: false, + }; +} + +test('the point budget is shared: a sparse tile keeps its few, a dense one gets the rest', async (t) => { + const tuning = { + ...TUNING, + maxTiles: 9, + maxSequences: 900, + maxPoints: 90, + keepOrder: true, + }; + const { state, coverage } = setup(t, { + // Street zoom far enough in that several tiles are in view. + zoomFor: () => ({ zoom: 18, kind: 'sequence' }), + tuning, + decoded: (centre, tile) => { + const dense = tile.x % 2 === 0 && tile.y % 2 === 0; + const lines = dense + ? Array.from({ length: 30 }, (_, i) => + pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre), + ) + : [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)]; + return { sequences: lines, overview: [] }; + }, + }); + coverage.refresh(); + assert.ok( + await until( + () => state.coverage.tiles.size > 1 && !state.coverage.pending.size, + ), + ); + const entries = [...state.coverage.tiles.values()]; + const drawn = (entry) => entry.pointsDrawn; + const total = entries.reduce((sum, entry) => sum + drawn(entry), 0); + assert.ok(total <= tuning.maxPoints, `within the budget: ${total}`); + const sparse = entries.filter((entry) => entry.pointDemand === 2); + const dense = entries.filter((entry) => entry.pointDemand === 150); + assert.ok(sparse.length && dense.length, 'both kinds in view'); + for (const entry of sparse) + assert.equal(drawn(entry), 2, 'a sparse tile keeps all'); + const fair = Math.floor(tuning.maxPoints / tuning.maxTiles); + const left = tuning.maxPoints - sparse.length * 2; + for (const entry of dense) + assert.ok( + drawn(entry) > fair || dense.length * fair >= left, + `a dense tile gets more than the even share: ${drawn(entry)}`, + ); + assert.ok( + total > tuning.maxPoints - 5 * dense.length, + 'little budget unused', + ); +}); + +test('a selected sequence the budget left out is drawn, highlighted', async (t) => { + const { state, coverage } = setup(t, { + tuning: { + ...TUNING, + maxTiles: 1, + maxSequences: 10, + maxPoints: 5, + keepOrder: true, + }, + decoded: (centre, tile) => ({ + sequences: [ + pointsLine('long', 5, tile, centre), + pointsLine('short', 3, tile, centre), + ], + overview: [], + }), + }); + coverage.refresh(); + assert.ok(await until(() => state.coverage.tiles.size === 1)); + const [tile] = state.coverage.tiles.values(); + assert.deepEqual([...tile.sequences.keys()], ['long']); + state.sequence.selectedId = 'short'; + coverage.recolorSequence('short', true); + assert.deepEqual([...tile.sequences.keys()].sort(), ['long', 'short']); +}); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index b4fb67398..0457e9f76 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -79,8 +79,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { // photo's size before it is decoded. const length = Number(response.headers?.get?.('content-length')); if (length > MAX_IMAGE_BYTES) throw new Error(failure); - const blob = await response.blob(); - if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure); + const blob = await boundedBlob(response, failure); const bitmap = await (resize ? createImageBitmap(blob, { resizeWidth: resize[0], @@ -88,6 +87,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { resizeQuality: 'high', }) : createImageBitmap(blob)); + // (A resized decode reports its new size: this guards unresized ones.) if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) { bitmap.close?.(); throw new Error(failure); @@ -101,6 +101,34 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { } } +/** + * The body as a Blob, read with a running count so a host that sends no + * length (or a false one) cannot fill memory before the cap is checked. + */ +async function boundedBlob(response, failure) { + const reader = response.body?.getReader?.(); + if (!reader) { + const blob = await response.blob(); + if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure); + return blob; + } + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_IMAGE_BYTES) { + reader.cancel().catch(() => {}); + throw new Error(failure); + } + chunks.push(value); + } + return new Blob(chunks, { + type: response.headers?.get?.('content-type') || 'image/jpeg', + }); +} + /** The panorama size a canvas this wide can show sharply at this field of view. */ export function panoSizeFor(canvasPixels, fovDeg) { const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg); diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs index 78397a632..05391a3cc 100644 --- a/src/layers/streetLevel/photoViewer.test.mjs +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -303,6 +303,20 @@ test('a picture file or bitmap past any real photo’s size is refused', async ( respond({ width: MAX_IMAGE_SIDE + 1 }); await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); assert.equal(closed, 1, 'the oversize bitmap is released'); + // No length given, a body that keeps coming: cut off while it streams. + let pulled = 0; + globalThis.fetch = async () => ({ + ok: true, + headers: new Map(), + body: new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(new Uint8Array(8 * 1024 * 1024)); + }, + }), + }); + await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); + assert.ok(pulled * 8 * 1024 * 1024 <= MAX_IMAGE_BYTES + 16 * 1024 * 1024); respond({}); assert.equal((await loadImage('https://x/a.jpg')).width, 100); }); diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index c75a01768..0a354d06b 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -9,7 +9,8 @@ import { /** * Douglas–Peucker in tile units: the points of `line` ({x, y}) that keep - * it within `tolerance` of the original, first and last always kept. + * it within `tolerance` of the original (measured to each segment, so + * dead ends and U-turns stay), first and last always kept. * Iterative, so a long track cannot overflow the stack. */ export function simplifyLine(line, tolerance) { @@ -24,15 +25,20 @@ export function simplifyLine(line, tolerance) { const b = line[to]; const dx = b.x - a.x; const dy = b.y - a.y; - const span = Math.hypot(dx, dy); + const span2 = dx * dx + dy * dy; let far = -1; let farthest = tolerance; for (let i = from + 1; i < to; i++) { const p = line[i]; - // Distance to the chord, or to `a` where the chord has no length. - const d = span - ? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / span - : Math.hypot(p.x - a.x, p.y - a.y); + // Distance to the segment, not the infinite line: a dead end or a + // U-turn (a point beyond the chord's ends) is kept. + const t = span2 + ? Math.max( + 0, + Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / span2), + ) + : 0; + const d = Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); if (d > farthest) { farthest = d; far = i; diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index 104471a1e..c98ff3461 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -216,6 +216,13 @@ test('lines are simplified within a few tile units, ends and corners kept', () = ]; assert.equal(simplifyLine(loop, 4).length, 3); assert.equal(simplifyLine(zigzag, 0).length, 1000, 'no tolerance, no change'); + // A dead end: out to 100 and back to 50 keeps its far end. + const deadEnd = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 50, y: 0 }, + ]; + assert.deepEqual(simplifyLine(deadEnd, 4), deadEnd); }); test('a decoded GPS track keeps a fraction of its points', () => { diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index 9eb13cdaf..b6cfa93c8 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -286,11 +286,15 @@ export function createPanoramaxProvider({ }); /** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */ const pictureLists = createLru(PICTURE_LIST_CACHE_SIZE); - function picturesIn(tile, sequenceId, signal) { + /** + * Only finished lists are kept: a load in flight belongs to its own + * selection, whose abort (a re-centre) must not empty the next one's. + */ + async function picturesIn(tile, sequenceId, signal) { const key = `${tileKey(tile)}:${sequenceId}`; const kept = pictureLists.get(key); if (kept) return kept; - const list = readable(async () => + const list = await readable(async () => decodePictures( await source.getTile(tile, { signal, lane: 'pictures' }), tile, @@ -298,7 +302,6 @@ export function createPanoramaxProvider({ ), ); pictureLists.set(key, list); - list.catch(() => pictureLists.delete(key)); return list; } @@ -418,6 +421,9 @@ export function createPanoramaxProvider({ cones.setVisible(false); drawn = null; preferred = null; + // Off: decoded tiles and lists go (several MB each over a city). + coverageTiles.clear(); + pictureLists.clear(); }, destroy(cesiumViewer) { diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index 59fc95b3b..7e8ccca0d 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -62,8 +62,8 @@ export const COVERAGE_TUNING = Object.freeze({ }); /** * Lines are simplified (Douglas–Peucker) to this many tile units of a - * 4,096 extent: under a pixel at every height lines are drawn from, and - * 6–10% of the points GPS tracks carry. + * 4,096 extent: about a pixel where each zoom is drawn (1.6 m at z14, + * within GPS noise), and 6–10% of the points GPS tracks carry. */ export const SIMPLIFY_TOLERANCE_UNITS = 4; /** @@ -280,8 +280,8 @@ export function normalizeItem(item) { isPano: isEquirectangular(item), capturedAt: panoramaxTimeMs(props.datetime), creator: creatorOf(item), - // Licences ask for the source to be named: the instance holding it is - // named beside an author, or alone where the author is hidden. + // Licences ask for the source to be named: where no author can be + // named, the caption names the instance holding the picture. via: instanceOf(item), license: licenseLabel(props.license), width: Number.isFinite(width) && width > 0 ? width : null, diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 8cf3911d9..031f376d7 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -927,19 +927,89 @@ test('lines keep the decoded (longest-first) order under a budget of points', as assert.ok(COVERAGE_TUNING.maxPoints > 0); }); -test('a coverage tile decoded a moment ago is not fetched or decoded again', async (t) => { +test('a coverage tile decoded a moment ago is not fetched again; switched off, the tiles are let go', async (t) => { const env = coverageSetup(t, { sequences: [line('a')] }); - let asked = 0; + const asked = []; const getTile = env.source.getTile; env.source.getTile = (tile, options) => { - asked++; + asked.push(`${tile.z}/${tile.x}/${tile.y}`); return getTile(tile, options); }; env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - const first = asked; + const z14 = asked.length; + // Up a zoom band and back down: the z14 tile comes from memory. + env.viewer.view.height = 3_000; + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().zoom === 13)); + assert.ok(await until(() => !env.instance.coverageStats().loading)); + const withZ13 = asked.length; + env.viewer.view.height = 900; + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().zoom === 14)); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(asked.length, withZ13, 'nothing fetched again'); + assert.ok(z14 > 0); + // Off and on: the memory went back, so the tile is fetched again. env.instance.deactivate(); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - assert.equal(asked, first); + assert.ok(asked.length > withZ13); +}); + +test('a re-centre while the cones still load does not inherit the aborted load’s tiles', async (t) => { + const items = sequence('long', 400); + const tiles = pictureTiles(items); + const env = await mounted({ items, tiles }); + const cones = await withCones(t, env); + let hold = null; + env.source.getTile = async (tile, { signal } = {}) => { + env.source.calls.tiles.push(`${tile.z}/${tile.x}/${tile.y}`); + if (hold) await hold.promise; + signal?.throwIfAborted(); + return tiles(tile); + }; + await env.instance.viewer.open('long-0'); + const tileOf = (item) => { + const [lon, lat] = item.geometry.coordinates; + return pictureTilesAround({ lon, lat })[0]; + }; + const first = new Set( + pictureTilesAround({ + lon: items[0].geometry.coordinates[0], + lat: items[0].geometry.coordinates[1], + }).map((tile) => `${tile.x}/${tile.y}`), + ); + const out = items.findIndex((item) => { + const tile = tileOf(item); + return !first.has(`${tile.x}/${tile.y}`); + }); + hold = {}; + hold.promise = new Promise((resolve) => (hold.release = resolve)); + const loading = env.instance.selectSequence('long'); + await new Promise((resolve) => setTimeout(resolve, 5)); + // The far picture opens while the first load is held: the cones re-centre. + await env.instance.viewer.open(`long-${out}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + const release = hold.release; + hold = null; + release(); + await loading; + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + // Every picture in the tiles the second load covers has its cone. + const [lon, lat] = items[out].geometry.coordinates; + const second = new Set( + pictureTilesAround({ lon, lat }).map((tile) => `${tile.x}/${tile.y}`), + ); + const ids = new Set(cones.ids()); + const covered = items.filter((item) => { + const tile = tileOf(item); + return second.has(`${tile.x}/${tile.y}`); + }); + assert.ok(covered.length > 0); + assert.equal( + covered.filter((item) => ids.has(item.id)).length, + covered.length, + 'every picture in the re-centred tiles has its cone (the shared ones too)', + ); }); diff --git a/src/layers/streetLevel/tileCache.js b/src/layers/streetLevel/tileCache.js index 55d997cdb..7fc9bb889 100644 --- a/src/layers/streetLevel/tileCache.js +++ b/src/layers/streetLevel/tileCache.js @@ -44,5 +44,7 @@ export function createTileCache({ kept.set(key, { at: now(), tile: loaded }); return loaded; }, + /** Forget every tile (the layer is off: its memory goes back). */ + clear: () => kept.clear(), }; }