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
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
+88 -11
View File
@@ -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;
+92 -1
View File
@@ -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']);
});
+30 -2
View File
@@ -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)',
);
});
+2
View File
@@ -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(),
};
}