mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
1a546bec72
commit
3ae367d0dd
11 files changed
+343
-33
No files matched your search
+10
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user