mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44: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
@@ -1,3 +1,4 @@
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
|
||||
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||
|
||||
@@ -11,6 +12,10 @@ import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||
* so the nearest photo opened is even and › must skip the one after it. The
|
||||
* first nearest-photo lookup is rate-limited (429, Retry-After: 0), as
|
||||
* KartaView does under load.
|
||||
*
|
||||
* Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the
|
||||
* imagery filter drops it. KartaView filters coverage only in its PNG
|
||||
* tiles, so filtered PNG tiles are drawn here for the streets that pass.
|
||||
*/
|
||||
|
||||
export const KV_SEQUENCE_ID = '900001';
|
||||
@@ -19,9 +24,20 @@ export const KV_LINE = Object.freeze({
|
||||
south: 38.5795,
|
||||
north: 38.5875,
|
||||
});
|
||||
/** A street ~130 m east of the drive with only flat 2019 photos. */
|
||||
export const KV_OLD_STREET = Object.freeze({
|
||||
lon: -121.4916,
|
||||
south: 38.58,
|
||||
north: 38.587,
|
||||
});
|
||||
const OLD_STREET_UPLOADED = '2019-06-01';
|
||||
const KV_SPACING_M = 25;
|
||||
const METRES_PER_DEG_LAT = 110_540;
|
||||
const SHOT = '2024-05-02 16:20:00.000';
|
||||
/** KartaView's time format for `ms`. */
|
||||
const kartaViewTime = (ms) =>
|
||||
new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */
|
||||
const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000);
|
||||
|
||||
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
|
||||
|
||||
@@ -66,7 +82,8 @@ function record(photo) {
|
||||
lng: String(photo.lon),
|
||||
matchLat: String(photo.lat),
|
||||
matchLng: String(photo.lon),
|
||||
heading: '0',
|
||||
// A dashcam phone's compass, disturbed by the car: the drive runs north.
|
||||
heading: '200',
|
||||
fieldOfView: photo.isPano ? '360' : '0',
|
||||
projection: photo.isPano ? 'SPHERE' : 'PLANE',
|
||||
shotDate: SHOT,
|
||||
@@ -94,29 +111,126 @@ const raw = (status, body) => ({
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/** The drive's track inside tile `z/x/y` (with a small margin), as KartaView's JSON tile. */
|
||||
function tileLines(z, x, y) {
|
||||
const { west, east, south, north } = tileBounds(x, y, z);
|
||||
if (KV_LINE.lon < west || KV_LINE.lon > east) return [];
|
||||
const from = Math.max(KV_LINE.south, south);
|
||||
const to = Math.min(KV_LINE.north, north);
|
||||
if (from >= to) return [];
|
||||
// Two segments meeting end to start, as KartaView splits ways.
|
||||
const mid = (from + to) / 2;
|
||||
const at = (lat) => ({ lat: String(lat), lng: String(KV_LINE.lon) });
|
||||
/** The streets with photos: the drive, and the old street beside it. */
|
||||
function streets() {
|
||||
return [
|
||||
{ track: [at(from), at(mid)], matched: '1' },
|
||||
{ track: [at(mid), at(to)], matched: '1' },
|
||||
{ line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] },
|
||||
{ line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] },
|
||||
];
|
||||
}
|
||||
|
||||
/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */
|
||||
function stretch(line, z, x, y) {
|
||||
const { west, east, south, north } = tileBounds(x, y, z);
|
||||
if (line.lon < west || line.lon > east) return null;
|
||||
const from = Math.max(line.south, south);
|
||||
const to = Math.min(line.north, north);
|
||||
return from < to ? [from, to] : null;
|
||||
}
|
||||
|
||||
/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */
|
||||
function tileLines(z, x, y) {
|
||||
const lines = [];
|
||||
for (const { line } of streets()) {
|
||||
const inside = stretch(line, z, x, y);
|
||||
if (!inside) continue;
|
||||
const [from, to] = inside;
|
||||
// Two segments meeting end to start, as KartaView splits ways.
|
||||
const mid = (from + to) / 2;
|
||||
const at = (lat) => ({ lat: String(lat), lng: String(line.lon) });
|
||||
lines.push(
|
||||
{ track: [at(from), at(mid)], matched: '1' },
|
||||
{ track: [at(mid), at(to)], matched: '1' },
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Whether a street has photos passing a filtered tile's query. */
|
||||
function passesTileQuery(street, query) {
|
||||
const view = query.get('fieldOfView');
|
||||
if (view && !street.views.includes(view)) return false;
|
||||
const since = query.get('startDate');
|
||||
return !since || street.uploaded >= since;
|
||||
}
|
||||
|
||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
function crc32(bytes) {
|
||||
let c = 0xffffffff;
|
||||
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function pngChunk(type, data) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(data.length, 0);
|
||||
head.write(type, 4, 'ascii');
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
||||
return Buffer.concat([head, data, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filtered coverage tile as KartaView draws it: 512 px, transparent, the
|
||||
* passing streets three pixels wide.
|
||||
*/
|
||||
export function maskTilePng(z, x, y, query) {
|
||||
const size = 512;
|
||||
const rgba = Buffer.alloc(size * size * 4);
|
||||
const { west, east } = tileBounds(x, y, z);
|
||||
const n = 2 ** z;
|
||||
const row = (lat) => {
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||
return Math.round((my * n - y) * size);
|
||||
};
|
||||
for (const street of streets()) {
|
||||
if (!passesTileQuery(street, query)) continue;
|
||||
const inside = stretch(street.line, z, x, y);
|
||||
if (!inside) continue;
|
||||
const column = Math.round(
|
||||
((street.line.lon - west) / (east - west)) * size,
|
||||
);
|
||||
const top = Math.max(0, row(inside[1]));
|
||||
const bottom = Math.min(size - 1, row(inside[0]));
|
||||
for (let py = top; py <= bottom; py++)
|
||||
for (let px = column - 1; px <= column + 1; px++) {
|
||||
if (px < 0 || px >= size) continue;
|
||||
rgba.set([99, 91, 255, 255], (py * size + px) * 4);
|
||||
}
|
||||
}
|
||||
const rows = Buffer.alloc(size * (size * 4 + 1));
|
||||
for (let py = 0; py < size; py++)
|
||||
rgba.copy(
|
||||
rows,
|
||||
py * (size * 4 + 1) + 1,
|
||||
py * size * 4,
|
||||
(py + 1) * size * 4,
|
||||
);
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header.set([8, 6, 0, 0, 0], 8);
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
pngChunk('IHDR', header),
|
||||
pngChunk('IDAT', deflateSync(rows)),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A call named by host, path shape and parameter names. */
|
||||
export function describeKartaViewCall(method, url) {
|
||||
const keys = [...url.searchParams.keys()].sort();
|
||||
const path = url.pathname
|
||||
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
|
||||
.replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png')
|
||||
.replace(/\/photo\/\d+$/, '/photo/{id}')
|
||||
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
|
||||
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
|
||||
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
|
||||
.replace(
|
||||
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
|
||||
@@ -171,6 +285,23 @@ export function answerKartaViewRequest({ method, url }) {
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
// Filtered coverage: KartaView filters only its PNG tiles.
|
||||
const mask = path.match(
|
||||
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/,
|
||||
);
|
||||
if (mask)
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
headers: CORS,
|
||||
body: maskTilePng(
|
||||
Number(mask[3]),
|
||||
Number(mask[1]),
|
||||
Number(mask[2]),
|
||||
address.searchParams,
|
||||
),
|
||||
known: true,
|
||||
};
|
||||
if (path === '/2.0/photo/') {
|
||||
if (nearestLookups++ === 0)
|
||||
return {
|
||||
@@ -184,7 +315,15 @@ export function answerKartaViewRequest({ method, url }) {
|
||||
};
|
||||
const q = address.searchParams;
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
// Its own filter, as KartaView's: projection, and upload date.
|
||||
const projection = q.get('projection');
|
||||
const since = q.get('startDate');
|
||||
const near = photos
|
||||
.filter(
|
||||
(photo) =>
|
||||
(!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) &&
|
||||
(!since || SHOT.slice(0, 10) >= since),
|
||||
)
|
||||
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
|
||||
.filter(({ metres }) => metres < 200)
|
||||
.sort((a, b) => a.metres - b.metres)
|
||||
@@ -202,6 +341,18 @@ export function answerKartaViewRequest({ method, url }) {
|
||||
? { ...ok({ data: record(photo) }), known: true }
|
||||
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
|
||||
}
|
||||
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
|
||||
if (sequence)
|
||||
return {
|
||||
...ok({
|
||||
data: {
|
||||
id: sequence[1],
|
||||
countActivePhotos:
|
||||
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
|
||||
},
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
|
||||
if (page) {
|
||||
const q = address.searchParams;
|
||||
|
||||
@@ -532,6 +532,7 @@
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
@@ -1013,5 +1014,7 @@
|
||||
"src/tools/views.test.mjs",
|
||||
"src/app/embed.test.mjs",
|
||||
"src/tools/globePanel.test.mjs",
|
||||
"src/costEndpointThrottle.test.mjs"
|
||||
"src/costEndpointThrottle.test.mjs",
|
||||
"src/sources/gbfsStations.test.mjs",
|
||||
"scripts/qa-gbfs-names.mjs"
|
||||
]
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keyless station-name regression against the real globe and bike-share tool.
|
||||
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
|
||||
* GBFS documents are synthetic; no operator feed or provider key is required.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const urlIndex = process.argv.indexOf('--url');
|
||||
const appUrl =
|
||||
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
|
||||
const shots = path.resolve('qa-shots/gbfs-names');
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const information = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'english',
|
||||
name: [
|
||||
{ language: 'hu', text: 'Magyar tér' },
|
||||
{ language: 'en', text: 'QA English Square' },
|
||||
],
|
||||
},
|
||||
{
|
||||
station_id: 'short',
|
||||
name: [null, { language: 'en', text: ' ' }],
|
||||
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
|
||||
},
|
||||
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
|
||||
{ station_id: 'legacy', name: ' QA Legacy Station ' },
|
||||
{
|
||||
station_id: 'fallback',
|
||||
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
|
||||
},
|
||||
].map((station, index) => ({
|
||||
...station,
|
||||
lat: 30.2681 + index * 0.001,
|
||||
lon: -97.7469 + index * 0.001,
|
||||
capacity: 13,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const status = {
|
||||
data: {
|
||||
stations: information.data.stations.map(({ station_id }) => ({
|
||||
station_id,
|
||||
num_bikes_available: 4,
|
||||
num_docks_available: 9,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(process.env.PUPPETEER_EXECUTABLE_PATH
|
||||
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
|
||||
: {}),
|
||||
args:
|
||||
process.platform === 'darwin'
|
||||
? ['--use-angle=metal']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
let checks = 0;
|
||||
const check = (name, actual, expected) => {
|
||||
assert.deepEqual(actual, expected, name);
|
||||
checks++;
|
||||
console.log(`[PASS] ${name}`);
|
||||
};
|
||||
async function open(mode = 'normal') {
|
||||
const page = await browser.newPage();
|
||||
await page.bringToFront();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(
|
||||
({ information, status, mode }) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__qaPainted = [];
|
||||
const paint = CanvasRenderingContext2D.prototype.fillText;
|
||||
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
|
||||
if (
|
||||
String(text).startsWith('QA ') ||
|
||||
String(text) === 'Station unnamed'
|
||||
) {
|
||||
window.__qaPainted.push(String(text));
|
||||
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
|
||||
}
|
||||
return paint.call(this, text, ...args);
|
||||
};
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const url = String(
|
||||
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||
);
|
||||
if (url.includes('/api/gbfs/')) {
|
||||
if (mode === 'failure') return new Response('{}', { status: 503 });
|
||||
const payload =
|
||||
mode === 'empty'
|
||||
? { data: { stations: [] } }
|
||||
: decodeURIComponent(url).includes('station_information')
|
||||
? information
|
||||
: status;
|
||||
return new Response(JSON.stringify(payload), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/flights')) return new Response('{"states":[]}');
|
||||
if (url.includes('/api/military')) return new Response('{"ac":[]}');
|
||||
if (url.includes('/api/vessels'))
|
||||
return new Response('{"connected":false,"vessels":[]}');
|
||||
return original(input, init);
|
||||
};
|
||||
},
|
||||
{ information, status, mode },
|
||||
);
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.getElementById('loading-screen') ||
|
||||
(document.getElementById('loading-screen').classList.contains('hidden') &&
|
||||
getComputedStyle(document.getElementById('loading-screen')).opacity ===
|
||||
'0'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, viewer } = window.__godsEyeView;
|
||||
for (const [id, layer] of dataManager.layers) {
|
||||
if (layer.enabled)
|
||||
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
}
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||
window.__godsEyeView.requestRender('gbfs-name-qa');
|
||||
});
|
||||
return { page, errors };
|
||||
}
|
||||
try {
|
||||
const { page, errors } = await open();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
|
||||
.count === 5,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const records = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getAnalystRecords(),
|
||||
);
|
||||
check(
|
||||
'the layer retains all five stations and normalized names',
|
||||
records.map((r) => r.name),
|
||||
[
|
||||
'QA English Square',
|
||||
'QA Short Name',
|
||||
null,
|
||||
'QA Legacy Station',
|
||||
'QA Magyar Station',
|
||||
],
|
||||
);
|
||||
const tool = await page.evaluate(() =>
|
||||
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
|
||||
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
|
||||
}),
|
||||
);
|
||||
check('the real bike-share tool succeeds', tool.ok, true);
|
||||
check(
|
||||
'the tool preserves localized, short, unnamed, legacy and non-English names',
|
||||
tool.data.rows.map((r) => r.name).sort(),
|
||||
[
|
||||
null,
|
||||
'QA English Square',
|
||||
'QA Short Name',
|
||||
'QA Legacy Station',
|
||||
'QA Magyar Station',
|
||||
].sort(),
|
||||
);
|
||||
check(
|
||||
'availability remains attached to every station',
|
||||
tool.data.rows.every(
|
||||
(r) => r.bikes_available === 4 && r.docks_available === 9,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const clickPoint = await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const point = viewer.scene.cartesianToCanvasCoordinates(
|
||||
Cartesian3.fromDegrees(-97.7469, 30.2681),
|
||||
);
|
||||
const rect = viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: rect.left + point.x, y: rect.top + point.y };
|
||||
});
|
||||
await page.mouse.click(clickPoint.x, clickPoint.y);
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
check(
|
||||
'a real pointer click selects the station with its localized name',
|
||||
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
for (const [id, title] of [
|
||||
['english', 'QA English Square'],
|
||||
['short', 'QA Short Name'],
|
||||
['unnamed', 'Station unnamed'],
|
||||
]) {
|
||||
await page.evaluate((id) => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
|
||||
app.requestRender('gbfs-name-selection-qa');
|
||||
}, id);
|
||||
await page.waitForFunction(
|
||||
(title) => window.__qaPainted.includes(title),
|
||||
{ timeout: 10000 },
|
||||
title,
|
||||
);
|
||||
check(
|
||||
`the selected ${id} card paints the expected title`,
|
||||
await page.evaluate((title) => window.__qaPainted.includes(title), title),
|
||||
true,
|
||||
);
|
||||
await page.screenshot({ path: path.join(shots, `${id}.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.every((record) => !record.skipLabel),
|
||||
);
|
||||
check(
|
||||
`Escape clears the ${id} card`,
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.filter((record) => record.skipLabel).length,
|
||||
),
|
||||
0,
|
||||
);
|
||||
}
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||
app.styleManager.toggleCleanView(true);
|
||||
const Cartesian3 = app.viewer.camera.position.constructor;
|
||||
app.viewer.camera.cancelFlight();
|
||||
app.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module._selectBikeshareStationForTest('austin-capmetro:english');
|
||||
app.requestRender('gbfs-name-mobile-qa');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
check(
|
||||
'the mobile selected card paints its normalized name',
|
||||
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
|
||||
true,
|
||||
);
|
||||
await page.screenshot({ path: path.join(shots, 'mobile.png') });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'disabling clears selected cards and analyst records',
|
||||
await page.evaluate(() => ({
|
||||
count: window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getAnalystRecords().length,
|
||||
selected: window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.filter((record) => record.skipLabel).length,
|
||||
})),
|
||||
{ count: 0, selected: 0 },
|
||||
);
|
||||
check('normal fixtures produce no page exceptions', errors, []);
|
||||
await page.close();
|
||||
for (const mode of ['empty', 'failure']) {
|
||||
const { page, errors } = await open(mode);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().loading,
|
||||
);
|
||||
check(
|
||||
`${mode} leaves no rendered stations`,
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count,
|
||||
),
|
||||
0,
|
||||
);
|
||||
check(`${mode} produces no page exceptions`, errors, []);
|
||||
await page.close();
|
||||
}
|
||||
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
+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