mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user