fix(street-level): Panoramax IC8 round 2 — shared point budget, selected line, cone cache, DP segments

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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 15:39:10 +01:00
1 parent 1a546bec72
commit 3ae367d0dd
11 files changed
+343 -33

No files matched your search

+10 -1
View File
@@ -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 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 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 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° 60 km the `grid` layer's 0.1°
density cells, binned to at most 96×96 points a tile (a z0 tile holds 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 ~70,000 cells) and not counted as sequences: the panel says to zoom in. Both
+88 -11
View File
@@ -155,19 +155,85 @@ export function createLineCoverage({
function drawnSequences(entry) { function drawnSequences(entry) {
const drawn = []; const drawn = [];
const current = filter(); const current = filter();
const selectedId = state.sequence.selectedId ?? null;
const cap = entry.pointCap ?? perTilePointCap;
let points = 0; let points = 0;
let demand = 0;
for (const sequence of entry.sequenceList) { for (const sequence of entry.sequenceList) {
if (drawn.length >= perTileSequenceCap) break;
if (!passes(sequence, current)) continue; if (!passes(sequence, current)) continue;
// A line past the point budget is skipped; a shorter one may still fit.
const size = pointsOf(sequence); 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; points += size;
drawn.push(sequence); drawn.push(sequence);
} }
// What every passing line would take: the budget is shared by it.
entry.pointDemand = demand;
entry.pointsDrawn = points;
return drawn; 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) { function pointsOf(sequence) {
let count = 0; let count = 0;
for (const part of sequence.parts) count += part.length; 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). // must not drop a newer request's entry (which is what counts as loading).
if (state.coverage.pending.get(key) === controller) { if (state.coverage.pending.get(key) === controller) {
state.coverage.pending.delete(key); state.coverage.pending.delete(key);
if (!state.coverage.pending.size) purgeStale(); if (!state.coverage.pending.size) {
purgeStale();
shareBudget();
}
} }
notify(); notify();
} }
@@ -809,13 +878,12 @@ export function createLineCoverage({
/** Rebuild every loaded tile from its decoded cache (after a filter change). */ /** Rebuild every loaded tile from its decoded cache (after a filter change). */
function rebuild() { function rebuild() {
purgeStale(); purgeStale();
for (const entry of state.coverage.tiles.values()) { // What each tile's lines need under the new filter, then the shares,
cancelCast(entry); // then one redraw each.
entry.castRequested = false; for (const entry of state.coverage.tiles.values())
entry.meshParts = null; if (entry.kind === 'sequence') drawnSequences(entry);
detachPrimitive(entry); shareCaps();
attachPrimitive(entry); for (const entry of state.coverage.tiles.values()) redrawEntry(entry);
}
requestRender(); requestRender();
notify(); notify();
} }
@@ -860,6 +928,15 @@ export function createLineCoverage({
/** Recolour one sequence, every part of it, in place (selection highlight). */ /** Recolour one sequence, every part of it, in place (selection highlight). */
function recolorSequence(id, selected) { 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()) { for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id); const sequence = entry.sequences.get(id);
if (!sequence) continue; if (!sequence) continue;
+92 -1
View File
@@ -76,7 +76,7 @@ function setup(
fetchTile: async (tile) => { fetchTile: async (tile) => {
asked.push(tile); asked.push(tile);
if (fail) throw fail; if (fail) throw fail;
if (decoded) return decoded(centre); if (decoded) return decoded(centre, tile);
return { return {
sequences: [ 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(); const [tile] = state.coverage.tiles.values();
assert.deepEqual([...tile.sequences.keys()], ['three', 'two']); 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']);
});
+30 -2
View File
@@ -79,8 +79,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) {
// photo's size before it is decoded. // photo's size before it is decoded.
const length = Number(response.headers?.get?.('content-length')); const length = Number(response.headers?.get?.('content-length'));
if (length > MAX_IMAGE_BYTES) throw new Error(failure); if (length > MAX_IMAGE_BYTES) throw new Error(failure);
const blob = await response.blob(); const blob = await boundedBlob(response, failure);
if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure);
const bitmap = await (resize const bitmap = await (resize
? createImageBitmap(blob, { ? createImageBitmap(blob, {
resizeWidth: resize[0], resizeWidth: resize[0],
@@ -88,6 +87,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) {
resizeQuality: 'high', resizeQuality: 'high',
}) })
: createImageBitmap(blob)); : createImageBitmap(blob));
// (A resized decode reports its new size: this guards unresized ones.)
if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) { if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) {
bitmap.close?.(); bitmap.close?.();
throw new Error(failure); 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. */ /** The panorama size a canvas this wide can show sharply at this field of view. */
export function panoSizeFor(canvasPixels, fovDeg) { export function panoSizeFor(canvasPixels, fovDeg) {
const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg); const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg);
@@ -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 }); respond({ width: MAX_IMAGE_SIDE + 1 });
await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/); await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/);
assert.equal(closed, 1, 'the oversize bitmap is released'); 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({}); respond({});
assert.equal((await loadImage('https://x/a.jpg')).width, 100); assert.equal((await loadImage('https://x/a.jpg')).width, 100);
}); });
@@ -9,7 +9,8 @@ import {
/** /**
* Douglas–Peucker in tile units: the points of `line` ({x, y}) that keep * 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. * Iterative, so a long track cannot overflow the stack.
*/ */
export function simplifyLine(line, tolerance) { export function simplifyLine(line, tolerance) {
@@ -24,15 +25,20 @@ export function simplifyLine(line, tolerance) {
const b = line[to]; const b = line[to];
const dx = b.x - a.x; const dx = b.x - a.x;
const dy = b.y - a.y; const dy = b.y - a.y;
const span = Math.hypot(dx, dy); const span2 = dx * dx + dy * dy;
let far = -1; let far = -1;
let farthest = tolerance; let farthest = tolerance;
for (let i = from + 1; i < to; i++) { for (let i = from + 1; i < to; i++) {
const p = line[i]; const p = line[i];
// Distance to the chord, or to `a` where the chord has no length. // Distance to the segment, not the infinite line: a dead end or a
const d = span // U-turn (a point beyond the chord's ends) is kept.
? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / span const t = span2
: Math.hypot(p.x - a.x, p.y - a.y); ? 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) { if (d > farthest) {
farthest = d; farthest = d;
far = i; far = i;
@@ -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(loop, 4).length, 3);
assert.equal(simplifyLine(zigzag, 0).length, 1000, 'no tolerance, no change'); 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', () => { test('a decoded GPS track keeps a fraction of its points', () => {
@@ -286,11 +286,15 @@ export function createPanoramaxProvider({
}); });
/** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */ /** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */
const pictureLists = createLru(PICTURE_LIST_CACHE_SIZE); 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 key = `${tileKey(tile)}:${sequenceId}`;
const kept = pictureLists.get(key); const kept = pictureLists.get(key);
if (kept) return kept; if (kept) return kept;
const list = readable(async () => const list = await readable(async () =>
decodePictures( decodePictures(
await source.getTile(tile, { signal, lane: 'pictures' }), await source.getTile(tile, { signal, lane: 'pictures' }),
tile, tile,
@@ -298,7 +302,6 @@ export function createPanoramaxProvider({
), ),
); );
pictureLists.set(key, list); pictureLists.set(key, list);
list.catch(() => pictureLists.delete(key));
return list; return list;
} }
@@ -418,6 +421,9 @@ export function createPanoramaxProvider({
cones.setVisible(false); cones.setVisible(false);
drawn = null; drawn = null;
preferred = null; preferred = null;
// Off: decoded tiles and lists go (several MB each over a city).
coverageTiles.clear();
pictureLists.clear();
}, },
destroy(cesiumViewer) { destroy(cesiumViewer) {
@@ -62,8 +62,8 @@ export const COVERAGE_TUNING = Object.freeze({
}); });
/** /**
* Lines are simplified (Douglas–Peucker) to this many tile units of a * 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 * 4,096 extent: about a pixel where each zoom is drawn (1.6 m at z14,
* 6–10% of the points GPS tracks carry. * within GPS noise), and 6–10% of the points GPS tracks carry.
*/ */
export const SIMPLIFY_TOLERANCE_UNITS = 4; export const SIMPLIFY_TOLERANCE_UNITS = 4;
/** /**
@@ -280,8 +280,8 @@ export function normalizeItem(item) {
isPano: isEquirectangular(item), isPano: isEquirectangular(item),
capturedAt: panoramaxTimeMs(props.datetime), capturedAt: panoramaxTimeMs(props.datetime),
creator: creatorOf(item), creator: creatorOf(item),
// Licences ask for the source to be named: the instance holding it is // Licences ask for the source to be named: where no author can be
// named beside an author, or alone where the author is hidden. // named, the caption names the instance holding the picture.
via: instanceOf(item), via: instanceOf(item),
license: licenseLabel(props.license), license: licenseLabel(props.license),
width: Number.isFinite(width) && width > 0 ? width : null, width: Number.isFinite(width) && width > 0 ? width : null,
@@ -927,19 +927,89 @@ test('lines keep the decoded (longest-first) order under a budget of points', as
assert.ok(COVERAGE_TUNING.maxPoints > 0); 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')] }); const env = coverageSetup(t, { sequences: [line('a')] });
let asked = 0; const asked = [];
const getTile = env.source.getTile; const getTile = env.source.getTile;
env.source.getTile = (tile, options) => { env.source.getTile = (tile, options) => {
asked++; asked.push(`${tile.z}/${tile.x}/${tile.y}`);
return getTile(tile, options); return getTile(tile, options);
}; };
env.instance.activate(env.viewer); env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); 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.deactivate();
env.instance.activate(env.viewer); env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); 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)',
);
}); });
+2
View File
@@ -44,5 +44,7 @@ export function createTileCache({
kept.set(key, { at: now(), tile: loaded }); kept.set(key, { at: now(), tile: loaded });
return loaded; return loaded;
}, },
/** Forget every tile (the layer is off: its memory goes back). */
clear: () => kept.clear(),
}; };
} }