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.
This commit is contained in:
Said Turksever committed 2026-11-03 14:32:03 +00:00
1 parent 98de41c94a
commit ed2703d345
7 files changed
+142

No files matched your search

@@ -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' }),
@@ -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 }),
@@ -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';
@@ -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);
});
@@ -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 : [];
},
};
}
@@ -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] } }] }),
+1
View File
@@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) {
return new Uint8Array(0);
},
getSequenceImages: async () => [],
nearestImages: async () => [],
...overrides,
};
}