mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge upstream/main (KartaView #1121 merged) into pr/panoramax
KartaView's later viewer changes are carried into the shared photoViewer.js: the camera bearing (`bearingOf`), redrawing the same photo after a GPU reset through `onReopen`, no pose while a lost context recovers, the 2,560 px flat version only once the viewer outgrows 1,280 px, `refreshPose`, and one sharper-panorama load at a time with a second look once it lands. Its tile cache's `keyOf` and `clear` move to the shared tileCache.js. KartaView gets `onEscape` beside `onReopen`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
da0d370fa3
43 files changed
+3299
-178
No files matched your search
+189
-25
@@ -487,10 +487,50 @@ async function assertKeylessGate(page) {
|
||||
assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/);
|
||||
}
|
||||
|
||||
/**
|
||||
* With no Mapillary key the panel points at KartaView, which needs none:
|
||||
* lit, it draws coverage and the layer is not key-gated.
|
||||
*/
|
||||
async function assertKeylessKartaView(page) {
|
||||
assert.match(
|
||||
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
|
||||
/light KARTAVIEW/,
|
||||
'KEY REQUIRED points at KartaView',
|
||||
);
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await page.evaluate((at) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(at.lon, at.lat, at.height),
|
||||
),
|
||||
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||||
});
|
||||
}, PARK);
|
||||
await sleep(1000);
|
||||
}
|
||||
await clickPanelControl(
|
||||
page,
|
||||
'#sl-provider-chips [data-chip-id="kartaview"]',
|
||||
);
|
||||
await waitForCoverage(page);
|
||||
const state = await page.evaluate(() => ({
|
||||
status: document.getElementById('sl-status').textContent,
|
||||
keyRequired: window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState().keyRequired,
|
||||
}));
|
||||
assert.equal(state.keyRequired, false);
|
||||
assert.doesNotMatch(state.status, /KEY/);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* 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
|
||||
*/
|
||||
export function findPickPoint({
|
||||
@@ -499,6 +539,7 @@ export function findPickPoint({
|
||||
points,
|
||||
billboardIds,
|
||||
billboardPrefix = null,
|
||||
budgetMs = 4000,
|
||||
}) {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
@@ -553,24 +594,54 @@ export function findPickPoint({
|
||||
centres.push([at.x, at.y]);
|
||||
}
|
||||
}
|
||||
for (const [cx, cy] of centres)
|
||||
for (let r = 0; r <= 16; r += 2)
|
||||
// A pick is a render of the scene (0.5–2 s each under SwiftShader): try
|
||||
// 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 [
|
||||
[0, 0],
|
||||
[r, 0],
|
||||
[-r, 0],
|
||||
[0, r],
|
||||
[0, -r],
|
||||
]) {
|
||||
const hit = tryAt(cx + dx, cy + dy);
|
||||
if (hit) return hit;
|
||||
}
|
||||
if (points || billboardIds) return null;
|
||||
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) {
|
||||
const hit = tryAt(x, y);
|
||||
if (hit) return hit;
|
||||
])
|
||||
candidates.push([cx + dx, cy + dy]);
|
||||
}
|
||||
// Aimed at known places: never fall back to a scan of the whole screen.
|
||||
if (!points && !billboardIds && billboardPrefix === null)
|
||||
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)
|
||||
candidates.push([x, y]);
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -840,14 +911,22 @@ async function main() {
|
||||
await sleep(1000);
|
||||
}
|
||||
await step(
|
||||
'KartaView: its chip draws keyless coverage, credits KartaView and hides the filters',
|
||||
'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date',
|
||||
async () => {
|
||||
await clickPanelControl(page, kvChip);
|
||||
assert.ok(await lit('kartaview'), 'KartaView alone is lit');
|
||||
await waitForCoverage(page);
|
||||
const u = await ui();
|
||||
assert.equal(u.keyRequired, false);
|
||||
assert.deepEqual(u.filterControls, { pano: false, since: false });
|
||||
assert.deepEqual(u.filterControls, { pano: true, since: true });
|
||||
// KartaView's SINCE is its upload date: the panel says so.
|
||||
assert.equal(u.sinceNote, 'KartaView: by upload date');
|
||||
assert.equal(
|
||||
await page.$eval('#sl-since-note', (node) =>
|
||||
node.hidden ? null : node.textContent,
|
||||
),
|
||||
'KartaView: by upload date',
|
||||
);
|
||||
assert.equal(
|
||||
await page.$eval(kvChip, (chip) =>
|
||||
chip.style.getPropertyValue('--chip-color'),
|
||||
@@ -876,6 +955,72 @@ async function main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back',
|
||||
async () => {
|
||||
const before = (await ui()).coverage.count;
|
||||
const setStop = (index) =>
|
||||
page.$eval(
|
||||
'#sl-since',
|
||||
(input, value) => {
|
||||
input.value = String(value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
index,
|
||||
);
|
||||
// Filtered tiles are drawn by KartaView on demand: give them time.
|
||||
const settled = async (test, arg, what) =>
|
||||
assert.ok(
|
||||
await uiUntil(test, arg, { timeout: 60_000 }),
|
||||
`${what}: ${JSON.stringify((await ui()).coverage)}`,
|
||||
);
|
||||
await clickPanelControl(page, '[data-sl-pano="pano"]');
|
||||
await settled(
|
||||
(u) => u.filter.pano === 'pano' && !u.coverage.loading,
|
||||
null,
|
||||
'360° drawn',
|
||||
);
|
||||
const panos = (await ui()).coverage.count;
|
||||
assert.ok(panos <= before, `${panos} ≤ ${before}`);
|
||||
// Fixtures: the old street has only flat photos, the drive 360° too.
|
||||
if (fixtures)
|
||||
assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`);
|
||||
await clickPanelControl(page, '[data-sl-pano="all"]');
|
||||
await settled(
|
||||
(u, n) =>
|
||||
u.filter.pano === 'all' &&
|
||||
!u.coverage.loading &&
|
||||
u.coverage.count === n,
|
||||
before,
|
||||
'every line again',
|
||||
);
|
||||
await setStop(5);
|
||||
await settled(
|
||||
(u) => u.filter.sinceDays === 365 && !u.coverage.loading,
|
||||
null,
|
||||
'LAST YEAR drawn',
|
||||
);
|
||||
const lastYear = (await ui()).coverage.count;
|
||||
assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`);
|
||||
// Fixtures: the drive is a month old, the old street from 2019.
|
||||
if (fixtures)
|
||||
assert.ok(
|
||||
lastYear > 0 && lastYear < before,
|
||||
`0 < ${lastYear} < ${before}`,
|
||||
);
|
||||
await setStop(0);
|
||||
await settled(
|
||||
(u, n) =>
|
||||
u.filter.sinceDays === 0 &&
|
||||
!u.coverage.loading &&
|
||||
u.coverage.count === n,
|
||||
before,
|
||||
'ANY DATE: every line again',
|
||||
);
|
||||
assert.equal((await ui()).coverage.error, null);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo',
|
||||
async ({ skip }) => {
|
||||
@@ -965,6 +1110,15 @@ async function main() {
|
||||
),
|
||||
`cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`,
|
||||
);
|
||||
// The drive runs north; the photos' compass says 200°.
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u) =>
|
||||
Number.isFinite(u.street.bearing) &&
|
||||
Math.min(u.street.bearing, 360 - u.street.bearing) < 2,
|
||||
),
|
||||
`the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`,
|
||||
);
|
||||
}
|
||||
const before = street.imageId;
|
||||
await clickPanelControl(page, '.sl-photo-step-next');
|
||||
@@ -1833,9 +1987,10 @@ async function main() {
|
||||
((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south),
|
||||
]);
|
||||
// Ground primitives are built over a few frames: poll for a pick.
|
||||
// A loaded machine renders a frame in seconds: 60 s, not 30.
|
||||
const handle = await page.waitForFunction(
|
||||
findPickPoint,
|
||||
{ timeout: 30_000, polling: 250 },
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
{
|
||||
prefix: 'mly:seq:',
|
||||
id: target,
|
||||
@@ -1860,22 +2015,28 @@ async function main() {
|
||||
);
|
||||
if (fixtures)
|
||||
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 60 s as the line pick above.
|
||||
const cone = await page.waitForFunction(
|
||||
findPickPoint,
|
||||
{ timeout: 15_000, polling: 250 },
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
{
|
||||
prefix: 'mly:img:',
|
||||
id: null,
|
||||
points: fixtures
|
||||
? fixture.images.map((image) => [image.lon, image.lat])
|
||||
: null,
|
||||
points: null,
|
||||
billboardIds: null,
|
||||
// Live: the cones' positions are only known from the globe.
|
||||
billboardPrefix: fixtures ? null : 'mly:img:',
|
||||
billboardPrefix: '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(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
@@ -2003,7 +2164,9 @@ async function main() {
|
||||
assert.ok(neighbour, 'a neighbour of the other camera type');
|
||||
const handle = await page.waitForFunction(
|
||||
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:',
|
||||
id: neighbour.id,
|
||||
@@ -2811,7 +2974,7 @@ async function main() {
|
||||
);
|
||||
if (fixtures)
|
||||
await step(
|
||||
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED',
|
||||
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED, and KartaView draws there',
|
||||
async () => {
|
||||
const keyless = await browser.newPage();
|
||||
await keyless.setViewport(VIEWPORTS[0]);
|
||||
@@ -2820,6 +2983,7 @@ async function main() {
|
||||
await boot(keyless, url);
|
||||
await expandStrip(keyless);
|
||||
await assertKeylessGate(keyless);
|
||||
await assertKeylessKartaView(keyless);
|
||||
renderErrorsSeen.push(
|
||||
...(await readRenderErrors(keyless)).map(
|
||||
(text) => `[keyless] scene.renderError: ${text}`,
|
||||
|
||||
Reference in new issue
Block a user