diff --git a/README.md b/README.md index d4534755c..b3827c4b3 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Zoom in to street level (below 60 km) for the green coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters narrow the lines, cones and nearest-photo lookups. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on mapillary.com, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the layer is on. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Zoom in to street level (below 60 km) for the green coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters narrow the lines and cones. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on mapillary.com, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the layer is on. Share links carry the Mapillary switch and the filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). diff --git a/scripts/fixtures/street-level/mapillaryGraph.mjs b/scripts/fixtures/street-level/mapillaryGraph.mjs index 9b44919ca..5653b0092 100644 --- a/scripts/fixtures/street-level/mapillaryGraph.mjs +++ b/scripts/fixtures/street-level/mapillaryGraph.mjs @@ -27,15 +27,6 @@ export const THUMB_HOST = 'qa-fixture.mapillary.com'; const METRES_PER_DEG_LAT = 110_540; -/** Ground distance in metres, as the app's metresBetween. */ -export function metresApart(a, b) { - const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180; - return Math.hypot( - (b.lon - a.lon) * 111_320 * Math.cos(lat), - (b.lat - a.lat) * METRES_PER_DEG_LAT, - ); -} - /** * Photos along PHOTO_LINE, south to north; every third one is a panorama. * @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>} @@ -181,18 +172,6 @@ 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/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 282450023..df5b9ffc7 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -264,6 +264,35 @@ async function assertKeyRequired(page) { assert.match(shown.hint, /MAPILLARY_CLIENT_TOKEN/, 'names the key to add'); } +/** + * Client coordinates of the first thing drawn whose pick id starts with + * `prefix`, scanning `box` ({x, y, w, h} in canvas pixels; default: a row + * across the middle) every `step` px; null when nothing matches. + */ +const findPick = (page, prefix, { box = null, step = 3 } = {}) => + page.evaluate( + (want, area, stride) => { + const { scene } = window.__godsEyeView.viewer; + const rect = scene.canvas.getBoundingClientRect(); + const { x, y, w, h } = area || { + x: 0, + y: Math.round(rect.height / 2), + w: rect.width, + h: 1, + }; + for (let py = y; py < y + h; py += stride) + for (let px = x; px < x + w; px += stride) { + const id = scene.pick({ x: px, y: py })?.id; + if (typeof id === 'string' && id.startsWith(want)) + return { x: rect.left + px, y: rect.top + py, local: { x: px, y: py } }; + } + return null; + }, + prefix, + box, + step, + ); + /** Park the camera over the photo line; a late startup flight can move it. */ async function park(page) { for (let attempt = 0; attempt < 5; attempt++) { @@ -519,14 +548,30 @@ async function main() { await park(page); await waitForCoverage(page); await step( - 'the viewer loads and the nearest photo opens with a caption', + 'a line selects its sequence, and a cone opens its photo with a caption', async () => { - await page.evaluate(() => { - // Fire and forget: the open can outlive one CDP call. - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openNearest(); + // Fixtures answer lookups for the photo sequence only. + const line = await findPick( + page, + fixtures ? `mly:seq:${PHOTO_SEQUENCE_ID}` : 'mly:seq:', + ); + assert.ok(line, 'a sequence line across the middle of the view'); + await page.mouse.click(line.x, line.y); + await uiUntil( + page, + (u) => u.sequence.selectedId && !u.sequence.loading, + ); + const cone = await findPick(page, 'mly:img:', { + box: { + x: Math.max(0, line.local.x - 150), + y: Math.max(0, line.local.y - 150), + w: 300, + h: 300, + }, + step: 8, }); + assert.ok(cone, 'an image cone near the selected line'); + await page.mouse.click(cone.x, cone.y); await uiUntil( page, (u) => (u.street.imageId && !u.street.loading) || u.street.error, diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index e6089d34c..be3e28e79 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -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(); }, diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 069238c51..93556b93d 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -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); diff --git a/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs index 8b3d7fd08..3dcd43b12 100644 --- a/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs @@ -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. diff --git a/src/layers/streetLevel/providers/mapillary/nearest.js b/src/layers/streetLevel/providers/mapillary/nearest.js deleted file mode 100644 index 89832b1e3..000000000 --- a/src/layers/streetLevel/providers/mapillary/nearest.js +++ /dev/null @@ -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; -} diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 36fa2e0b0..0308c1131 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -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 © Mapillary 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'; diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 8871f3173..008697b1b 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -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 : []; - }, }; } diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index e7927e90b..d744e499d 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -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 () => { diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 4cfd98b2c..ead085c35 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -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; diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index 3c8370f3e..d0da93f36 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -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. */ diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index c22ef999c..87d58e85d 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -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; diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index 6ae8d95ed..76a8125bd 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -18,7 +18,6 @@ export function fakeMapillarySource(overrides = {}) { return new Uint8Array(0); }, getSequenceImages: async () => [], - nearestImages: async () => [], ...overrides, }; } diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index e1f3f63d0..e8e7e782a 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -4,7 +4,6 @@ import { fixtureTile } from '../../scripts/qa-street-level.mjs'; import { answerMapillaryRequest, describeCall, - metresApart, PHOTO_LINE, PHOTO_SEQUENCE_ID, photoImages, @@ -73,16 +72,14 @@ test('the photo sequence runs through the parked view, clipped to each tile it c ); }); -test('the fixture photos: one sequence, 360° and flat, close enough for any nearest lookup on the line', () => { +test('the fixture photos: one sequence, 360° and flat, spaced past the cone thinning', () => { const images = photoImages(Date.UTC(2026, 9, 1)); assert.ok(images.length >= 3); assert.ok(images.some((image) => image.isPano)); assert.ok(images.some((image) => !image.isPano)); for (let i = 1; i < images.length; i++) { - const gap = metresApart(images[i - 1], images[i]); - // Above the cones' 3 m thinning, and at most 50 m apart: any point on - // the line has a photo within the Graph API's 50 m radius. - assert.ok(gap > 3 && gap < 50, `gap ${gap}`); + const gap = (images[i].lat - images[i - 1].lat) * 110_540; + assert.ok(gap > 3, `gap ${gap}`); } assert.equal(new Set(images.map((image) => image.id)).size, images.length); }); @@ -97,35 +94,8 @@ const answer = (url, method = 'GET') => { }; }; -test('the Graph fixtures answer the provider’s nearest and sequence lookups', () => { +test('the Graph fixtures answer the provider’s sequence lookups', () => { const images = photoImages(); - const middle = images[Math.floor(images.length / 2)]; - const near = answer( - graph('images', { - lat: String(middle.lat + 0.0001), - lng: String(middle.lon), - radius: '50', - limit: '8', - fields: 'id,geometry,is_pano,captured_at,sequence', - }), - ); - assert.equal(near.status, 200); - assert.ok(near.json.data.length > 0); - assert.ok(near.json.data.some((record) => record.id === middle.id)); - for (const record of near.json.data) { - assert.deepEqual(Object.keys(record).sort(), [ - 'captured_at', - 'geometry', - 'id', - 'is_pano', - 'sequence', - ]); - assert.equal(record.sequence, PHOTO_SEQUENCE_ID); - } - const far = answer( - graph('images', { lat: '0', lng: '0', radius: '50', fields: 'id' }), - ); - assert.deepEqual(far.json.data, [], 'nothing outside the radius'); const sequence = answer( graph('images', { sequence_ids: PHOTO_SEQUENCE_ID,