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 };
|
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 };
|
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||||
return {
|
return {
|
||||||
...json(400, { error: 'unsupported images query' }),
|
...json(400, { error: 'unsupported images query' }),
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
} from '../../lineCoverage.js';
|
} from '../../lineCoverage.js';
|
||||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||||
import { coverageZoomForHeight } from '../../tileMath.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 { decodeCoverageTile } from './decode.js';
|
||||||
import { createMapillaryViewer } from './viewer.js';
|
import { createMapillaryViewer } from './viewer.js';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +20,7 @@ import {
|
|||||||
MAPILLARY_NAME,
|
MAPILLARY_NAME,
|
||||||
MAPILLARY_PROVIDER_ID,
|
MAPILLARY_PROVIDER_ID,
|
||||||
MAPILLARY_SOURCE_METHODS,
|
MAPILLARY_SOURCE_METHODS,
|
||||||
|
NEAREST_LIMIT,
|
||||||
PICK_PREFIX,
|
PICK_PREFIX,
|
||||||
RATE_LIMITED_MESSAGE,
|
RATE_LIMITED_MESSAGE,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
@@ -193,6 +197,38 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
|
|||||||
}),
|
}),
|
||||||
coverageLines: () => parts.coverage.drawnLines(),
|
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:
|
viewer:
|
||||||
photoViewer ||
|
photoViewer ||
|
||||||
createMapillaryViewer({ source, render: context.services?.render }),
|
createMapillaryViewer({ source, render: context.services?.render }),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
|||||||
'getStatus',
|
'getStatus',
|
||||||
'getTile',
|
'getTile',
|
||||||
'getSequenceImages',
|
'getSequenceImages',
|
||||||
|
'nearestImages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
|
/** 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. */
|
/** Fields requested for a sequence's images. */
|
||||||
export const SEQUENCE_IMAGE_FIELDS =
|
export const SEQUENCE_IMAGE_FIELDS =
|
||||||
'id,captured_at,compass_angle,geometry,is_pano';
|
'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.deepEqual(await status, { configured: false });
|
||||||
assert.equal(instance.coverageStats().keyRequired, true);
|
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 {
|
import {
|
||||||
MAPILLARY_GRAPH_HOST,
|
MAPILLARY_GRAPH_HOST,
|
||||||
|
NEAREST_IMAGE_FIELDS,
|
||||||
|
NEAREST_LIMIT,
|
||||||
SEQUENCE_IMAGE_FIELDS,
|
SEQUENCE_IMAGE_FIELDS,
|
||||||
SEQUENCE_IMAGES_LIMIT,
|
SEQUENCE_IMAGES_LIMIT,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
@@ -147,5 +150,24 @@ export function createMapillarySource({
|
|||||||
);
|
);
|
||||||
return Array.isArray(payload?.data) ? payload.data : [];
|
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 () => {
|
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
|
||||||
const { calls, fetchImpl } = fakeFetch(() =>
|
const { calls, fetchImpl } = fakeFetch(() =>
|
||||||
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
|
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) {
|
|||||||
return new Uint8Array(0);
|
return new Uint8Array(0);
|
||||||
},
|
},
|
||||||
getSequenceImages: async () => [],
|
getSequenceImages: async () => [],
|
||||||
|
nearestImages: async () => [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user