mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor(street-level): drop openNearest; QA opens a photo by clicking a line then a cone
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
07a239fd82
commit
94049c5667
15 files changed
+61
-439
No files matched your search
@@ -338,7 +338,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Zoom in to street level (below 60 km) for the green coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters narrow the lines, cones and nearest-photo lookups. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on mapillary.com, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the layer is on.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Zoom in to street level (below 60 km) for the green coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters narrow the lines and cones. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on mapillary.com, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the layer is on.
|
||||
|
||||
Share links carry the Mapillary switch and the filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year).
|
||||
|
||||
|
||||
@@ -27,15 +27,6 @@ export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
||||
|
||||
const METRES_PER_DEG_LAT = 110_540;
|
||||
|
||||
/** Ground distance in metres, as the app's metresBetween. */
|
||||
export function metresApart(a, b) {
|
||||
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
|
||||
return Math.hypot(
|
||||
(b.lon - a.lon) * 111_320 * Math.cos(lat),
|
||||
(b.lat - a.lat) * METRES_PER_DEG_LAT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos along PHOTO_LINE, south to north; every third one is a panorama.
|
||||
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
|
||||
@@ -181,18 +172,6 @@ export function answerMapillaryRequest({ method, url, images }) {
|
||||
: [];
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('lat') && q.has('lng')) {
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
||||
const limit = Number(q.get('limit')) || 10;
|
||||
// In no particular order, as the real API answers.
|
||||
const data = photos
|
||||
.filter((image) => metresApart(at, image) <= radius)
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((image) => withFields(imageRecord(image), fields));
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||
return {
|
||||
...json(400, { error: 'unsupported images query' }),
|
||||
|
||||
@@ -264,6 +264,35 @@ async function assertKeyRequired(page) {
|
||||
assert.match(shown.hint, /MAPILLARY_CLIENT_TOKEN/, 'names the key to add');
|
||||
}
|
||||
|
||||
/**
|
||||
* Client coordinates of the first thing drawn whose pick id starts with
|
||||
* `prefix`, scanning `box` ({x, y, w, h} in canvas pixels; default: a row
|
||||
* across the middle) every `step` px; null when nothing matches.
|
||||
*/
|
||||
const findPick = (page, prefix, { box = null, step = 3 } = {}) =>
|
||||
page.evaluate(
|
||||
(want, area, stride) => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
const rect = scene.canvas.getBoundingClientRect();
|
||||
const { x, y, w, h } = area || {
|
||||
x: 0,
|
||||
y: Math.round(rect.height / 2),
|
||||
w: rect.width,
|
||||
h: 1,
|
||||
};
|
||||
for (let py = y; py < y + h; py += stride)
|
||||
for (let px = x; px < x + w; px += stride) {
|
||||
const id = scene.pick({ x: px, y: py })?.id;
|
||||
if (typeof id === 'string' && id.startsWith(want))
|
||||
return { x: rect.left + px, y: rect.top + py, local: { x: px, y: py } };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
prefix,
|
||||
box,
|
||||
step,
|
||||
);
|
||||
|
||||
/** Park the camera over the photo line; a late startup flight can move it. */
|
||||
async function park(page) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
@@ -519,14 +548,30 @@ async function main() {
|
||||
await park(page);
|
||||
await waitForCoverage(page);
|
||||
await step(
|
||||
'the viewer loads and the nearest photo opens with a caption',
|
||||
'a line selects its sequence, and a cone opens its photo with a caption',
|
||||
async () => {
|
||||
await page.evaluate(() => {
|
||||
// Fire and forget: the open can outlive one CDP call.
|
||||
void window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.openNearest();
|
||||
// Fixtures answer lookups for the photo sequence only.
|
||||
const line = await findPick(
|
||||
page,
|
||||
fixtures ? `mly:seq:${PHOTO_SEQUENCE_ID}` : 'mly:seq:',
|
||||
);
|
||||
assert.ok(line, 'a sequence line across the middle of the view');
|
||||
await page.mouse.click(line.x, line.y);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => u.sequence.selectedId && !u.sequence.loading,
|
||||
);
|
||||
const cone = await findPick(page, 'mly:img:', {
|
||||
box: {
|
||||
x: Math.max(0, line.local.x - 150),
|
||||
y: Math.max(0, line.local.y - 150),
|
||||
w: 300,
|
||||
h: 300,
|
||||
},
|
||||
step: 8,
|
||||
});
|
||||
assert.ok(cone, 'an image cone near the selected line');
|
||||
await page.mouse.click(cone.x, cone.y);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { createState, isActive } from './state.js';
|
||||
import { createState } from './state.js';
|
||||
import { createMarker } from './marker.js';
|
||||
import { createCameraFraming } from './cameraFraming.js';
|
||||
import { createCredit } from './credits.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createViewerHost } from './viewerHost.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { viewCentre } from './view.js';
|
||||
import { normalizeFilter, sameFilter } from './filter.js';
|
||||
import { COLORS, STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
import { createCoverage } from './providers/mapillary/coverage.js';
|
||||
import { createSequences } from './providers/mapillary/sequences.js';
|
||||
import { createMapillaryViewer } from './providers/mapillary/viewer.js';
|
||||
import { nearestImageId } from './providers/mapillary/nearest.js';
|
||||
import {
|
||||
COLORS as MAPILLARY_COLORS,
|
||||
MAPILLARY_CREDIT_HTML,
|
||||
MAPILLARY_KEY_ID,
|
||||
MAPILLARY_SOURCE_METHODS,
|
||||
NEAREST_RADIUS_M,
|
||||
} from './providers/mapillary/policy.js';
|
||||
|
||||
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
@@ -124,7 +121,6 @@ export function createStreetLevelLayer({
|
||||
|
||||
/** Close the photo, clear lines and cones, forget refusals. */
|
||||
function stopDrawing() {
|
||||
abortNearest();
|
||||
parts.viewerHost.unmount();
|
||||
parts.coverage.detach();
|
||||
parts.coverage.clear();
|
||||
@@ -145,16 +141,7 @@ export function createStreetLevelLayer({
|
||||
notify();
|
||||
}
|
||||
|
||||
/** The nearest-image lookup in flight; any newer user action aborts it. */
|
||||
let nearestLookup = null;
|
||||
|
||||
function abortNearest() {
|
||||
nearestLookup?.abort();
|
||||
nearestLookup = null;
|
||||
}
|
||||
|
||||
function openImage(imageId) {
|
||||
abortNearest();
|
||||
return parts.viewerHost.open(imageId);
|
||||
}
|
||||
|
||||
@@ -319,44 +306,8 @@ export function createStreetLevelLayer({
|
||||
parts.viewerHost.attach(element);
|
||||
},
|
||||
openImage,
|
||||
/** Open the nearest image around a point (default: the view centre). */
|
||||
async openNearest(point) {
|
||||
if (!isActive(state)) return false;
|
||||
const view = point || viewCentre(state.viewer);
|
||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||
return false;
|
||||
abortNearest();
|
||||
const lookup = new AbortController();
|
||||
nearestLookup = lookup;
|
||||
state.street.loading = true;
|
||||
state.street.error = null;
|
||||
notify();
|
||||
let imageId = null;
|
||||
let failure = null;
|
||||
try {
|
||||
imageId = await nearestImageId(
|
||||
source,
|
||||
{ lat: view.lat, lon: view.lon },
|
||||
resolveFilter(state.filter),
|
||||
{ signal: lookup.signal },
|
||||
);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
// Overtaken, closed or switched off: whatever did that owns the panel.
|
||||
if (lookup.signal.aborted) return false;
|
||||
nearestLookup = null;
|
||||
if (imageId) return openImage(imageId);
|
||||
state.street.error =
|
||||
failure?.message ||
|
||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
|
||||
state.street.loading = false;
|
||||
notify();
|
||||
return false;
|
||||
},
|
||||
/** Close the image and deselect its sequence on the map. */
|
||||
closeViewer() {
|
||||
abortNearest();
|
||||
parts.viewerHost.close();
|
||||
parts.sequences.clearSelection();
|
||||
},
|
||||
|
||||
@@ -132,17 +132,6 @@ function deferred() {
|
||||
const posingViewer = () =>
|
||||
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
|
||||
|
||||
/** Nearest-image lookups that answer when the test says, with their signals. */
|
||||
function slowLookups() {
|
||||
const lookups = [];
|
||||
const nearestImages = (query, { signal } = {}) => {
|
||||
const answer = deferred();
|
||||
lookups.push({ query, signal, answer });
|
||||
return answer.promise.then((id) => [{ id, is_pano: false }]);
|
||||
};
|
||||
return { lookups, source: fakeMapillarySource({ nearestImages }) };
|
||||
}
|
||||
|
||||
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t);
|
||||
const shown = () => viewer.credits.map((credit) => credit.html);
|
||||
@@ -217,41 +206,6 @@ test('switching Mapillary off clears coverage and closes the image it shows', as
|
||||
assert.equal(layer.getUIState().providerOn, false);
|
||||
});
|
||||
|
||||
test('openNearest without the panel reports it instead of loading forever', async (t) => {
|
||||
const source = fakeMapillarySource({
|
||||
nearestImages: async () => [{ id: 1 }],
|
||||
});
|
||||
const { layer, photoViewer } = await startLayer(t, { source });
|
||||
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
|
||||
const { street } = layer.getUIState();
|
||||
assert.equal(street.loading, false);
|
||||
assert.match(street.error, /Open the Street Level panel/);
|
||||
assert.equal(photoViewer.calls.mount, 0);
|
||||
});
|
||||
|
||||
test('openNearest opens nothing once the layer or its switch went off during the lookup', async (t) => {
|
||||
for (const off of [
|
||||
(layer) => layer.disable(),
|
||||
(layer) => layer.setParams({ mapillary: false }),
|
||||
]) {
|
||||
const { lookups, source } = slowLookups();
|
||||
const { layer, photoViewer } = await startLayer(t, { source });
|
||||
layer.attachViewerHost({});
|
||||
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||
off(layer);
|
||||
lookups[0].answer.resolve('img1');
|
||||
assert.equal(await opening, false);
|
||||
assert.equal(lookups[0].signal.aborted, true, 'the request was aborted');
|
||||
assert.equal(photoViewer.calls.mount, 0, 'no viewer was stood up');
|
||||
const { street } = layer.getUIState();
|
||||
assert.deepEqual(
|
||||
[street.open, street.loading, street.error],
|
||||
[false, false, null],
|
||||
);
|
||||
layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('a sequence error is withdrawn with the sequence, never the viewer’s own', async (t) => {
|
||||
const source = fakeMapillarySource({
|
||||
getSequenceImages: async () => {
|
||||
@@ -292,65 +246,6 @@ test('closing the photo, switching the layer off or destroying it stops the fram
|
||||
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
|
||||
});
|
||||
|
||||
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
||||
const { lookups, source } = slowLookups();
|
||||
const { layer, photoViewer } = await startLayer(t, { source });
|
||||
layer.attachViewerHost({});
|
||||
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||
const newer = layer.openNearest({ lat: 38.59, lon: -121.48 });
|
||||
lookups[1].answer.resolve('newer');
|
||||
assert.equal(await newer, true);
|
||||
lookups[0].answer.resolve('older');
|
||||
assert.equal(await older, false);
|
||||
assert.deepEqual(photoViewer.calls.open, ['newer']);
|
||||
assert.equal(lookups[0].signal?.aborted, true, 'its request was aborted');
|
||||
assert.equal(lookups[1].signal?.aborted, false);
|
||||
});
|
||||
|
||||
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
|
||||
const { lookups, source } = slowLookups();
|
||||
const { layer, photoViewer } = await startLayer(t, { source });
|
||||
layer.attachViewerHost({});
|
||||
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||
// A cone click opens its image directly.
|
||||
assert.equal(await layer.openImage('picked'), true);
|
||||
lookups[0].answer.resolve('nearest');
|
||||
assert.equal(await lookup, false);
|
||||
assert.deepEqual(photoViewer.calls.open, ['picked']);
|
||||
assert.equal(lookups[0].signal?.aborted, true);
|
||||
});
|
||||
|
||||
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
|
||||
const { lookups, source } = slowLookups();
|
||||
const { layer, viewer, photoViewer } = await startLayer(t, { source });
|
||||
layer.attachViewerHost({});
|
||||
|
||||
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||
layer.closeViewer();
|
||||
// The aborted fetch rejects, as fetch does; that is not the user's error.
|
||||
lookups[0].answer.reject(new DOMException('aborted', 'AbortError'));
|
||||
assert.equal(await closed, false);
|
||||
let { street } = layer.getUIState();
|
||||
assert.deepEqual(
|
||||
[street.open, street.loading, street.error],
|
||||
[false, false, null],
|
||||
);
|
||||
assert.equal(lookups[0].signal?.aborted, true, 'closing aborts the request');
|
||||
|
||||
const disabled = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
lookups[1].answer.resolve('img1');
|
||||
assert.equal(await disabled, false, 'nor does it open once back on');
|
||||
assert.deepEqual(photoViewer.calls.open, []);
|
||||
({ street } = layer.getUIState());
|
||||
assert.deepEqual(
|
||||
[street.open, street.loading, street.error],
|
||||
[false, false, null],
|
||||
);
|
||||
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
|
||||
});
|
||||
|
||||
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
|
||||
assert.throws(() => createStreetLevelLayer({ source: {} }), /source/);
|
||||
const { layer } = await startLayer(t);
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { nearestImageId } from './nearest.js';
|
||||
import { mapillaryImageUrl } from './policy.js';
|
||||
import { sequenceIdFromPick } from './coverage.js';
|
||||
import { thinImages } from './sequences.js';
|
||||
import { resolveFilter } from '../../filter.js';
|
||||
|
||||
const ALL = Object.freeze({ pano: 'all', sinceMs: null });
|
||||
|
||||
/** A source answering nearest-image lookups with `nearest`, recording options. */
|
||||
function fakeSource(nearest = []) {
|
||||
const requests = [];
|
||||
return {
|
||||
requests,
|
||||
nearestImages: async (query, options) => {
|
||||
requests.push({ query, options });
|
||||
return nearest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const at = (id, lon, lat) => ({
|
||||
id,
|
||||
is_pano: false,
|
||||
captured_at: 10,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
|
||||
test('image deep links match the mapillary.com share format', () => {
|
||||
assert.equal(
|
||||
@@ -35,101 +12,12 @@ test('image deep links match the mapillary.com share format', () => {
|
||||
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||
});
|
||||
|
||||
test('nearestImageId honours the imagery filter and hands the signal to the source', async () => {
|
||||
const source = fakeSource([
|
||||
{ id: 1, is_pano: false, captured_at: 10 },
|
||||
{ id: 2, is_pano: true, captured_at: 20 },
|
||||
]);
|
||||
const { signal } = new AbortController();
|
||||
assert.equal(
|
||||
await nearestImageId(source, { lat: 1, lon: 2 }, ALL, { signal }),
|
||||
'1',
|
||||
);
|
||||
assert.equal(source.requests[0].options.signal, signal);
|
||||
assert.equal(source.requests[0].query.radius, 50);
|
||||
assert.equal(
|
||||
await nearestImageId(
|
||||
source,
|
||||
{ lat: 1, lon: 2 },
|
||||
{
|
||||
pano: 'pano',
|
||||
sinceMs: null,
|
||||
},
|
||||
),
|
||||
'2',
|
||||
);
|
||||
assert.equal(
|
||||
await nearestImageId(
|
||||
source,
|
||||
{ lat: 1, lon: 2 },
|
||||
{
|
||||
pano: 'all',
|
||||
sinceMs: 100,
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('any part of a multi-part sequence picks the whole sequence', () => {
|
||||
test('a sequence line pick names its sequence; other picks do not', () => {
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
|
||||
assert.equal(sequenceIdFromPick(null), null);
|
||||
});
|
||||
|
||||
test('nearestImageId picks the closest image, not the first the API returned', async () => {
|
||||
const source = fakeSource([
|
||||
at('far', 2.0004, 1), // ~45 m east
|
||||
at('near', 2.00005, 1), // ~5 m east
|
||||
at('mid', 2, 1.0002), // ~22 m north
|
||||
]);
|
||||
assert.equal(await nearestImageId(source, { lat: 1, lon: 2 }, ALL), 'near');
|
||||
});
|
||||
|
||||
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
|
||||
const day = 86_400_000;
|
||||
const capturedAt = Date.now() - day / 2;
|
||||
const source = fakeSource([
|
||||
{ id: 'recent', is_pano: false, captured_at: capturedAt },
|
||||
]);
|
||||
// The layer resolves the stored filter on every lookup.
|
||||
const lookup = () =>
|
||||
nearestImageId(
|
||||
source,
|
||||
{ lat: 1, lon: 2 },
|
||||
resolveFilter({ pano: 'all', sinceDays: 1 }),
|
||||
);
|
||||
assert.equal(await lookup(), 'recent');
|
||||
t.mock.timers.tick(2 * day);
|
||||
assert.equal(
|
||||
await lookup(),
|
||||
null,
|
||||
'two days on, the image is older than the window',
|
||||
);
|
||||
});
|
||||
|
||||
test('nearestImageId measures the short way round the date line', async () => {
|
||||
const east = fakeSource([
|
||||
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
|
||||
at('across', -179.9999, 0), // ~11 m east, across ±180°
|
||||
]);
|
||||
assert.equal(
|
||||
await nearestImageId(east, { lat: 0, lon: 179.9999 }, ALL),
|
||||
'across',
|
||||
);
|
||||
// And from the other side.
|
||||
const west = fakeSource([
|
||||
at('same-side', -179.9995, 0),
|
||||
at('across', 179.9999, 0),
|
||||
]);
|
||||
assert.equal(
|
||||
await nearestImageId(west, { lat: 0, lon: -179.9999 }, ALL),
|
||||
'across',
|
||||
);
|
||||
});
|
||||
|
||||
test('cone thinning across the date line drops images metres apart', () => {
|
||||
const image = (id, lon) => ({ id, lon, lat: 0 });
|
||||
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { NEAREST_LIMIT, NEAREST_RADIUS_M } from './policy.js';
|
||||
|
||||
/**
|
||||
* Id of the nearest image around a point that passes the resolved imagery
|
||||
* filter, or null. An aborted `signal` gives up (the source may reject).
|
||||
*/
|
||||
export async function nearestImageId(
|
||||
source,
|
||||
{ lat, lon },
|
||||
filter,
|
||||
{ signal } = {},
|
||||
) {
|
||||
const images = await source.nearestImages(
|
||||
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
|
||||
{ signal },
|
||||
);
|
||||
// The API returns the images in the radius in no particular order.
|
||||
const metres = (record) => {
|
||||
const [lon2, lat2] = (record.computed_geometry || record.geometry)
|
||||
?.coordinates || [NaN, NaN];
|
||||
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
|
||||
return Number.isFinite(d) ? d : Infinity;
|
||||
};
|
||||
const hit = [...images]
|
||||
.sort((a, b) => metres(a) - metres(b))
|
||||
.find((record) =>
|
||||
passesImageryFilter(
|
||||
{
|
||||
isPano: record.is_pano === true,
|
||||
capturedAt: Number(record.captured_at) || 0,
|
||||
},
|
||||
filter,
|
||||
),
|
||||
);
|
||||
return hit ? String(hit.id) : null;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
||||
'getStatus',
|
||||
'getTile',
|
||||
'getSequenceImages',
|
||||
'nearestImages',
|
||||
]);
|
||||
|
||||
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
|
||||
@@ -48,11 +47,6 @@ export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||
export const IMAGE_CONE_SIZE_PX = 26;
|
||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
|
||||
/** Nearest-image search radius in metres (the graph API caps it at 50 m). */
|
||||
export const NEAREST_RADIUS_M = 50;
|
||||
/** Images asked for in a nearest-image search. */
|
||||
export const NEAREST_LIMIT = 8;
|
||||
|
||||
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
|
||||
export const MAPILLARY_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
|
||||
@@ -64,8 +58,5 @@ export function mapillaryImageUrl(imageId) {
|
||||
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
|
||||
}
|
||||
|
||||
/** Fields requested from the graph API. */
|
||||
export const IMAGE_FIELDS =
|
||||
'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score';
|
||||
export const SEQUENCE_IMAGE_FIELDS =
|
||||
'id,captured_at,compass_angle,geometry,is_pano,computed_altitude';
|
||||
/** Fields requested for a sequence's images. */
|
||||
export const SEQUENCE_IMAGE_FIELDS = 'id,captured_at,compass_angle,geometry,is_pano';
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
IMAGE_FIELDS,
|
||||
MAPILLARY_GRAPH_HOST,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
SEQUENCE_IMAGE_FIELDS,
|
||||
SEQUENCE_IMAGES_LIMIT,
|
||||
} from './policy.js';
|
||||
@@ -126,23 +123,5 @@ export function createMapillarySource({
|
||||
);
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
},
|
||||
|
||||
async nearestImages(
|
||||
{ lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT },
|
||||
{ signal } = {},
|
||||
) {
|
||||
const payload = await graph(
|
||||
'images',
|
||||
{
|
||||
lat: String(lat),
|
||||
lng: String(lon),
|
||||
radius: String(Math.min(50, Math.max(1, radius))),
|
||||
limit: String(Math.min(100, Math.max(1, limit))),
|
||||
fields: IMAGE_FIELDS,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -62,21 +62,6 @@ test('graph lookups call graph.mapillary.com with the client token and requested
|
||||
assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc');
|
||||
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
|
||||
assert.match(url.searchParams.get('fields'), /geometry/);
|
||||
|
||||
const nearest = await source.nearestImages({
|
||||
lat: 38.5,
|
||||
lon: -121.5,
|
||||
radius: 500,
|
||||
limit: 500,
|
||||
});
|
||||
assert.equal(nearest.length, 1);
|
||||
const nearestUrl = new URL(calls[1].url);
|
||||
assert.equal(
|
||||
nearestUrl.searchParams.get('radius'),
|
||||
'50',
|
||||
'radius is clamped to the API maximum',
|
||||
);
|
||||
assert.equal(nearestUrl.searchParams.get('limit'), '100');
|
||||
});
|
||||
|
||||
test('graph calls without a token fail fast as keyRequired without a request', async () => {
|
||||
|
||||
@@ -153,20 +153,6 @@ export function visibleBbox(
|
||||
];
|
||||
}
|
||||
|
||||
/** Centre of the visible ground, or the camera's own footprint. */
|
||||
export function viewCentre(viewer) {
|
||||
if (!viewer) return null;
|
||||
const bbox = visibleBbox(viewer);
|
||||
if (bbox) {
|
||||
// Across the date line (west > east) the middle is on the far side of 0°.
|
||||
const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0);
|
||||
let lon = bbox[0] + span / 2;
|
||||
if (lon > 180) lon -= 360;
|
||||
return { lat: (bbox[1] + bbox[3]) / 2, lon };
|
||||
}
|
||||
return cameraNadir(viewer);
|
||||
}
|
||||
|
||||
/** The point on the ground under the camera, as {lon, lat} degrees. */
|
||||
export function cameraNadir(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createHorizonCull,
|
||||
groundUnderCamera,
|
||||
metresBetween,
|
||||
viewCentre,
|
||||
viewFocus,
|
||||
visibleBbox,
|
||||
} from './view.js';
|
||||
@@ -89,16 +88,11 @@ test('a view across the date line is a narrow box with west > east', () => {
|
||||
const bbox = visibleBbox(viewer);
|
||||
assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`);
|
||||
assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`);
|
||||
const centre = viewCentre(viewer);
|
||||
assert.ok(Math.abs(Math.abs(centre.lon) - 180) < 0.5, `centre ${centre.lon}`);
|
||||
});
|
||||
|
||||
test('an ordinary view keeps west < east', () => {
|
||||
const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51]));
|
||||
assert.ok(bbox[0] < bbox[2]);
|
||||
assert.ok(
|
||||
Math.abs(viewCentre(gridViewer([10, 11, 12], [50, 51])).lon - 11) < 1e-6,
|
||||
);
|
||||
});
|
||||
|
||||
/** A pinhole viewer `agl` m above ground `ground` m up, looking north. */
|
||||
|
||||
@@ -78,8 +78,6 @@ export function createViewerHost({ state, parts, adapter }) {
|
||||
async function open(imageId) {
|
||||
if (!imageId) return false;
|
||||
if (!state.street.host) {
|
||||
// A nearest-image lookup may have set it; nothing is loading now.
|
||||
state.street.loading = false;
|
||||
state.street.error = 'Open the Street Level panel to view imagery';
|
||||
notify();
|
||||
return false;
|
||||
|
||||
@@ -18,7 +18,6 @@ export function fakeMapillarySource(overrides = {}) {
|
||||
return new Uint8Array(0);
|
||||
},
|
||||
getSequenceImages: async () => [],
|
||||
nearestImages: async () => [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { fixtureTile } from '../../scripts/qa-street-level.mjs';
|
||||
import {
|
||||
answerMapillaryRequest,
|
||||
describeCall,
|
||||
metresApart,
|
||||
PHOTO_LINE,
|
||||
PHOTO_SEQUENCE_ID,
|
||||
photoImages,
|
||||
@@ -73,16 +72,14 @@ test('the photo sequence runs through the parked view, clipped to each tile it c
|
||||
);
|
||||
});
|
||||
|
||||
test('the fixture photos: one sequence, 360° and flat, close enough for any nearest lookup on the line', () => {
|
||||
test('the fixture photos: one sequence, 360° and flat, spaced past the cone thinning', () => {
|
||||
const images = photoImages(Date.UTC(2026, 9, 1));
|
||||
assert.ok(images.length >= 3);
|
||||
assert.ok(images.some((image) => image.isPano));
|
||||
assert.ok(images.some((image) => !image.isPano));
|
||||
for (let i = 1; i < images.length; i++) {
|
||||
const gap = metresApart(images[i - 1], images[i]);
|
||||
// Above the cones' 3 m thinning, and at most 50 m apart: any point on
|
||||
// the line has a photo within the Graph API's 50 m radius.
|
||||
assert.ok(gap > 3 && gap < 50, `gap ${gap}`);
|
||||
const gap = (images[i].lat - images[i - 1].lat) * 110_540;
|
||||
assert.ok(gap > 3, `gap ${gap}`);
|
||||
}
|
||||
assert.equal(new Set(images.map((image) => image.id)).size, images.length);
|
||||
});
|
||||
@@ -97,35 +94,8 @@ const answer = (url, method = 'GET') => {
|
||||
};
|
||||
};
|
||||
|
||||
test('the Graph fixtures answer the provider’s nearest and sequence lookups', () => {
|
||||
test('the Graph fixtures answer the provider’s sequence lookups', () => {
|
||||
const images = photoImages();
|
||||
const middle = images[Math.floor(images.length / 2)];
|
||||
const near = answer(
|
||||
graph('images', {
|
||||
lat: String(middle.lat + 0.0001),
|
||||
lng: String(middle.lon),
|
||||
radius: '50',
|
||||
limit: '8',
|
||||
fields: 'id,geometry,is_pano,captured_at,sequence',
|
||||
}),
|
||||
);
|
||||
assert.equal(near.status, 200);
|
||||
assert.ok(near.json.data.length > 0);
|
||||
assert.ok(near.json.data.some((record) => record.id === middle.id));
|
||||
for (const record of near.json.data) {
|
||||
assert.deepEqual(Object.keys(record).sort(), [
|
||||
'captured_at',
|
||||
'geometry',
|
||||
'id',
|
||||
'is_pano',
|
||||
'sequence',
|
||||
]);
|
||||
assert.equal(record.sequence, PHOTO_SEQUENCE_ID);
|
||||
}
|
||||
const far = answer(
|
||||
graph('images', { lat: '0', lng: '0', radius: '50', fields: 'id' }),
|
||||
);
|
||||
assert.deepEqual(far.json.data, [], 'nothing outside the radius');
|
||||
const sequence = answer(
|
||||
graph('images', {
|
||||
sequence_ids: PHOTO_SEQUENCE_ID,
|
||||
|
||||
Reference in new issue
Block a user