mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix(street-level): KartaView review P2s; budget the gate's pick search
- Viewer: resizes while a sharper photo loads start no second load (the last size is looked at once it lands); decoded bitmaps nobody will draw (an overtaken preview or upgrade) are closed. - Cones: a photo opened while the drive's first page loads is the one they centre on. - A sequence page that lands while a lost WebGL context recovers keeps its cones: no pose is emitted without a renderer, and a viewer error never rejects page data. - Source: an answer turned away unread (HTTP error, 429, length past the cap) aborts its request and cancels its body, freeing the connection. - Gate: findPickPoint no longer picks each centre five times at radius 0, aims step 13's cone pick at the drawn billboards, and caps each evaluation at 4 s, resuming from a cursor on the next poll, so a slow machine cannot spend a whole wait in one evaluation. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e9a4dbb062
commit
04b3231534
7 files changed
+350
-30
No files matched your search
+61
-21
@@ -507,6 +507,7 @@ async function assertKeylessKartaView(page) {
|
|||||||
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
|
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
|
||||||
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
|
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
|
||||||
* or else a grid mid-screen. Only points where the canvas is topmost count.
|
* or else a grid mid-screen. Only points where the canvas is topmost count.
|
||||||
|
* Each call picks for at most `budgetMs`; the next call resumes the search.
|
||||||
* @returns {{x: number, y: number, id: string}|null} client coordinates
|
* @returns {{x: number, y: number, id: string}|null} client coordinates
|
||||||
*/
|
*/
|
||||||
export function findPickPoint({
|
export function findPickPoint({
|
||||||
@@ -515,6 +516,7 @@ export function findPickPoint({
|
|||||||
points,
|
points,
|
||||||
billboardIds,
|
billboardIds,
|
||||||
billboardPrefix = null,
|
billboardPrefix = null,
|
||||||
|
budgetMs = 4000,
|
||||||
}) {
|
}) {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
const scene = viewer.scene;
|
const scene = viewer.scene;
|
||||||
@@ -569,24 +571,54 @@ export function findPickPoint({
|
|||||||
centres.push([at.x, at.y]);
|
centres.push([at.x, at.y]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [cx, cy] of centres)
|
// A pick is a render of the scene (0.5–2 s each under SwiftShader): try
|
||||||
for (let r = 0; r <= 16; r += 2)
|
// the centres the canvas is on top of first, nearest the screen centre,
|
||||||
|
// so the first pick usually hits.
|
||||||
|
const uncovered = ([x, y]) =>
|
||||||
|
document.elementFromPoint(rect.left + x, rect.top + y) === canvas;
|
||||||
|
const fromMiddle = ([x, y]) =>
|
||||||
|
(x - rect.width / 2) ** 2 + (y - rect.height / 2) ** 2;
|
||||||
|
centres.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(uncovered(b)) - Number(uncovered(a)) ||
|
||||||
|
fromMiddle(a) - fromMiddle(b),
|
||||||
|
);
|
||||||
|
const candidates = [];
|
||||||
|
for (const [cx, cy] of centres) {
|
||||||
|
candidates.push([cx, cy]);
|
||||||
|
for (let r = 2; r <= 16; r += 2)
|
||||||
for (const [dx, dy] of [
|
for (const [dx, dy] of [
|
||||||
[0, 0],
|
|
||||||
[r, 0],
|
[r, 0],
|
||||||
[-r, 0],
|
[-r, 0],
|
||||||
[0, r],
|
[0, r],
|
||||||
[0, -r],
|
[0, -r],
|
||||||
]) {
|
])
|
||||||
const hit = tryAt(cx + dx, cy + dy);
|
candidates.push([cx + dx, cy + dy]);
|
||||||
if (hit) return hit;
|
}
|
||||||
}
|
// Aimed at known places: never fall back to a scan of the whole screen.
|
||||||
if (points || billboardIds) return null;
|
if (!points && !billboardIds && billboardPrefix === null)
|
||||||
for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18)
|
for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18)
|
||||||
for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) {
|
for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6)
|
||||||
const hit = tryAt(x, y);
|
candidates.push([x, y]);
|
||||||
if (hit) return hit;
|
// One evaluation stays well inside the caller's wait: past the budget it
|
||||||
|
// returns null, and the next poll carries on from where this one stopped.
|
||||||
|
const key = JSON.stringify([prefix, id, billboardPrefix]);
|
||||||
|
const cursors = (window.__qaPickCursors ??= {});
|
||||||
|
const deadline = performance.now() + budgetMs;
|
||||||
|
let at = cursors[key] ?? 0;
|
||||||
|
if (at >= candidates.length) at = 0;
|
||||||
|
for (; at < candidates.length; at++) {
|
||||||
|
const hit = tryAt(...candidates[at]);
|
||||||
|
if (hit) {
|
||||||
|
delete cursors[key];
|
||||||
|
return hit;
|
||||||
}
|
}
|
||||||
|
if (performance.now() > deadline) {
|
||||||
|
cursors[key] = at + 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete cursors[key];
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1542,22 +1574,28 @@ async function main() {
|
|||||||
);
|
);
|
||||||
if (fixtures)
|
if (fixtures)
|
||||||
assert.equal(sequence.images, fixture.images.length, 'every photo');
|
assert.equal(sequence.images, fixture.images.length, 'every photo');
|
||||||
// The cones are on the globe: one of them picks as an image.
|
// The cones are on the globe: one of them picks as an image. Aim at
|
||||||
|
// where each cone billboard is drawn (its ground position at
|
||||||
|
// height 0 can sit a few pixels off, and every missed pick costs a
|
||||||
|
// render); the same 30 s as the line pick above.
|
||||||
const cone = await page.waitForFunction(
|
const cone = await page.waitForFunction(
|
||||||
findPickPoint,
|
findPickPoint,
|
||||||
{ timeout: 15_000, polling: 250 },
|
{ timeout: 30_000, polling: 250 },
|
||||||
{
|
{
|
||||||
prefix: 'mly:img:',
|
prefix: 'mly:img:',
|
||||||
id: null,
|
id: null,
|
||||||
points: fixtures
|
points: null,
|
||||||
? fixture.images.map((image) => [image.lon, image.lat])
|
|
||||||
: null,
|
|
||||||
billboardIds: null,
|
billboardIds: null,
|
||||||
// Live: the cones' positions are only known from the globe.
|
billboardPrefix: 'mly:img:',
|
||||||
billboardPrefix: fixtures ? null : 'mly:img:',
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.ok(await cone.jsonValue(), 'an image cone is pickable');
|
const picked = await cone.jsonValue();
|
||||||
|
assert.ok(picked, 'an image cone is pickable');
|
||||||
|
if (fixtures)
|
||||||
|
assert.ok(
|
||||||
|
fixture.images.some((image) => image.id === picked.id),
|
||||||
|
`the cone is a photo of the drive: ${picked.id}`,
|
||||||
|
);
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.__godsEyeView.dataManager.layers
|
window.__godsEyeView.dataManager.layers
|
||||||
.get('street-level')
|
.get('street-level')
|
||||||
@@ -1685,7 +1723,9 @@ async function main() {
|
|||||||
assert.ok(neighbour, 'a neighbour of the other camera type');
|
assert.ok(neighbour, 'a neighbour of the other camera type');
|
||||||
const handle = await page.waitForFunction(
|
const handle = await page.waitForFunction(
|
||||||
findPickPoint,
|
findPickPoint,
|
||||||
{ timeout: 15_000, polling: 250 },
|
// A pick is a render; under SwiftShader on a busy machine one
|
||||||
|
// evaluation can take seconds.
|
||||||
|
{ timeout: 30_000, polling: 250 },
|
||||||
{
|
{
|
||||||
prefix: 'mly:img:',
|
prefix: 'mly:img:',
|
||||||
id: neighbour.id,
|
id: neighbour.id,
|
||||||
|
|||||||
@@ -206,8 +206,13 @@ export function createKartaViewProvider({
|
|||||||
),
|
),
|
||||||
metresBetween,
|
metresBetween,
|
||||||
);
|
);
|
||||||
// The open photo may have just learnt which way it looked.
|
// The open photo may have just learnt which way it looked. The
|
||||||
viewer.refreshPose?.();
|
// page is good whatever the viewer is doing.
|
||||||
|
try {
|
||||||
|
viewer.refreshPose?.();
|
||||||
|
} catch {
|
||||||
|
/* the next pose carries the direction */
|
||||||
|
}
|
||||||
return { page, hasMore, photos: list };
|
return { page, hasMore, photos: list };
|
||||||
});
|
});
|
||||||
pages.set(key, entry);
|
pages.set(key, entry);
|
||||||
@@ -362,10 +367,17 @@ export function createKartaViewProvider({
|
|||||||
const cones = createSequenceCones({
|
const cones = createSequenceCones({
|
||||||
state,
|
state,
|
||||||
async loadImages(sequenceId, { signal }) {
|
async loadImages(sequenceId, { signal }) {
|
||||||
const anchor = anchors.get(sequenceId);
|
let anchor = anchors.get(sequenceId);
|
||||||
if (!anchor) return [];
|
if (!anchor) return [];
|
||||||
const window = await windowOf(anchor);
|
let window = await windowOf(anchor);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
// Another photo of the drive opened while that page loaded (its
|
||||||
|
// re-centre found nothing drawn yet): centre on the newest one.
|
||||||
|
while (anchors.get(sequenceId) !== anchor) {
|
||||||
|
anchor = anchors.get(sequenceId);
|
||||||
|
window = await windowOf(anchor);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
}
|
||||||
drawnPage = { sequenceId, page: window.home };
|
drawnPage = { sequenceId, page: window.home };
|
||||||
countPhotos(sequenceId);
|
countPhotos(sequenceId);
|
||||||
return thinImages(
|
return thinImages(
|
||||||
|
|||||||
@@ -859,3 +859,104 @@ test('the marker is KartaView’s own cone, and it points along the drive, not a
|
|||||||
`then along the drive: ${bearings}`,
|
`then along the drive: ${bearings}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Hold `seq`'s page `page` until the returned function is called. */
|
||||||
|
function holdPage(source, seq, page) {
|
||||||
|
const getPage = source.getSequencePhotos;
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => (release = resolve));
|
||||||
|
source.getSequencePhotos = async (id, options) => {
|
||||||
|
if (id === seq && options.page === page) await gate;
|
||||||
|
return getPage(id, options);
|
||||||
|
};
|
||||||
|
return () => release();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a photo opened while the cones first load is the one they centre on', async (t) => {
|
||||||
|
const size = SEQUENCE_PAGE_SIZE;
|
||||||
|
const env = await mounted({
|
||||||
|
sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) },
|
||||||
|
});
|
||||||
|
const cones = await withCones(t, env);
|
||||||
|
const release = holdPage(env.source, 's', 1);
|
||||||
|
await env.instance.viewer.open('s-10');
|
||||||
|
const selected = env.instance.selectSequence('s');
|
||||||
|
// A line click further along the drive while page 1 is still loading:
|
||||||
|
// nothing is drawn yet for its re-centre to move.
|
||||||
|
await env.instance.viewer.open(`s-${size * 4 + 10}`);
|
||||||
|
release();
|
||||||
|
await selected;
|
||||||
|
assert.ok(
|
||||||
|
await until(
|
||||||
|
() => !env.instance.sequenceStats().loading && cones.count() > 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
cones()[0] <= size * 4 + 10 && cones()[1] >= size * 4 + 10,
|
||||||
|
`around the newer photo, not the first: ${cones()}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sequence page that lands while a lost GPU context recovers still draws its cones', async (t) => {
|
||||||
|
const env = setup({ sequences: { p: [0, 1, 2, 3] } });
|
||||||
|
const sphere = (record) => ({
|
||||||
|
...record,
|
||||||
|
projection: 'SPHERE',
|
||||||
|
fieldOfView: '360',
|
||||||
|
filepathProc: `2022/10/6/wrapped_proc/${record.id}.jpg`,
|
||||||
|
});
|
||||||
|
const getPhoto = env.source.getPhoto;
|
||||||
|
env.source.getPhoto = async (id) => sphere(await getPhoto(id));
|
||||||
|
const getPage = env.source.getSequencePhotos;
|
||||||
|
env.source.getSequencePhotos = async (...args) => {
|
||||||
|
const page = await getPage(...args);
|
||||||
|
return { ...page, photos: page.photos.map(sphere) };
|
||||||
|
};
|
||||||
|
const release = holdPage(env.source, 'p', 1);
|
||||||
|
let holdImages = false;
|
||||||
|
let releaseImage = null;
|
||||||
|
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||||
|
const definition = createKartaViewProvider({
|
||||||
|
source: env.source,
|
||||||
|
viewerOptions: {
|
||||||
|
load: async (url) => {
|
||||||
|
if (holdImages) await new Promise((r) => (releaseImage = r));
|
||||||
|
return { src: url };
|
||||||
|
},
|
||||||
|
createRenderer: () => ({
|
||||||
|
setImage() {},
|
||||||
|
setView: (next) => Object.assign(view, next),
|
||||||
|
getView: () => ({ ...view }),
|
||||||
|
resize() {},
|
||||||
|
clear() {},
|
||||||
|
destroy() {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { context, actions } = fakeContext();
|
||||||
|
const errors = [];
|
||||||
|
context.actions.reportError = (message) => errors.push(message);
|
||||||
|
const instance = definition.create(context);
|
||||||
|
actions.instance = instance;
|
||||||
|
const cones = await withCones(t, { instance });
|
||||||
|
const host = fakeHost();
|
||||||
|
await instance.viewer.mount(host);
|
||||||
|
await instance.viewer.open('p-0');
|
||||||
|
// › to the next panorama; the GPU resets while it loads.
|
||||||
|
holdImages = true;
|
||||||
|
const opening = instance.viewer.open('p-1');
|
||||||
|
assert.ok(await until(() => releaseImage !== null));
|
||||||
|
host.children[0].children[1].fire('webglcontextlost');
|
||||||
|
const selected = instance.selectSequence('p');
|
||||||
|
release();
|
||||||
|
await selected;
|
||||||
|
assert.ok(
|
||||||
|
await until(() => !instance.sequenceStats().loading && cones.count() > 0),
|
||||||
|
'the page is drawn, not dropped',
|
||||||
|
);
|
||||||
|
releaseImage();
|
||||||
|
await opening;
|
||||||
|
assert.equal(host.children[0].dataset.kind, 'pano', 'p-1 shows');
|
||||||
|
// reportError(null) clears; nothing was reported.
|
||||||
|
assert.deepEqual(errors.filter(Boolean), []);
|
||||||
|
});
|
||||||
@@ -145,8 +145,10 @@ export function createKartaViewSource({
|
|||||||
),
|
),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
let response = null;
|
||||||
|
let read = false;
|
||||||
try {
|
try {
|
||||||
const response = await fetchImpl(url, { signal: controller.signal });
|
response = await fetchImpl(url, { signal: controller.signal });
|
||||||
if (response.status === 429)
|
if (response.status === 429)
|
||||||
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
|
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
|
||||||
status: 429,
|
status: 429,
|
||||||
@@ -166,6 +168,7 @@ export function createKartaViewSource({
|
|||||||
// KartaView answers chunked, without a length: count while reading.
|
// KartaView answers chunked, without a length: count while reading.
|
||||||
const reader = response.body?.getReader?.();
|
const reader = response.body?.getReader?.();
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
|
read = true;
|
||||||
try {
|
try {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -175,6 +178,7 @@ export function createKartaViewSource({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
read = true;
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -209,6 +213,13 @@ export function createKartaViewSource({
|
|||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
signal?.removeEventListener('abort', onAbort);
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
// An answer turned away unread (429, an error, too long) still holds
|
||||||
|
// its connection, one of the six the browser has: let it go.
|
||||||
|
if (response && !read) {
|
||||||
|
controller.abort();
|
||||||
|
if (response.body && !response.body.locked)
|
||||||
|
response.body.cancel?.().catch?.(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -595,3 +595,62 @@ test('a sequence page gets the full 30 s on its first try, then 30 s once more',
|
|||||||
assert.match(String(outcome?.error?.message), /did not answer in time/);
|
assert.match(String(outcome?.error?.message), /did not answer in time/);
|
||||||
assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS);
|
assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an answer turned away unread (an error, a 429, a length past the cap) lets its connection go', async () => {
|
||||||
|
/** A response whose body is never read: records its cancel and the abort. */
|
||||||
|
const unread = (answer) => {
|
||||||
|
const seen = { cancelled: false, signal: null };
|
||||||
|
const fetchImpl = async (url, { signal }) => {
|
||||||
|
seen.signal = signal;
|
||||||
|
return {
|
||||||
|
...answer,
|
||||||
|
body: {
|
||||||
|
locked: false,
|
||||||
|
getReader: () => {
|
||||||
|
throw new Error('not read');
|
||||||
|
},
|
||||||
|
cancel: async () => {
|
||||||
|
seen.cancelled = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return { seen, fetchImpl };
|
||||||
|
};
|
||||||
|
const cases = [
|
||||||
|
[{ ok: false, status: 503, headers: new Map() }, /HTTP 503/],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Map([['content-length', String(64 * 1024 * 1024)]]),
|
||||||
|
},
|
||||||
|
/too much data/,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [answer, message] of cases) {
|
||||||
|
const { seen, fetchImpl } = unread(answer);
|
||||||
|
const source = createKartaViewSource({ fetchImpl });
|
||||||
|
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), message);
|
||||||
|
assert.equal(seen.signal.aborted, true, `${message}: request aborted`);
|
||||||
|
assert.equal(seen.cancelled, true, `${message}: body cancelled`);
|
||||||
|
}
|
||||||
|
// A 429 is let go before its retry is asked.
|
||||||
|
const { seen, fetchImpl } = unread({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
headers: new Map([['retry-after', '0']]),
|
||||||
|
});
|
||||||
|
const signals = [];
|
||||||
|
const source = createKartaViewSource({
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
signals.push(init.signal);
|
||||||
|
if (signals.length === 1) return fetchImpl(url, init);
|
||||||
|
return ok(photoPayload([]));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await source.getPhoto('1');
|
||||||
|
assert.equal(signals[0].aborted, true);
|
||||||
|
assert.equal(seen.cancelled, true);
|
||||||
|
assert.equal(signals[1].aborted, false, 'a read answer is left alone');
|
||||||
|
});
|
||||||
@@ -73,6 +73,11 @@ async function loadBitmap(url, timeoutMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Free a decoded bitmap nobody will draw (an <img> has nothing to free). */
|
||||||
|
function release(image) {
|
||||||
|
image?.close?.();
|
||||||
|
}
|
||||||
|
|
||||||
/** 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);
|
||||||
@@ -130,6 +135,10 @@ export function createKartaViewViewer({
|
|||||||
let panoWidth = 0;
|
let panoWidth = 0;
|
||||||
/** The flat photo on screen is the 2,560 px version. */
|
/** The flat photo on screen is the 2,560 px version. */
|
||||||
let flatSharp = false;
|
let flatSharp = false;
|
||||||
|
/** The sharper version loading ({seq, kind}): a resize starts no second one. */
|
||||||
|
let upgrading = null;
|
||||||
|
/** A resize came while it loaded: look again once it lands. */
|
||||||
|
let upgradeAgain = false;
|
||||||
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
||||||
let openingId = null;
|
let openingId = null;
|
||||||
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
||||||
@@ -143,6 +152,9 @@ export function createKartaViewViewer({
|
|||||||
|
|
||||||
function emit() {
|
function emit() {
|
||||||
if (!photo || !showing) return;
|
if (!photo || !showing) return;
|
||||||
|
// A lost GPU context, still being recovered: the open that redraws the
|
||||||
|
// panorama poses once it is on screen.
|
||||||
|
if (showing === 'pano' && !renderer) return;
|
||||||
const view = showing === 'pano' ? renderer.getView() : null;
|
const view = showing === 'pano' ? renderer.getView() : null;
|
||||||
const heading = cameraBearing(photo);
|
const heading = cameraBearing(photo);
|
||||||
const pose = {
|
const pose = {
|
||||||
@@ -485,20 +497,40 @@ export function createKartaViewViewer({
|
|||||||
? resizedImageUrl(target.urls.pano, size)
|
? resizedImageUrl(target.urls.pano, size)
|
||||||
: resizedImageUrl(target.urls.full, size);
|
: resizedImageUrl(target.urls.full, size);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
// Resizes come in bursts while the panel animates: one load at a time
|
||||||
|
// for this photo, and one more look once it lands.
|
||||||
|
if (upgrading?.seq === seq && upgrading.kind === kind) {
|
||||||
|
upgradeAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pending = (upgrading = { seq, kind });
|
||||||
|
upgradeAgain = false;
|
||||||
|
let image = null;
|
||||||
try {
|
try {
|
||||||
const image = await load(url, { crossOrigin: kind === 'pano' });
|
image = await load(url, { crossOrigin: kind === 'pano' });
|
||||||
if (seq !== openSeq || photo !== target || showing !== kind) return;
|
if (seq !== openSeq || photo !== target || showing !== kind) return;
|
||||||
if (kind === 'pano') {
|
if (kind === 'pano') {
|
||||||
if (!renderer || size[0] <= panoWidth) return;
|
if (!renderer || size[0] <= panoWidth) return;
|
||||||
renderer.setImage(image);
|
renderer.setImage(image);
|
||||||
|
image = null;
|
||||||
panoWidth = size[0];
|
panoWidth = size[0];
|
||||||
} else {
|
} else {
|
||||||
picture.src = image.src;
|
picture.src = image.src;
|
||||||
|
image = null;
|
||||||
flatSharp = true;
|
flatSharp = true;
|
||||||
}
|
}
|
||||||
requestRender();
|
requestRender();
|
||||||
} catch {
|
} catch {
|
||||||
/* the first version stays */
|
/* the first version stays */
|
||||||
|
} finally {
|
||||||
|
release(image);
|
||||||
|
if (upgrading === pending) {
|
||||||
|
upgrading = null;
|
||||||
|
if (upgradeAgain && seq === openSeq && photo === target) {
|
||||||
|
upgradeAgain = false;
|
||||||
|
upgrade(target, seq, showing);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,10 +556,16 @@ export function createKartaViewViewer({
|
|||||||
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||||
{ crossOrigin: true },
|
{ crossOrigin: true },
|
||||||
);
|
);
|
||||||
if (seq !== openSeq) return false;
|
if (seq !== openSeq) {
|
||||||
|
release(image);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// The context may have been lost while this loaded: build on the new
|
// The context may have been lost while this loaded: build on the new
|
||||||
// canvas, or let the caller show the front view.
|
// canvas, or let the caller show the front view.
|
||||||
if (!ensureRenderer()) throw new Error(LOAD_FAILED);
|
if (!ensureRenderer()) {
|
||||||
|
release(image);
|
||||||
|
throw new Error(LOAD_FAILED);
|
||||||
|
}
|
||||||
const restore = restoreView?.id === target.id ? restoreView.view : null;
|
const restore = restoreView?.id === target.id ? restoreView.view : null;
|
||||||
restoreView = null;
|
restoreView = null;
|
||||||
if (restore) renderer.setView(restore);
|
if (restore) renderer.setView(restore);
|
||||||
|
|||||||
@@ -105,8 +105,11 @@ function setup({
|
|||||||
rendererThrows = false,
|
rendererThrows = false,
|
||||||
steps = {},
|
steps = {},
|
||||||
failSteps = false,
|
failSteps = false,
|
||||||
|
hold = () => false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const loads = [];
|
const loads = [];
|
||||||
|
const held = [];
|
||||||
|
const closed = [];
|
||||||
const renderers = [];
|
const renderers = [];
|
||||||
const stepped = [];
|
const stepped = [];
|
||||||
const asked = [];
|
const asked = [];
|
||||||
@@ -130,7 +133,10 @@ function setup({
|
|||||||
loads.push({ url, crossOrigin: options?.crossOrigin });
|
loads.push({ url, crossOrigin: options?.crossOrigin });
|
||||||
if (failLoads.some((part) => url.includes(part)))
|
if (failLoads.some((part) => url.includes(part)))
|
||||||
throw new Error('The KartaView photo could not be loaded');
|
throw new Error('The KartaView photo could not be loaded');
|
||||||
return { src: url };
|
// A decoded bitmap: close() frees it.
|
||||||
|
const image = { src: url, close: () => closed.push(url) };
|
||||||
|
if (!hold(url)) return image;
|
||||||
|
return new Promise((resolve) => held.push(() => resolve(image)));
|
||||||
},
|
},
|
||||||
createRenderer: (canvas) => {
|
createRenderer: (canvas) => {
|
||||||
attempts.renderer++;
|
attempts.renderer++;
|
||||||
@@ -152,6 +158,8 @@ function setup({
|
|||||||
stepped,
|
stepped,
|
||||||
asked,
|
asked,
|
||||||
attempts,
|
attempts,
|
||||||
|
held,
|
||||||
|
closed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,3 +931,54 @@ test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px
|
|||||||
for (let i = 0; i < 4; i++) await settle();
|
for (let i = 0; i < 4; i++) await settle();
|
||||||
assert.equal(loads.length, 2, 'once only');
|
assert.equal(loads.length, 2, 'once only');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resizes while a sharper panorama loads start no second load; the last size wins once it lands', async () => {
|
||||||
|
let first = true;
|
||||||
|
const { viewer, host, loads, renderers, held } = setup({
|
||||||
|
hold: (url) => {
|
||||||
|
const holdIt = first && url.includes('rs:fit:2048');
|
||||||
|
if (holdIt) first = false;
|
||||||
|
return holdIt;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await viewer.mount(host);
|
||||||
|
await viewer.open('pano1');
|
||||||
|
assert.ok(await until(() => held.length === 1));
|
||||||
|
// The panel animates open: a burst of resizes while 2,048 px loads.
|
||||||
|
parts(host).canvas.clientWidth = 1400;
|
||||||
|
for (let i = 0; i < 5; i++) viewer.resize();
|
||||||
|
for (let i = 0; i < 4; i++) await settle();
|
||||||
|
assert.equal(loads.length, 2, 'the preview and the one 2,048 px load');
|
||||||
|
held[0]();
|
||||||
|
assert.ok(await until(() => renderers[0].images.length === 3));
|
||||||
|
assert.match(renderers[0].images[1], /rs:fit:2048:1024/);
|
||||||
|
assert.match(renderers[0].images[2], /rs:fit:4096:2048/);
|
||||||
|
for (let i = 0; i < 4; i++) await settle();
|
||||||
|
assert.equal(loads.length, 3, 'one more load, for the size asked last');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a preview or sharper version nobody will draw is freed, not left decoded', async () => {
|
||||||
|
const { viewer, host, renderers, held, closed } = setup({
|
||||||
|
hold: (url) => /rs:fit:(1024|2048):/.test(url),
|
||||||
|
});
|
||||||
|
await viewer.mount(host);
|
||||||
|
// pano1's preview is still loading when flat1 opens over it.
|
||||||
|
const overtaken = viewer.open('pano1');
|
||||||
|
assert.ok(await until(() => held.length === 1));
|
||||||
|
await viewer.open('flat1');
|
||||||
|
held[0]();
|
||||||
|
await overtaken;
|
||||||
|
assert.deepEqual(closed.length, 1);
|
||||||
|
assert.match(closed[0], /rs:fit:1024/, 'the overtaken preview');
|
||||||
|
// pano1 shows; its sharp version lands after flat1 replaced it.
|
||||||
|
const shown = viewer.open('pano1');
|
||||||
|
assert.ok(await until(() => held.length === 2));
|
||||||
|
held[1]();
|
||||||
|
await shown;
|
||||||
|
assert.ok(await until(() => held.length === 3));
|
||||||
|
await viewer.open('flat1');
|
||||||
|
held[2]();
|
||||||
|
assert.ok(await until(() => closed.length === 2));
|
||||||
|
assert.match(closed[1], /rs:fit:2048/, 'the overtaken sharp version');
|
||||||
|
assert.equal(renderers[0].images.length, 1, 'only the preview was drawn');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user