mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
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:
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] } }] }),
|
||||
|
||||
@@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) {
|
||||
return new Uint8Array(0);
|
||||
},
|
||||
getSequenceImages: async () => [],
|
||||
nearestImages: async () => [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user