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:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 15:52:21 +01:00
commit da0d370fa3
43 files changed
+3299 -178

No files matched your search

+189 -25
View File
@@ -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}`,