From ed2703d345e88f702f0d59e07f821ff82ee4b60b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH] feat(street-level): restore Mapillary's nearest-image lookup for show_street_level and sl= links The slim Street Level core (#974) removed nearestImage from the Mapillary provider. show_street_level and sl=mapillary links need it to open the photo closest to a point, so this restores it on the slim core: Graph API /images within 50 m, sorted by distance, first one that passes the imagery filter. Kept as its own commit so it can be dropped if #974's removal should stand. --- .../fixtures/street-level/mapillaryGraph.mjs | 12 ++++++ .../streetLevel/providers/mapillary/index.js | 36 +++++++++++++++++ .../streetLevel/providers/mapillary/policy.js | 10 +++++ .../providers/mapillary/provider.test.mjs | 39 +++++++++++++++++++ .../streetLevel/providers/mapillary/source.js | 22 +++++++++++ .../providers/mapillary/source.test.mjs | 22 +++++++++++ src/testSupport/streetLevelFakes.mjs | 1 + 7 files changed, 142 insertions(+) diff --git a/scripts/fixtures/street-level/mapillaryGraph.mjs b/scripts/fixtures/street-level/mapillaryGraph.mjs index a18da379f..a978a0539 100644 --- a/scripts/fixtures/street-level/mapillaryGraph.mjs +++ b/scripts/fixtures/street-level/mapillaryGraph.mjs @@ -181,6 +181,18 @@ 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' }), diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 901df886c..c47280e9e 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -5,6 +5,9 @@ import { } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { coverageZoomForHeight } from '../../tileMath.js'; +import { passesImageryFilter } from '../../filter.js'; +import { NEAREST_RADIUS_M } from '../../policy.js'; +import { metresBetween } from '../../view.js'; import { decodeCoverageTile } from './decode.js'; import { createMapillaryViewer } from './viewer.js'; import { @@ -17,6 +20,7 @@ import { MAPILLARY_NAME, MAPILLARY_PROVIDER_ID, MAPILLARY_SOURCE_METHODS, + NEAREST_LIMIT, PICK_PREFIX, RATE_LIMITED_MESSAGE, } from './policy.js'; @@ -193,6 +197,38 @@ export function createMapillaryProvider({ source, photoViewer = null }) { }), coverageLines: () => parts.coverage.drawnLines(), + /** + * The nearest image that passes the imagery filter, or null: what + * show_street_level and sl= links open (clicks open a line, then a + * cone). The core aborts `signal` once a newer request overtakes it. + */ + async nearestImage({ lat, lon }, { 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 filter = context.getFilter(); + 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; + }, + viewer: photoViewer || createMapillaryViewer({ source, render: context.services?.render }), diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 183082934..b1609983a 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -16,6 +16,7 @@ export const MAPILLARY_SOURCE_METHODS = Object.freeze([ 'getStatus', 'getTile', 'getSequenceImages', + 'nearestImages', ]); /** Stable id prefixes for picked primitives; every one starts with `mly:`. */ @@ -72,3 +73,12 @@ export function mapillaryImageUrl(imageId) { /** Fields requested for a sequence's images. */ export const SEQUENCE_IMAGE_FIELDS = 'id,captured_at,compass_angle,geometry,is_pano'; + +/** + * Images asked for in a nearest-image search (show_street_level and links + * open the photo nearest a point; the graph API caps the radius at 50 m). + */ +export const NEAREST_LIMIT = 8; +/** Fields a nearest-image search needs: where each is, and the filter's. */ +export const NEAREST_IMAGE_FIELDS = + 'id,captured_at,geometry,computed_geometry,is_pano'; diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index ce2f0bb0c..ae406df32 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -170,3 +170,42 @@ test('a status the server never answers falls back to the bundled token once the assert.deepEqual(await status, { configured: false }); assert.equal(instance.coverageStats().keyRequired, true); }); + +/** A graph image record at a point. */ +const imageAt = (id, lon, lat, extra = {}) => ({ + id, + is_pano: false, + captured_at: 10, + geometry: { type: 'Point', coordinates: [lon, lat] }, + ...extra, +}); + +test('nearestImage opens the closest image that passes the filter, not the first the API returned', async () => { + const requests = []; + const answer = [ + imageAt('far', 2.0004, 1), // ~45 m east + imageAt('near-pano', 2.00002, 1, { is_pano: true }), // ~2 m, a panorama + imageAt('near', 2.00005, 1), // ~5 m east + imageAt('mid', 2, 1.0002), // ~22 m north + ]; + const source = fakeMapillarySource({ + nearestImages: async (query, options) => { + requests.push({ query, options }); + return answer; + }, + }); + const { signal } = new AbortController(); + const flat = createMapillaryProvider({ source }).create( + fakeContext({ pano: 'flat', sinceMs: null }), + ); + assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near'); + assert.equal(requests[0].options.signal, signal, 'the core can abort it'); + assert.equal(requests[0].query.radius, 50); + assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 2]); + const any = createMapillaryProvider({ source }).create(fakeContext()); + assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano'); + const tooOld = createMapillaryProvider({ source }).create( + fakeContext({ pano: 'all', sinceMs: 100 }), + ); + assert.equal(await tooOld.nearestImage({ lat: 1, lon: 2 }), null); +}); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 38a8b9c39..022a14dac 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -1,5 +1,8 @@ +import { NEAREST_RADIUS_M } from '../../policy.js'; import { MAPILLARY_GRAPH_HOST, + NEAREST_IMAGE_FIELDS, + NEAREST_LIMIT, SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGES_LIMIT, } from './policy.js'; @@ -147,5 +150,24 @@ export function createMapillarySource({ ); return Array.isArray(payload?.data) ? payload.data : []; }, + + /** Images within `radius` metres of a point, in no particular order. */ + 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: NEAREST_IMAGE_FIELDS, + }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, }; } diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index b502a7fd8..883918f4f 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -126,6 +126,28 @@ test('a rejected key and a rate limit are told apart from other tile errors', as ); }); +test('nearestImages asks the graph API around a point, its radius and limit clamped', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const images = await source.nearestImages({ + lat: 38.5, + lon: -121.5, + radius: 500, + limit: 500, + }); + assert.equal(images.length, 1); + const url = new URL(calls[0].url); + assert.equal(url.pathname, '/images'); + assert.equal(url.searchParams.get('lat'), '38.5'); + assert.equal(url.searchParams.get('lng'), '-121.5'); + assert.equal(url.searchParams.get('radius'), '50', 'the API maximum'); + assert.equal(url.searchParams.get('limit'), '100'); + assert.match(url.searchParams.get('fields'), /computed_geometry/); + assert.match(url.searchParams.get('fields'), /is_pano/); +}); + test('with a Graph endpoint, sequence lookups go there without a token', async () => { const { calls, fetchImpl } = fakeFetch(() => jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index f6d5a6d70..a14825d80 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) { return new Uint8Array(0); }, getSequenceImages: async () => [], + nearestImages: async () => [], ...overrides, }; }