mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Image loading (photoViewer.js) - One AbortController per open: a new open, close or unmount stops the previous photo's first and sharper downloads (an <img> by src = ''). A collapsed viewer never falls back to the original; Panoramax's flat fallback is the thumbnail. A superseded open resolves quietly. - Flat HD tracked as "loading" and "shown" apart: a discarded response no longer blocks later upgrades, and resizes while it loads ask once. - No upgrade of the photo still shown while another opens. - Size limits before decoding: imageSize.js reads JPEG/PNG/WebP/GIF headers; more than 64 MP or 16,384 px a side is refused undecoded, an <img> that decodes larger is refused and let go, flat HD needs a known width of at most 8,192 px, and panoramas always decode with a resize. Panoramax provider - Retry-After parsed as seconds or an HTTP date; the browser cannot read it here, so busy answers back off 5 s doubling to 300 s, reset once answered. - Headers timed, then the body by an idle timeout (15 s of silence), so a slow link still gets its megabyte tiles. - Cones centre only on the picture shown (onShowing), never on a late lookup; lookups nobody waits for are aborted (shared, refcounted). - onReopen: a GPU reset redraws in the viewer, not through the core. - Cones draw the home z15 tile at once and add neighbours as they arrive; a neighbour that failed is not counted as drawn. - Coverage tiles decode in ~8 ms slices (a 2 MB z10 tile over Paris was one ~85-200 ms task); sorted by measured length without a Map. - Box search asks for 20, not 100 (the API returns them nearest first). - looksLikeEmail checks every '@'; creatorOf de-duplicates with a Set. lineCoverage.js - A redraw while a swap still builds keeps what is on screen (the swap's old lines and its ready new ones) and drops only the unready ones. Tests for each (mutation-checked), two vacuous tests made real, the cones' pictures lane and readable()'s busy/timeout paths covered; the fixture's box search is nearest first. Docs: README filters and share links, CHANGELOG HD wording, CURRENT-STATE. Co-Authored-By: Claude Opus 5.5 <[email protected]>
533 lines
16 KiB
JavaScript
533 lines
16 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { createLineCoverage } from './lineCoverage.js';
|
||
import {
|
||
coverageViewer,
|
||
tileCentre,
|
||
} from '../../testSupport/streetLevelFakes.mjs';
|
||
import { until } from '../../testSupport/kartaViewFakes.mjs';
|
||
|
||
const TUNING = Object.freeze({
|
||
maxTiles: 9,
|
||
overviewMaxTiles: 0,
|
||
maxSequences: 900,
|
||
lineWidthPx: 2,
|
||
overviewPointPx: 2,
|
||
moveDebounceMs: 10,
|
||
viewRangeMinM: 2_500,
|
||
viewRangePerHeight: 10,
|
||
viewNearM: 1_000,
|
||
});
|
||
|
||
/**
|
||
* Line coverage over one tile holding one line, on a viewer looking down from
|
||
* `height`. `fetchTile` answers with that line unless `fail` says otherwise.
|
||
*/
|
||
function setup(
|
||
t,
|
||
{
|
||
zoomFor,
|
||
messages = { rateLimited: 'busy' },
|
||
passes,
|
||
fail,
|
||
decoded = null,
|
||
tuning = TUNING,
|
||
overviewPointSize = null,
|
||
} = {},
|
||
) {
|
||
const centre = tileCentre(-121.4944, 38.5816);
|
||
const viewer = coverageViewer(
|
||
{ lon: centre.lon, lat: centre.lat, height: 900 },
|
||
t,
|
||
);
|
||
const state = {
|
||
viewer,
|
||
services: {},
|
||
keyRequired: false,
|
||
keyRejected: false,
|
||
context: {
|
||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||
isActive: () => true,
|
||
notify() {},
|
||
getSurface: () => 'draped',
|
||
groundCaster: null,
|
||
meshSampler: null,
|
||
},
|
||
coverage: {
|
||
zoom: null,
|
||
kind: null,
|
||
tiles: new Map(),
|
||
stale: new Map(),
|
||
staleTimer: null,
|
||
pending: new Map(),
|
||
lastError: null,
|
||
holdUntil: 0,
|
||
holdTimer: null,
|
||
debounceTimer: null,
|
||
removeCameraListener: null,
|
||
terrainReady: Promise.resolve(),
|
||
hint: '',
|
||
},
|
||
sequence: { selectedId: null },
|
||
};
|
||
const asked = [];
|
||
const coverage = createLineCoverage({
|
||
state,
|
||
fetchTile: async (tile) => {
|
||
asked.push(tile);
|
||
if (fail) throw fail;
|
||
if (decoded) return decoded(centre, tile);
|
||
return {
|
||
sequences: [
|
||
{
|
||
id: 'line-1',
|
||
parts: [
|
||
[
|
||
[centre.lon - 0.001, centre.lat],
|
||
[centre.lon + 0.001, centre.lat],
|
||
],
|
||
],
|
||
capturedAt: 0,
|
||
isPano: false,
|
||
},
|
||
],
|
||
overview: [],
|
||
};
|
||
},
|
||
zoomFor:
|
||
zoomFor ??
|
||
((height) =>
|
||
Number.isFinite(height) && height <= 6_000
|
||
? { zoom: 14, kind: 'sequence' }
|
||
: null),
|
||
colors: { coverage: '#635bff', selected: '#00e5ff' },
|
||
pickPrefix: 'test:line:',
|
||
renderKey: 'test-coverage',
|
||
tuning,
|
||
messages,
|
||
...(passes ? { passes } : {}),
|
||
...(overviewPointSize ? { overviewPointSize } : {}),
|
||
});
|
||
t.after(() => {
|
||
coverage.clear();
|
||
coverage.resetErrors();
|
||
});
|
||
return { viewer, state, coverage, asked };
|
||
}
|
||
|
||
test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => {
|
||
const tooHigh = 'zoom in to see test coverage';
|
||
const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } });
|
||
high.viewer.view.height = 7_000;
|
||
high.coverage.refresh();
|
||
assert.equal(high.state.coverage.hint, tooHigh);
|
||
assert.equal(high.state.coverage.zoom, null);
|
||
assert.equal(high.asked.length, 0, 'nothing is asked from up there');
|
||
const generic = setup(t);
|
||
generic.viewer.view.height = 7_000;
|
||
generic.coverage.refresh();
|
||
assert.notEqual(generic.state.coverage.hint, tooHigh);
|
||
assert.match(generic.state.coverage.hint, /Point the camera/);
|
||
});
|
||
|
||
test('a provider’s passes() decides which lines are drawn and counted', async (t) => {
|
||
const all = setup(t, { passes: () => true });
|
||
all.coverage.refresh();
|
||
assert.ok(await until(() => all.state.coverage.tiles.size === 1));
|
||
assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn');
|
||
const none = setup(t, { passes: () => false });
|
||
none.coverage.refresh();
|
||
assert.ok(await until(() => none.state.coverage.tiles.size === 1));
|
||
assert.equal(none.coverage.sequenceCount(), 0, 'filtered out');
|
||
});
|
||
|
||
test('a refused key without its own message keeps the error it came with', async (t) => {
|
||
const refused = Object.assign(new Error('the source refused the key'), {
|
||
keyRejected: true,
|
||
});
|
||
const { state, coverage } = setup(t, { fail: refused });
|
||
coverage.refresh();
|
||
assert.ok(await until(() => state.keyRejected === true));
|
||
assert.equal(state.coverage.lastError, 'the source refused the key');
|
||
});
|
||
|
||
/** Three lines in one tile: an old long one, then two newer short ones. */
|
||
function threeLines(centre) {
|
||
const line = (id, capturedAt, span) => ({
|
||
id,
|
||
parts: [
|
||
[
|
||
[centre.lon - span, centre.lat],
|
||
[centre.lon + span, centre.lat],
|
||
],
|
||
],
|
||
capturedAt,
|
||
isPano: false,
|
||
});
|
||
return {
|
||
sequences: [
|
||
line('old-long', 1, 0.002),
|
||
line('new-short', 2, 0.0002),
|
||
line('newer-short', 3, 0.0001),
|
||
],
|
||
overview: [],
|
||
};
|
||
}
|
||
|
||
test('the per-tile cap keeps the newest lines, or the provider’s own order with keepOrder', async (t) => {
|
||
const capped = { ...TUNING, maxTiles: 1, maxSequences: 2 };
|
||
const drawnIds = (state) =>
|
||
[...state.coverage.tiles.values()].flatMap((tile) => [
|
||
...tile.sequences.keys(),
|
||
]);
|
||
const newest = setup(t, { decoded: threeLines, tuning: capped });
|
||
newest.coverage.refresh();
|
||
assert.ok(await until(() => newest.state.coverage.tiles.size === 1));
|
||
assert.deepEqual(drawnIds(newest.state).sort(), ['new-short', 'newer-short']);
|
||
const kept = setup(t, {
|
||
decoded: threeLines,
|
||
tuning: { ...capped, keepOrder: true },
|
||
});
|
||
kept.coverage.refresh();
|
||
assert.ok(await until(() => kept.state.coverage.tiles.size === 1));
|
||
assert.deepEqual(drawnIds(kept.state), ['old-long', 'new-short']);
|
||
});
|
||
|
||
test('overview points take the provider’s size, each its own', async (t) => {
|
||
const { state, coverage, viewer } = setup(t, {
|
||
zoomFor: () => ({ zoom: 3, kind: 'overview' }),
|
||
tuning: { ...TUNING, overviewMaxTiles: 4 },
|
||
decoded: (centre) => ({
|
||
sequences: [],
|
||
overview: [
|
||
{ id: 'a', lon: centre.lon, lat: centre.lat, weight: 1 },
|
||
{ id: 'b', lon: centre.lon + 0.01, lat: centre.lat, weight: 50_000 },
|
||
],
|
||
}),
|
||
overviewPointSize: (point) => (point.weight > 1 ? 7 : 3),
|
||
});
|
||
coverage.refresh();
|
||
assert.ok(await until(() => state.coverage.tiles.size > 0));
|
||
const sizes = [];
|
||
for (const primitive of viewer.scene.primitives.items)
|
||
for (let i = 0; i < (primitive.length ?? 0); i++)
|
||
sizes.push(primitive.get(i).pixelSize);
|
||
assert.deepEqual(sizes.sort(), [3, 7]);
|
||
});
|
||
|
||
test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => {
|
||
const { state, coverage } = setup(t, {
|
||
tuning: {
|
||
...TUNING,
|
||
maxTiles: 1,
|
||
maxSequences: 10,
|
||
maxPoints: 5,
|
||
keepOrder: true,
|
||
},
|
||
decoded: (centre) => {
|
||
const line = (id, n) => ({
|
||
id,
|
||
parts: [
|
||
Array.from({ length: n }, (_, i) => [
|
||
centre.lon - 0.001 + i * 0.0001,
|
||
centre.lat,
|
||
]),
|
||
],
|
||
capturedAt: 0,
|
||
isPano: false,
|
||
});
|
||
return {
|
||
sequences: [line('three', 3), line('four', 4), line('two', 2)],
|
||
overview: [],
|
||
};
|
||
},
|
||
});
|
||
coverage.refresh();
|
||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||
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']);
|
||
});
|
||
|
||
/** Count the line primitives a coverage adds to the scene. */
|
||
function countAdds(viewer) {
|
||
const counts = { added: 0 };
|
||
for (const collection of [
|
||
viewer.scene.groundPrimitives,
|
||
viewer.scene.primitives,
|
||
]) {
|
||
const add = collection.add.bind(collection);
|
||
collection.add = (primitive) => {
|
||
counts.added++;
|
||
return add(primitive);
|
||
};
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
test('a tile redrawn for a new share keeps its old lines until the new ones are ready', async (t) => {
|
||
const tuning = {
|
||
...TUNING,
|
||
maxTiles: 9,
|
||
maxSequences: 900,
|
||
maxPoints: 90,
|
||
keepOrder: true,
|
||
};
|
||
const { state, coverage, viewer } = setup(t, {
|
||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||
tuning,
|
||
decoded: (centre, tile) => {
|
||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||
return {
|
||
sequences: 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)],
|
||
overview: [],
|
||
};
|
||
},
|
||
});
|
||
coverage.refresh();
|
||
assert.ok(
|
||
await until(
|
||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||
),
|
||
);
|
||
const redrawn = [...state.coverage.tiles.values()].filter(
|
||
(entry) => entry.swap,
|
||
);
|
||
assert.ok(redrawn.length > 0, 'a dense tile took a bigger share');
|
||
for (const entry of redrawn)
|
||
for (const { primitive } of entry.swap.old)
|
||
assert.ok(
|
||
viewer.scene.groundPrimitives.items.has(primitive) ||
|
||
viewer.scene.primitives.items.has(primitive),
|
||
'the old lines are still on the globe while the new ones build',
|
||
);
|
||
});
|
||
|
||
test('a second redraw while a swap still builds keeps the lines on screen', async (t) => {
|
||
const { state, coverage, viewer } = setup(t, {
|
||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||
tuning: {
|
||
...TUNING,
|
||
maxTiles: 9,
|
||
maxSequences: 900,
|
||
maxPoints: 90,
|
||
keepOrder: true,
|
||
},
|
||
decoded: (centre, tile) => {
|
||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||
return {
|
||
sequences: 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)],
|
||
overview: [],
|
||
};
|
||
},
|
||
});
|
||
coverage.refresh();
|
||
assert.ok(
|
||
await until(
|
||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||
),
|
||
);
|
||
// A tile mid-swap (its new lines never become ready here) with a line
|
||
// the cap left out: selecting that line redraws the tile again.
|
||
const entry = [...state.coverage.tiles.values()].find(
|
||
(tile) =>
|
||
tile.swap &&
|
||
tile.sequenceList.some((sequence) => !tile.sequences.has(sequence.id)),
|
||
);
|
||
assert.ok(entry, 'a swapping tile with a line left out');
|
||
const onScreen = [...entry.swap.old];
|
||
const building = entry.primitives.map(({ primitive }) => primitive);
|
||
const left = entry.sequenceList.find(
|
||
(sequence) => !entry.sequences.has(sequence.id),
|
||
);
|
||
state.sequence.selectedId = left.id;
|
||
coverage.recolorSequence(left.id, true);
|
||
assert.ok(entry.sequences.has(left.id), 'the selected line is drawn');
|
||
const onGlobe = (primitive) =>
|
||
viewer.scene.groundPrimitives.items.has(primitive) ||
|
||
viewer.scene.primitives.items.has(primitive);
|
||
for (const { primitive } of onScreen)
|
||
assert.ok(
|
||
onGlobe(primitive),
|
||
'the lines on screen stay until the new ones are ready',
|
||
);
|
||
for (const primitive of building)
|
||
assert.ok(!onGlobe(primitive), 'the first swap’s unready lines are gone');
|
||
assert.ok(entry.swap, 'still swapping');
|
||
});
|
||
|
||
test('a selected line outside the cap does not make its tile redraw on every settle, nor does one the filter hides', async (t) => {
|
||
let pano = 'all';
|
||
const { state, coverage, viewer } = 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), isPano: true },
|
||
],
|
||
overview: [],
|
||
}),
|
||
});
|
||
state.context.getFilter = () => ({ pano, sinceMs: null });
|
||
coverage.refresh();
|
||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||
state.sequence.selectedId = 'short';
|
||
coverage.recolorSequence('short', true);
|
||
const counts = countAdds(viewer);
|
||
for (let i = 0; i < 3; i++) {
|
||
coverage.refresh();
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
}
|
||
assert.equal(counts.added, 0, 'settles redraw nothing');
|
||
// A selection the filter hides is not drawn, so it is not redrawn for.
|
||
pano = 'flat';
|
||
coverage.rebuild();
|
||
const after = countAdds(viewer);
|
||
for (let i = 0; i < 3; i++) coverage.recolorSequence('short', true);
|
||
assert.equal(after.added, 0);
|
||
});
|
||
|
||
test('small share increases add up: a retained tile redraws once its share has grown past the threshold since its last draw', async (t) => {
|
||
const tuning = {
|
||
...TUNING,
|
||
maxTiles: 1,
|
||
maxSequences: 900,
|
||
maxPoints: 100,
|
||
keepOrder: true,
|
||
};
|
||
const { state, coverage } = setup(t, {
|
||
tuning,
|
||
decoded: (centre, tile) => ({
|
||
sequences: Array.from({ length: 30 }, (_, i) =>
|
||
pointsLine(`l${i}`, 4, tile, centre),
|
||
),
|
||
overview: [],
|
||
}),
|
||
});
|
||
coverage.refresh();
|
||
assert.ok(
|
||
await until(
|
||
() => state.coverage.tiles.size === 1 && !state.coverage.pending.size,
|
||
),
|
||
);
|
||
const [tile] = state.coverage.tiles.values();
|
||
assert.equal(tile.pointsDrawn, 100);
|
||
const settle = async () => {
|
||
coverage.refresh();
|
||
await until(() => !state.coverage.pending.size);
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
};
|
||
// +8%: under the threshold, nothing is redrawn.
|
||
tuning.maxPoints = 108;
|
||
await settle();
|
||
assert.equal(tile.pointsDrawn, 100);
|
||
// +8% again: +16% since the tile was last drawn, so it is redrawn.
|
||
tuning.maxPoints = 116;
|
||
await settle();
|
||
assert.equal(tile.pointsDrawn, 116, 'the grown share is drawn');
|
||
});
|