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:
German KhokhlovandClaude Opus 5.5 committed 2026-10-08 15:01:42 +04:00
1 parent 07a239fd82
commit 94049c5667
15 files changed
+61 -439

No files matched your search

+2 -51
View File
@@ -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();
},
-105
View File
@@ -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 () => {
-14
View File
@@ -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
View File
@@ -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. */
-2
View File
@@ -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;