From e214129e450dab01dd5d811e5f91d19ae4128d9b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 8 Oct 2026 19:17:45 +0400 Subject: [PATCH] refactor(street-level): drop hover, legend and byte-budget cache; flatten the provider directory - No pointer cursor over coverage lines (no other layer has one); a click test replaces the hover tests. - No legend: one source, one colour. - The on-globe credit lives in index.js; credits.js is gone. - Billboard glyphs are SVG data URLs with the same geometry, so tests need no canvas stub. - The tile cache keeps the 120 most recently used tiles, same 24 h TTL, instead of a byte budget; listTileLayers was test-only. - providers/mapillary/ is flattened into the layer and its policy merged into one policy.js; the source's method list is a literal in the catalog. - One fakeCesiumViewer for the layer tests. - The fixture gate keeps the steps the unit tests do not cover: coverage and credit, a rejected key, line -> cone -> photo, close, a keyless page. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 2 +- package.json | 2 +- scripts/format-scope.json | 24 ++- scripts/package-boundaries.json | 42 ++--- scripts/qa-street-level.mjs | 158 +----------------- server/providers/mapillary/tiles.js | 42 ++--- server/providers/mapillary/trim.js | 13 -- src/app/constructCatalog.js | 3 +- src/layers/streetLevel/cameraFraming.test.mjs | 75 +++------ .../{providers/mapillary => }/coverage.js | 8 +- .../mapillary => }/coverage.test.mjs | 6 +- .../mapillary => }/coverageFixture.mjs | 0 src/layers/streetLevel/credits.js | 32 ---- .../{providers/mapillary => }/decode.js | 2 +- .../{providers/mapillary => }/decode.test.mjs | 0 src/layers/streetLevel/glyphs.js | 88 ++++------ src/layers/streetLevel/index.js | 35 ++-- src/layers/streetLevel/index.test.mjs | 83 +-------- .../mapillary => }/mapillary.test.mjs | 0 src/layers/streetLevel/marker.test.mjs | 7 - src/layers/streetLevel/policy.js | 52 +++++- .../streetLevel/providers/mapillary/policy.js | 63 ------- src/layers/streetLevel/selection.js | 88 +--------- src/layers/streetLevel/selection.test.mjs | 131 ++++----------- .../{providers/mapillary => }/sequences.js | 6 +- .../mapillary => }/sequences.test.mjs | 7 - .../{providers/mapillary => }/source.js | 0 .../{providers/mapillary => }/source.test.mjs | 0 .../{providers/mapillary => }/viewer.js | 0 src/layers/streetLevel/viewerHost.test.mjs | 3 +- src/standalone/layerSources.js | 2 +- src/testSupport/streetLevelFakes.mjs | 83 ++++++++- src/tooling/mapillaryProvider.test.mjs | 37 ++-- src/tooling/streetLevelQa.test.mjs | 91 ---------- src/tools/globePanel.test.mjs | 2 +- src/ui/streetLevelControls.js | 6 +- src/ui/streetLevelControls.test.mjs | 6 - src/ui/streetLevelPresentation.js | 1 - src/ui/styles/street-level.css | 38 +---- src/ui/templates/layer-panels.html | 4 - 41 files changed, 335 insertions(+), 909 deletions(-) rename src/layers/streetLevel/{providers/mapillary => }/coverage.js (98%) rename src/layers/streetLevel/{providers/mapillary => }/coverage.test.mjs (98%) rename src/layers/streetLevel/{providers/mapillary => }/coverageFixture.mjs (100%) delete mode 100644 src/layers/streetLevel/credits.js rename src/layers/streetLevel/{providers/mapillary => }/decode.js (96%) rename src/layers/streetLevel/{providers/mapillary => }/decode.test.mjs (100%) rename src/layers/streetLevel/{providers/mapillary => }/mapillary.test.mjs (100%) delete mode 100644 src/layers/streetLevel/providers/mapillary/policy.js rename src/layers/streetLevel/{providers/mapillary => }/sequences.js (97%) rename src/layers/streetLevel/{providers/mapillary => }/sequences.test.mjs (96%) rename src/layers/streetLevel/{providers/mapillary => }/source.js (100%) rename src/layers/streetLevel/{providers/mapillary => }/source.test.mjs (100%) rename src/layers/streetLevel/{providers/mapillary => }/viewer.js (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cff5f3a3..21a153cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,7 @@ - Street Level: Mapillary street-level imagery (free client token, CC BY-SA 4.0). A right-rail panel holds an ON/OFF pill, 360°/flat and captured-since - filters, a legend and an embedded viewer with EXPAND and FIT/FILL. Coverage + filters and an embedded viewer with EXPAND and FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and Google 3D tiles; opening a photo frames it with one camera flight. Share links carry the switch, the filters and the panel's collapsed state. A server proxy adds the diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index da9d7027b..b26816c71 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3063,7 +3063,7 @@ so the whole photo fits. Browser gates: `npm run qa:street-level -- --url ` runs against real Mapillary; `npm run qa:street-level:fixtures` answers every Mapillary request -from fixtures (`providers/mapillary/coverageFixture.mjs`, +from fixtures (`src/layers/streetLevel/coverageFixture.mjs`, `scripts/fixtures/street-level/`), but its server (default `:4173`) still needs some `MAPILLARY_CLIENT_TOKEN`. CI runs it `--strict` against `vite preview`. diff --git a/package.json b/package.json index d3f456999..e1873e439 100644 --- a/package.json +++ b/package.json @@ -260,7 +260,7 @@ "node": "./server/providers/mapillary.js" }, "./layers/street-level": "./src/layers/streetLevel/index.js", - "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", + "./layers/street-level/mapillary/source": "./src/layers/streetLevel/source.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", "./tools/panel": "./src/tools/panel.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2f6a6de8c..18dad5110 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -862,7 +862,7 @@ "src/ui/streetLevelPresentation.test.mjs", "src/app/layers/streetLevel.js", "src/tooling/mapillaryProvider.test.mjs", - "src/layers/streetLevel/providers/mapillary/source.test.mjs", + "src/layers/streetLevel/source.test.mjs", "src/streetLevelMarkup.test.mjs", "src/layers/streetLevel/index.js", "src/layers/streetLevel/policy.js", @@ -870,18 +870,16 @@ "src/layers/streetLevel/view.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/cameraFraming.js", - "src/layers/streetLevel/credits.js", "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/state.js", - "src/layers/streetLevel/providers/mapillary/policy.js", - "src/layers/streetLevel/providers/mapillary/coverage.js", - "src/layers/streetLevel/providers/mapillary/sequences.js", + "src/layers/streetLevel/coverage.js", + "src/layers/streetLevel/sequences.js", "src/layers/streetLevel/glyphs.js", - "src/layers/streetLevel/providers/mapillary/decode.js", + "src/layers/streetLevel/decode.js", "src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/selection.js", - "src/layers/streetLevel/providers/mapillary/viewer.js", - "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/viewer.js", + "src/layers/streetLevel/source.js", "server/providers/mapillary.js", "server/providers/mapillary/tiles.js", "server/providers/mapillary/trim.js", @@ -892,15 +890,15 @@ "scripts/fixtures/street-level/mapillaryGraph.mjs", "src/layers/streetLevel/filter.test.mjs", "src/layers/streetLevel/view.test.mjs", - "src/layers/streetLevel/providers/mapillary/mapillary.test.mjs", - "src/layers/streetLevel/providers/mapillary/decode.test.mjs", - "src/layers/streetLevel/providers/mapillary/coverage.test.mjs", - "src/layers/streetLevel/providers/mapillary/coverageFixture.mjs", + "src/layers/streetLevel/mapillary.test.mjs", + "src/layers/streetLevel/decode.test.mjs", + "src/layers/streetLevel/coverage.test.mjs", + "src/layers/streetLevel/coverageFixture.mjs", "src/layers/streetLevel/cameraFraming.test.mjs", "src/ui/streetLevelControls.test.mjs", "src/layers/streetLevel/marker.test.mjs", "src/layers/streetLevel/viewerHost.test.mjs", - "src/layers/streetLevel/providers/mapillary/sequences.test.mjs", + "src/layers/streetLevel/sequences.test.mjs", "src/layers/streetLevel/selection.test.mjs", "src/layers/streetLevel/index.test.mjs", "scripts/qa-overpass-offload.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8202f5584..f89fe8de1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1190,23 +1190,21 @@ "modules": [ "src/layers/streetLevel/index.js", "src/layers/streetLevel/viewerHost.js", - "src/layers/streetLevel/credits.js", "src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/filter.js", "src/layers/streetLevel/policy.js", "src/layers/streetLevel/state.js", - "src/layers/streetLevel/providers/mapillary/policy.js", - "src/layers/streetLevel/providers/mapillary/coverage.js", - "src/layers/streetLevel/providers/mapillary/sequences.js", + "src/layers/streetLevel/coverage.js", + "src/layers/streetLevel/sequences.js", "src/layers/streetLevel/glyphs.js", - "src/layers/streetLevel/providers/mapillary/decode.js", - "src/layers/streetLevel/providers/mapillary/coverageFixture.mjs", + "src/layers/streetLevel/decode.js", + "src/layers/streetLevel/coverageFixture.mjs", "src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/selection.js", - "src/layers/streetLevel/providers/mapillary/viewer.js", - "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/viewer.js", + "src/layers/streetLevel/source.js", "scripts/qa-street-level.mjs" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -2130,23 +2128,21 @@ "src/ui/streetLevelPresentation.js", "src/layers/streetLevel/index.js", "src/layers/streetLevel/viewerHost.js", - "src/layers/streetLevel/credits.js", "src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/filter.js", "src/layers/streetLevel/policy.js", "src/layers/streetLevel/state.js", - "src/layers/streetLevel/providers/mapillary/policy.js", - "src/layers/streetLevel/providers/mapillary/coverage.js", - "src/layers/streetLevel/providers/mapillary/sequences.js", + "src/layers/streetLevel/coverage.js", + "src/layers/streetLevel/sequences.js", "src/layers/streetLevel/glyphs.js", - "src/layers/streetLevel/providers/mapillary/decode.js", - "src/layers/streetLevel/providers/mapillary/coverageFixture.mjs", + "src/layers/streetLevel/decode.js", + "src/layers/streetLevel/coverageFixture.mjs", "src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/selection.js", - "src/layers/streetLevel/providers/mapillary/viewer.js", - "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/viewer.js", + "src/layers/streetLevel/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", "src/sources/capability.js", @@ -2645,23 +2641,21 @@ "src/maps/imageryHost.js", "src/layers/streetLevel/index.js", "src/layers/streetLevel/viewerHost.js", - "src/layers/streetLevel/credits.js", "src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/filter.js", "src/layers/streetLevel/policy.js", "src/layers/streetLevel/state.js", - "src/layers/streetLevel/providers/mapillary/policy.js", - "src/layers/streetLevel/providers/mapillary/coverage.js", - "src/layers/streetLevel/providers/mapillary/sequences.js", + "src/layers/streetLevel/coverage.js", + "src/layers/streetLevel/sequences.js", "src/layers/streetLevel/glyphs.js", - "src/layers/streetLevel/providers/mapillary/decode.js", - "src/layers/streetLevel/providers/mapillary/coverageFixture.mjs", + "src/layers/streetLevel/decode.js", + "src/layers/streetLevel/coverageFixture.mjs", "src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/selection.js", - "src/layers/streetLevel/providers/mapillary/viewer.js", - "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/viewer.js", + "src/layers/streetLevel/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", "src/sources/capability.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index ae10892fc..c93da5649 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -12,7 +12,7 @@ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; import { tileBounds } from '../src/layers/streetLevel/tileMath.js'; -import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs'; +import { encodeCoverageTile } from '../src/layers/streetLevel/coverageFixture.mjs'; import { hookRenderErrors, readRenderErrors, @@ -28,7 +28,6 @@ import { } from './fixtures/street-level/mapillaryGraph.mjs'; const VIEWPORT = { width: 1440, height: 900 }; -const PHONE = { width: 390, height: 844 }; /** Over downtown Sacramento, above the fixture photo line, looking down. */ const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 }; const DAY_MS = 86_400_000; @@ -38,7 +37,7 @@ const DAY_MS = 86_400_000; * sequences, plus the photo sequence where PHOTO_LINE crosses the tile. * @returns {Uint8Array} empty below z11, which the layer never asks for */ -export function fixtureTile(z, x, y, now = Date.now()) { +function fixtureTile(z, x, y, now = Date.now()) { if (z < 11) return new Uint8Array(0); const { west, east, south, north } = tileBounds(x, y, z); const at = (t, lo, hi) => lo + (hi - lo) * t; @@ -387,33 +386,7 @@ async function main() { 'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)', ); - await step('the panel starts as a collapsed right-rail strip', async () => { - const box = await page.evaluate(() => { - const panel = document.getElementById('street-level-panel'); - const r = panel.getBoundingClientRect(); - return { - collapsed: panel.classList.contains('collapsed'), - inRail: panel.parentElement.id === 'right-context-rail', - body: getComputedStyle(document.getElementById('sl-body')).display, - width: r.width, - right: r.right, - }; - }); - assert.equal(box.collapsed, true); - assert.equal(box.inRail, true); - assert.equal(box.body, 'none'); - assert.ok(box.width <= 200 && box.right <= VIEWPORT.width); - }); - - await step('expanding the strip shows the body and legend', async () => { - await expandPanel(page); - assert.equal( - await page.$$eval('#sl-legend li', (items) => items.length), - 2, - 'Mapillary and Selected', - ); - }); - + await expandPanel(page); if (!configured) { await step('keyless server: KEY REQUIRED gates the controls', () => assertKeyRequired(page), @@ -443,79 +416,6 @@ async function main() { }, ); - await step('the header pill switches the layer off and on', async () => { - await press(page, '#sl-status'); - await statusIs(page, 'OFF'); - assert.equal( - await page.evaluate(() => - window.__godsEyeView.dataManager.isEnabled('street-level'), - ), - false, - ); - assert.equal((await getUI(page)).coverage.count, 0); - await press(page, '#sl-status'); - await waitForCoverage(page); - await statusIs(page, 'ON'); - }); - - await step('the 360° filter narrows coverage', async () => { - const before = (await getUI(page)).coverage.count; - await press(page, '[data-sl-pano="pano"]'); - await uiUntil( - page, - (u) => u.filter.pano === 'pano' && !u.coverage.loading, - ); - const after = (await getUI(page)).coverage.count; - // Fixtures hold flat sequences too, so the filter must drop some. - assert.ok( - fixtures ? after < before : after <= before, - `${after} vs ${before}`, - ); - await press(page, '[data-sl-pano="all"]'); - await uiUntil( - page, - (u, n) => u.filter.pano === 'all' && u.coverage.count === n, - before, - ); - }); - - await step( - 'the SINCE slider narrows coverage and names its cut-off', - async () => { - const before = (await getUI(page)).coverage.count; - const setStop = (index) => - page.$eval( - '#sl-since', - (input, value) => { - input.value = String(value); - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - }, - index, - ); - await setStop(5); - await uiUntil( - page, - (u) => u.filter.sinceDays === 365 && !u.coverage.loading, - ); - const after = (await getUI(page)).coverage.count; - assert.ok( - fixtures ? after < before : after <= before, - `${after} vs ${before}`, - ); - assert.match( - await page.$eval('#sl-since-label', (node) => node.textContent), - /^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/, - ); - await setStop(0); - await uiUntil( - page, - (u, n) => u.filter.sinceDays === 0 && u.coverage.count === n, - before, - ); - }, - ); - await fixtureStep( 'a rejected key reads KEY REJECTED and clears when switched off', async () => { @@ -549,7 +449,6 @@ async function main() { }, ); - let imageId = null; await park(page); await waitForCoverage(page); await step( @@ -585,7 +484,6 @@ async function main() { ); const { street } = await getUI(page); assert.equal(street.error, null, `viewer error: ${street.error}`); - imageId = street.imageId; await page.waitForSelector('#sl-viewer .mapillary-dom', { timeout: 60_000, }); @@ -606,8 +504,8 @@ async function main() { assert.match(view.link, /mapillary\.com\/app\/\?pKey=/); if (fixtures) { assert.ok( - fixture.images.some((image) => image.id === imageId), - `fixture photo ${imageId}`, + fixture.images.some((image) => image.id === street.imageId), + `fixture photo ${street.imageId}`, ); assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID); } @@ -631,52 +529,6 @@ async function main() { ); }); - await step('on a phone the whole photo fits in the panel', async () => { - // Width alone drives the phone layout; toggling isMobile would reload. - await page.setViewport(PHONE); - await page.waitForFunction((w) => innerWidth === w, {}, PHONE.width); - await expandPanel(page); - await page.evaluate((id) => { - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openImage(id); - }, imageId); - await uiUntil( - page, - (u, id) => u.street.imageId === id && !u.street.loading, - imageId, - 60_000, - ); - // The panel settles its phone layout over a frame or two. - const fits = () => { - const inner = document - .querySelector('.street-level-panel-inner') - .getBoundingClientRect(); - const meta = document - .getElementById('sl-image-meta') - .getBoundingClientRect(); - const viewer = document - .getElementById('sl-viewer') - .getBoundingClientRect(); - return { - cut: Math.round(meta.bottom - inner.bottom), - height: Math.round(viewer.height), - overflowX: document.documentElement.scrollWidth > innerWidth, - }; - }; - await page - .waitForFunction( - `(${fits})().cut <= 1 && (${fits})().height >= 100`, - { timeout: 10_000 }, - ) - .catch(() => {}); - const fit = await page.evaluate(fits); - assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); - assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); - assert.equal(fit.overflowX, false, 'no sideways scroll'); - await page.setViewport(VIEWPORT); - }); - await fixtureStep( 'keyless server (second page): KEY REQUIRED gates the controls', async () => { diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 4c445c90b..4ef77090d 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -18,9 +18,8 @@ const FETCH_TIMEOUT_MS = 60_000; export const TILE_UPSTREAM_CONCURRENCY = 6; /** Tiles change only when new imagery is processed. */ export const TILE_TTL_MS = 24 * 60 * 60 * 1000; -export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; -/** Per-entry cost on top of its bytes, so empty tiles still count. */ -const ENTRY_OVERHEAD_BYTES = 1024; +/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */ +export const TILE_MEMORY_MAX_TILES = 120; /** The client token lives in the browser by design; the server adds it to tile URLs too. */ export function mapillaryToken() { @@ -139,39 +138,30 @@ function unlessAborted(promise, signal) { }); } -/** @type {Map} insertion-ordered LRU */ +/** @type {Map} least recently used first */ const memory = new Map(); -let memoryBytes = 0; /** * Upstream fetches shared per tile, with the requests still waiting on each. * @type {Map, controller: AbortController, waiters: number}>} */ const inFlight = new Map(); -const cost = (bytes) => bytes.length + ENTRY_OVERHEAD_BYTES; - -function forget(key) { - const hit = memory.get(key); - if (!hit) return null; - memory.delete(key); - memoryBytes -= cost(hit.bytes); - return hit; -} function remember(key, bytes, at = Date.now()) { - // One huge (untrimmed) tile must not flush everything else. - if (cost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return; - forget(key); + memory.delete(key); memory.set(key, { bytes, at }); - memoryBytes += cost(bytes); - for (const [oldest] of memory) { - if (memoryBytes <= TILE_MEMORY_BUDGET_BYTES) break; - forget(oldest); + for (const oldest of memory.keys()) { + if (memory.size <= TILE_MEMORY_MAX_TILES) break; + memory.delete(oldest); } } function recall(key) { - const hit = forget(key); - if (!hit || Date.now() - hit.at > TILE_TTL_MS) return null; + const hit = memory.get(key); + if (!hit) return null; + if (Date.now() - hit.at > TILE_TTL_MS) { + memory.delete(key); + return null; + } remember(key, hit.bytes, hit.at); // most recently used again return hit.bytes; } @@ -225,11 +215,5 @@ export async function fetchTile(address, { signal } = {}) { /** Test seam: forget every cached and in-flight tile. */ export function _resetTileCacheForTest() { memory.clear(); - memoryBytes = 0; inFlight.clear(); } - -/** Test seam: how many tiles memory holds and the bytes charged for them. */ -export function _tileMemoryForTest() { - return { entries: memory.size, bytes: memoryBytes }; -} diff --git a/server/providers/mapillary/trim.js b/server/providers/mapillary/trim.js index 90824769e..65f190172 100644 --- a/server/providers/mapillary/trim.js +++ b/server/providers/mapillary/trim.js @@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) { }, null); return dropped ? Buffer.from(writer.finish()) : bytes; } - -/** Names of the layers inside a vector tile, in order. */ -export function listTileLayers(bytes) { - const names = []; - if (!bytes?.length) return names; - new PbfReader(bytes).readFields((tag, _result, pbf) => { - if (tag !== TILE_LAYER_FIELD) return; - const end = pbf.readVarint() + pbf.pos; - names.push(layerName(bytes.subarray(pbf.pos, end))); - pbf.pos = end; - }, null); - return names; -} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index d45567e28..8e7b616e2 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -3,7 +3,6 @@ import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; -import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createApplicationFlights } from './layers/flights.js'; @@ -57,7 +56,7 @@ const SOURCE_METHODS = Object.freeze({ earthquakes: ['getSnapshot'], 'fire-perimeters': ['getSnapshot'], cables: ['fetch'], - mapillary: MAPILLARY_SOURCE_METHODS, + mapillary: ['getStatus', 'getTile', 'getSequenceImages'], }); /** diff --git a/src/layers/streetLevel/cameraFraming.test.mjs b/src/layers/streetLevel/cameraFraming.test.mjs index 7adb194a3..c1a17989e 100644 --- a/src/layers/streetLevel/cameraFraming.test.mjs +++ b/src/layers/streetLevel/cameraFraming.test.mjs @@ -2,48 +2,23 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { createCameraFraming } from './cameraFraming.js'; +import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs'; /** Height (m) of a Cartesian above the WGS84 ellipsoid. */ const heightOf = (cartesian) => Cesium.Cartographic.fromCartesian(cartesian).height; -/** - * A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight` - * cancels the current one, `land()` completes it, `cancelled` counts stops. - */ -function flightCamera(flights) { - let current = null; - function stop() { - const flight = current; - current = null; - flight?.options.cancel?.(); - return Boolean(flight); - } - const camera = { - cancelled: 0, - flyToBoundingSphere(sphere, options = {}) { - stop(); - current = { sphere, options }; - flights.push(current); - }, - cancelFlight() { - if (stop()) camera.cancelled++; - }, - land() { - const flight = current; - current = null; - flight?.options.complete?.(); - }, - }; - return camera; -} - /** * A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles * under the photo load) and whose globe terrain is `globe`. */ function setup({ sampled, globe = null, altitude = 149 }) { - const flights = []; + const viewer = fakeCesiumViewer(); + Object.assign(viewer.scene, { + sampleHeightSupported: true, + sampleHeight: () => sampled, + globe: { getHeight: () => globe }, + }); const state = { services: {}, street: { @@ -51,19 +26,13 @@ function setup({ sampled, globe = null, altitude = 149 }) { bearing: 105, altitude, }, - viewer: { - scene: { - sampleHeightSupported: true, - sampleHeight: () => sampled, - globe: { getHeight: () => globe }, - }, - camera: flightCamera(flights), - }, + viewer, }; return { framing: createCameraFraming({ state }), state, - flights, + viewer, + flights: viewer.flights.list, }; } @@ -88,35 +57,37 @@ test('a sample kilometres underground falls back to the globe, then the image al }); test('cancel stops the framing flight while it is still ours', () => { - const { framing, state } = setup({ sampled: 121 }); - const { camera } = state.viewer; + const { framing, viewer } = setup({ sampled: 121 }); framing.frame(framing.begin()); framing.cancel(); - assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops'); + assert.equal( + viewer.flights.cancelled, + 1, + 'the flight toward the photo stops', + ); framing.cancel(); - assert.equal(camera.cancelled, 1, 'and only once'); + assert.equal(viewer.flights.cancelled, 1, 'and only once'); // Re-framing (the next photo) supersedes the first flight, not the claim. framing.frame(framing.begin()); framing.frame(framing.begin()); framing.cancel(); - assert.equal(camera.cancelled, 2); + assert.equal(viewer.flights.cancelled, 2); }); test('cancel leaves a landed flight and a newer navigation flight alone', () => { - const { framing, state, flights } = setup({ sampled: 121 }); - const { camera } = state.viewer; + const { framing, viewer, flights } = setup({ sampled: 121 }); framing.frame(framing.begin()); - camera.land(); + viewer.flights.land(); framing.cancel(); - assert.equal(camera.cancelled, 0, 'landed: nothing to stop'); + assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop'); framing.frame(framing.begin()); // A search result flies the globe elsewhere; Cesium cancels ours first. - camera.flyToBoundingSphere(null, {}); + viewer.camera.flyToBoundingSphere(null, {}); assert.equal(flights.length, 3); framing.cancel(); - assert.equal(camera.cancelled, 0, 'the newer flight keeps going'); + assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going'); }); /** diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/coverage.js similarity index 98% rename from src/layers/streetLevel/providers/mapillary/coverage.js rename to src/layers/streetLevel/coverage.js index 552bfcd84..100aa986b 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/coverage.js @@ -1,9 +1,9 @@ import * as Cesium from 'cesium'; import { decodeCoverageTile } from './decode.js'; -import { passesImageryFilter, resolveFilter } from '../../filter.js'; -import { isActive } from '../../state.js'; -import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js'; -import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js'; +import { passesImageryFilter, resolveFilter } from './filter.js'; +import { isActive } from './state.js'; +import { groundUnderCamera, viewFocus, visibleBbox } from './view.js'; +import { coverageZoomForHeight, tilesForBbox } from './tileMath.js'; import { COLORS, COVERAGE_LINE_WIDTH_PX, diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/coverage.test.mjs similarity index 98% rename from src/layers/streetLevel/providers/mapillary/coverage.test.mjs rename to src/layers/streetLevel/coverage.test.mjs index 082f7f917..28841ac18 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/coverage.test.mjs @@ -2,11 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { createCoverage, ZOOM_IN_HINT } from './coverage.js'; -import { freshCoverage } from '../../state.js'; +import { freshCoverage } from './state.js'; import { encodeCoverageTile } from './coverageFixture.mjs'; -import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; +import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; import { COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES } from './policy.js'; -import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js'; +import { lonToTileX, latToTileY, tileBounds } from './tileMath.js'; const RAD = Math.PI / 180; // Node has no WebGL context to report line-width limits; a browser's diff --git a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs b/src/layers/streetLevel/coverageFixture.mjs similarity index 100% rename from src/layers/streetLevel/providers/mapillary/coverageFixture.mjs rename to src/layers/streetLevel/coverageFixture.mjs diff --git a/src/layers/streetLevel/credits.js b/src/layers/streetLevel/credits.js deleted file mode 100644 index c28e10a7f..000000000 --- a/src/layers/streetLevel/credits.js +++ /dev/null @@ -1,32 +0,0 @@ -import * as Cesium from 'cesium'; - -/** - * A static Cesium credit shown while the layer draws: CC BY-SA imagery needs - * visible attribution that goes away with the imagery. - */ -export function createCredit(html) { - let shown = null; - - function show(viewer) { - if (shown || !viewer?.creditDisplay) return; - try { - const credit = new Cesium.Credit(html, true); - viewer.creditDisplay.addStaticCredit(credit); - shown = credit; - } catch { - /* credit display unavailable */ - } - } - - function hide(viewer) { - if (!shown) return; - try { - viewer?.creditDisplay?.removeStaticCredit?.(shown); - } catch { - /* credit display already torn down */ - } - shown = null; - } - - return { show, hide }; -} diff --git a/src/layers/streetLevel/providers/mapillary/decode.js b/src/layers/streetLevel/decode.js similarity index 96% rename from src/layers/streetLevel/providers/mapillary/decode.js rename to src/layers/streetLevel/decode.js index ac2538b34..e610489a4 100644 --- a/src/layers/streetLevel/providers/mapillary/decode.js +++ b/src/layers/streetLevel/decode.js @@ -1,6 +1,6 @@ import { PbfReader } from 'pbf'; import { VectorTile } from '@mapbox/vector-tile'; -import { tileLocalToLonLat } from '../../tileMath.js'; +import { tileLocalToLonLat } from './tileMath.js'; /** * Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14 diff --git a/src/layers/streetLevel/providers/mapillary/decode.test.mjs b/src/layers/streetLevel/decode.test.mjs similarity index 100% rename from src/layers/streetLevel/providers/mapillary/decode.test.mjs rename to src/layers/streetLevel/decode.test.mjs diff --git a/src/layers/streetLevel/glyphs.js b/src/layers/streetLevel/glyphs.js index 962b1e47c..7410c3291 100644 --- a/src/layers/streetLevel/glyphs.js +++ b/src/layers/streetLevel/glyphs.js @@ -1,48 +1,27 @@ -/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */ +/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position marker. */ -const _cache = new Map(); +const px = (n) => Number(n.toFixed(2)); -/** A square canvas glyph drawn once per key, centred at (c, c). */ -function cachedGlyph(key, size, draw) { - if (_cache.has(key)) return _cache.get(key); - const element = document.createElement('canvas'); - element.width = size; - element.height = size; - draw(element.getContext('2d'), size / 2); - _cache.set(key, element); - return element; +function svg(size, body) { + const markup = `${body}`; + return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`; } /** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */ -function wedge(ctx, c, radius, halfAngle, color, alpha) { - ctx.beginPath(); - ctx.moveTo(c, c); - ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle); - ctx.closePath(); - ctx.fillStyle = color; - ctx.globalAlpha = alpha; - ctx.fill(); - ctx.globalAlpha = 1; +function wedge(size, radius, halfAngle, color, opacity) { + const c = size / 2; + const dx = px(radius * Math.sin(halfAngle)); + const dy = px(radius * Math.cos(halfAngle)); + const r = px(radius); + return ``; } -function ring(ctx, c, radius, color, width, alpha) { - ctx.beginPath(); - ctx.arc(c, c, radius, 0, Math.PI * 2); - ctx.strokeStyle = color; - ctx.lineWidth = width; - ctx.globalAlpha = alpha; - ctx.stroke(); - ctx.globalAlpha = 1; +function ring(size, radius, color, width, opacity) { + return ``; } -function dot(ctx, c, radius, color, outline, width) { - ctx.beginPath(); - ctx.arc(c, c, radius, 0, Math.PI * 2); - ctx.fillStyle = color; - ctx.fill(); - ctx.lineWidth = width; - ctx.strokeStyle = outline; - ctx.stroke(); +function dot(size, radius, color, outline, width) { + return ``; } /** Cone glyph pointing "up"; rotate the billboard by the compass angle. */ @@ -51,25 +30,28 @@ export function imageConeGlyph({ color = '#e8eaed', pano = false, } = {}) { - return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => { - if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5); - else wedge(ctx, c, size * 0.46, 0.62, color, 0.42); - dot( - ctx, - c, - size * 0.16, - color, - 'rgba(10,10,15,0.85)', - Math.max(1, size * 0.05), - ); - }); + const shape = pano + ? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5) + : wedge(size, size * 0.46, 0.62, color, 0.42); + return svg( + size, + shape + + dot( + size, + size * 0.16, + color, + 'rgba(10,10,15,0.85)', + Math.max(1, size * 0.05), + ), + ); } /** Marker for the image the viewer currently shows. */ export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { - return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => { - wedge(ctx, c, size * 0.48, 0.55, color, 0.55); - dot(ctx, c, size * 0.2, color, '#0a0a0f', 2); - ring(ctx, c, size * 0.3, color, 1.5, 0.8); - }); + return svg( + size, + wedge(size, size * 0.48, 0.55, color, 0.55) + + dot(size, size * 0.2, color, '#0a0a0f', 2) + + ring(size, size * 0.3, color, 1.5, 0.8), + ); } diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 228368388..512638167 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,18 +1,19 @@ +import * as Cesium from 'cesium'; 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, sameFilter } from './filter.js'; -import { keyStatusLabel, 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 { + keyStatusLabel, MAPILLARY_CREDIT_HTML, MAPILLARY_KEY_ID, -} from './providers/mapillary/policy.js'; + STREET_LEVEL_LAYER_ID, +} from './policy.js'; +import { createCoverage } from './coverage.js'; +import { createSequences } from './sequences.js'; +import { createMapillaryViewer } from './viewer.js'; export { STREET_LEVEL_LAYER_ID } from './policy.js'; @@ -33,8 +34,8 @@ const COVERAGE_OFF = Object.freeze({ * The Street Level layer: Mapillary coverage lines at street zoom, a selected * sequence's image cones, and the photo viewer in the panel. * @param {{source: object, services?: object, photoViewer?: object}} options - * `source` is providers/mapillary/source.js or a stand-in; `photoViewer` - * stands in for the MapillaryJS adapter (providers/mapillary/viewer.js). + * `source` is source.js or a stand-in; `photoViewer` stands in for the + * MapillaryJS adapter (viewer.js). */ export function createStreetLevelLayer({ source, @@ -44,7 +45,6 @@ export function createStreetLevelLayer({ const state = createState({ services }); const parts = {}; const context = { state, parts }; - const credit = createCredit(MAPILLARY_CREDIT_HTML); parts.marker = createMarker(context); parts.framing = createCameraFraming(context); parts.coverage = createCoverage({ state, source }); @@ -97,12 +97,25 @@ export function createStreetLevelLayer({ return keyCheck; } + /** The on-globe credit while the layer draws: CC BY-SA imagery needs it. */ + let credit = null; + function showCredit(viewer) { + if (credit || !viewer?.creditDisplay) return; + credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true); + viewer.creditDisplay.addStaticCredit(credit); + } + function hideCredit(viewer) { + if (!credit) return; + viewer?.creditDisplay?.removeStaticCredit?.(credit); + credit = null; + } + function startDrawing() { const viewer = state.viewer; if (!viewer) return; parts.sequences.setVisible(true); parts.coverage.attach(viewer); - credit.show(viewer); + showCredit(viewer); // Without a known key, a switch-on asks again: the server may be back, // or have a key now. if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey(); @@ -116,7 +129,7 @@ export function createStreetLevelLayer({ parts.coverage.unblock(); parts.sequences.clearSelection(); parts.sequences.setVisible(false); - credit.hide(state.viewer); + hideCredit(state.viewer); } /** The share link's `mapillary` switch: off, the layer draws nothing. */ diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 23c6760c4..fa7bb8e5a 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -2,83 +2,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; -import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; +import { MAPILLARY_CREDIT_HTML } from './policy.js'; import { + fakeCesiumViewer, fakeMapillarySource, fakePhotoViewer, - rayCamera, } from '../../testSupport/streetLevelFakes.mjs'; -/** - * A stand-in Cesium viewer the layer can be enabled on: a canvas for the - * click handler, camera events, a primitive list and a credit display. With - * `view`, the camera looks straight down on Sacramento from 900 m, so - * coverage asks for tiles. - */ -function fakeViewer({ view = false } = {}) { - const credits = []; - /** Cesium's flight bookkeeping: a new flight cancels the current one. */ - let flight = null; - const flights = { started: 0, cancelled: 0 }; - const stopFlight = () => { - const current = flight; - flight = null; - current?.cancel?.(); - return Boolean(current); - }; - const canvas = Object.assign(new EventTarget(), { - style: {}, - // Keep Cesium's handler on the canvas; there is no real document here. - disableRootEvents: true, - onwheel: null, - ...(view ? { clientWidth: 100, clientHeight: 100 } : {}), - }); - const camera = Object.assign( - view - ? rayCamera({ - lon: -121.4944, - lat: 38.5816, - altitude: 900, - pitch: -90, - width: 100, - height: 100, - }) - : {}, - { - changed: new Cesium.Event(), - moveStart: new Cesium.Event(), - moveEnd: new Cesium.Event(), - flyToBoundingSphere(sphere, options = {}) { - stopFlight(); - flight = options; - flights.started++; - }, - cancelFlight() { - if (stopFlight()) flights.cancelled++; - }, - }, - ); - return { - credits, - flights, - scene: { - canvas, - primitives: { add: (p) => p, remove() {} }, - groundPrimitives: { add: (p) => p, remove() {} }, - // Enough of a scene for the position marker to clamp to the ground. - frameState: { mode: Cesium.SceneMode.SCENE3D }, - updateHeight: () => () => {}, - getHeight: () => undefined, - }, - camera, - creditDisplay: { - addStaticCredit: (credit) => credits.push(credit), - removeStaticCredit: (credit) => - credits.splice(credits.indexOf(credit), 1), - }, - }; -} - /** * A layer over `source` and `photoViewer`, initialised on a stand-in viewer * and (unless `enable` is false) enabled. @@ -93,18 +23,15 @@ async function startLayer( } = {}, ) { const saved = globalThis.document; - // A pose moves the position marker, whose glyph is drawn on a canvas. - const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); - globalThis.document = Object.assign(new EventTarget(), { - createElement: () => ({ getContext: () => drawing }), - }); + // The click handler listens for Esc on the document. + globalThis.document = new EventTarget(); // Skip Cesium's one-time terrain table download for draped lines. t.mock.method( Cesium.GroundPolylinePrimitive, 'initializeTerrainHeights', async () => {}, ); - const viewer = fakeViewer({ view }); + const viewer = fakeCesiumViewer({ view }); const layer = createStreetLevelLayer({ source, photoViewer }); layer.init(viewer); if (enable) layer.enable(viewer); diff --git a/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs b/src/layers/streetLevel/mapillary.test.mjs similarity index 100% rename from src/layers/streetLevel/providers/mapillary/mapillary.test.mjs rename to src/layers/streetLevel/mapillary.test.mjs diff --git a/src/layers/streetLevel/marker.test.mjs b/src/layers/streetLevel/marker.test.mjs index cbea37a67..192016f0c 100644 --- a/src/layers/streetLevel/marker.test.mjs +++ b/src/layers/streetLevel/marker.test.mjs @@ -3,13 +3,6 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { createMarker } from './marker.js'; -// The marker glyph draws on a canvas; Node has none, so drawing is a no-op. -const noop = () => {}; -const context2d = new Proxy({}, { get: () => noop, set: () => true }); -globalThis.document ??= { - createElement: () => ({ getContext: () => context2d }), -}; - /** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */ function setup() { const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 0269ddcad..02a00030a 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -1,11 +1,21 @@ -/** Identity and shared tuning for the Street Level layer. */ +/** Identity and tuning for the Street Level layer and its Mapillary imagery. */ export const STREET_LEVEL_LAYER_ID = 'street-level'; +export const MAPILLARY_KEY_ID = 'mapillary'; +export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com'; /** Pick id of the viewer position marker. */ export const POSITION_PICK_ID = 'sl:pos'; -/** The selection highlight and the position marker. */ +/** Stable id prefixes for picked primitives; every one starts with `mly:`. */ +export const PICK_PREFIX = Object.freeze({ + root: 'mly:', + sequence: 'mly:seq:', + image: 'mly:img:', +}); + +/** Mapillary green for lines and cones, GEV cyan for the selection, amber for the marker. */ export const COLORS = Object.freeze({ + coverage: '#05cb63', selected: '#00d4ff', position: '#ffb300', }); @@ -19,6 +29,44 @@ export const MAX_SINCE_DAYS = 36_500; /** Filter the layer starts with: all imagery, any date. */ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); +/** Camera-driven coverage refresh. */ +export const COVERAGE_MOVE_DEBOUNCE_MS = 320; +export const COVERAGE_MAX_TILES = 9; +/** Shown when Mapillary refuses the server's token (401/403). */ +export const KEY_REJECTED_MESSAGE = + 'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings'; +/** Shown while Mapillary rate-limits tile requests (429). */ +export const RATE_LIMITED_MESSAGE = + 'Coverage requests are being rate-limited — they resume on their own'; +/** + * Street-zoom coverage reaches this far from the camera: camera height times + * the factor, never below the floor, even when a tilted view sees further. + */ +export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500; +export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10; +export const COVERAGE_MAX_SEQUENCES = 6000; +export const COVERAGE_LINE_WIDTH_PX = 2.5; + +/** Per-sequence image cones after a sequence is selected. */ +export const SEQUENCE_IMAGES_LIMIT = 2000; +export const IMAGE_CONE_SIZE_PX = 26; +export const IMAGE_CONE_MIN_SPACING_M = 3; + +/** 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'; + +/** Deep link to an image on mapillary.com, as the web app shares them. */ +export function mapillaryImageUrl(imageId) { + const id = String(imageId || '').trim(); + if (!id) return 'https://www.mapillary.com/app/'; + return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`; +} + +/** Fields requested for a sequence's images. */ +export const SEQUENCE_IMAGE_FIELDS = + 'id,captured_at,compass_angle,geometry,is_pano'; + /** The key gate's label for the pill and the layer list, or null when the key is fine. */ export function keyStatusLabel({ keyRequired, keyRejected }) { if (keyRejected) return 'KEY REJECTED'; diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js deleted file mode 100644 index 60e079fef..000000000 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ /dev/null @@ -1,63 +0,0 @@ -import { COLORS as SHARED_COLORS } from '../../policy.js'; - -/** Identity and tuning for Street Level's Mapillary imagery. */ -export const MAPILLARY_KEY_ID = 'mapillary'; -export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com'; - -/** Methods a source (./source.js or a stand-in) must have. */ -export const MAPILLARY_SOURCE_METHODS = Object.freeze([ - 'getStatus', - 'getTile', - 'getSequenceImages', -]); - -/** Stable id prefixes for picked primitives; every one starts with `mly:`. */ -export const PICK_PREFIX = Object.freeze({ - root: 'mly:', - sequence: 'mly:seq:', - image: 'mly:img:', -}); - -/** Mapillary green for lines, cones and the legend; GEV cyan for selection. */ -export const COLORS = Object.freeze({ - coverage: '#05cb63', - selected: SHARED_COLORS.selected, -}); - -/** Camera-driven coverage refresh. */ -export const COVERAGE_MOVE_DEBOUNCE_MS = 320; -export const COVERAGE_MAX_TILES = 9; -/** Shown when Mapillary refuses the server's token (401/403). */ -export const KEY_REJECTED_MESSAGE = - 'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings'; -/** Shown while Mapillary rate-limits tile requests (429). */ -export const RATE_LIMITED_MESSAGE = - 'Coverage requests are being rate-limited — they resume on their own'; -/** - * Street-zoom coverage reaches this far from the camera: camera height times - * the factor, never below the floor, even when a tilted view sees further. - */ -export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500; -export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10; -export const COVERAGE_MAX_SEQUENCES = 6000; -export const COVERAGE_LINE_WIDTH_PX = 2.5; - -/** Per-sequence image cones after a sequence is selected. */ -export const SEQUENCE_IMAGES_LIMIT = 2000; -export const IMAGE_CONE_SIZE_PX = 26; -export const IMAGE_CONE_MIN_SPACING_M = 3; - -/** 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'; - -/** Deep link to an image on mapillary.com, as the web app shares them. */ -export function mapillaryImageUrl(imageId) { - const id = String(imageId || '').trim(); - if (!id) return 'https://www.mapillary.com/app/'; - return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`; -} - -/** 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/selection.js b/src/layers/streetLevel/selection.js index c11be6619..63688b42c 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -1,9 +1,12 @@ import * as Cesium from 'cesium'; -import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js'; -import { PICK_PREFIX } from './providers/mapillary/policy.js'; -import { sequenceIdFromPick } from './providers/mapillary/coverage.js'; +import { + PICK_PREFIX, + POSITION_PICK_ID, + STREET_LEVEL_LAYER_ID, +} from './policy.js'; +import { sequenceIdFromPick } from './coverage.js'; -/** One click/hover handler for coverage lines, image cones and the marker. */ +/** One click handler for coverage lines, image cones and the marker. */ export function createSelection({ state, parts }) { const { picking, input } = state.services; @@ -51,67 +54,6 @@ export function createSelection({ state, parts }) { parts.sequences.clearSelection(); } - /** The pointer over the globe, and whether a frame is queued to pick it. */ - let pointer = null; - let hoverQueued = false; - let hoverCursor = false; - /** Buttons held: any of them, with any modifier, is a camera drag. */ - let pointerButtons = 0; - let removeHoverWatchers = null; - - function setHoverCursor(canvas, hit) { - if (hit === hoverCursor) return; - canvas.style.cursor = hit ? 'pointer' : ''; - hoverCursor = hit; - } - - function pickHover() { - hoverQueued = false; - const canvas = state.viewer?.scene?.canvas; - // Switched off (or torn down) since the frame was queued, or dragging. - if (!canvas || !state.enabled || !state.clickHandler || pointerButtons) - return; - let hit = false; - try { - const picked = state.viewer.scene.pick(pointer); - hit = ownsPick( - picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id, - ); - } catch { - hit = false; - } - setHoverCursor(canvas, hit); - } - - /** Pick the pointer on the next frame; at most one pick a frame. */ - function queueHoverPick() { - if (hoverQueued || !pointer || pointerButtons) return; - hoverQueued = true; - requestAnimationFrame(pickHover); - } - - /** Pointer cursor over anything this layer owns. */ - function onMove(movement) { - if (!state.enabled) return; - pointer = Cesium.Cartesian2.clone(movement.endPosition, pointer); - queueHoverPick(); - } - - /** Track held buttons, and pick again once the camera rests under a still pointer. */ - function watchHover(viewer) { - const canvas = viewer.scene.canvas; - const onButtons = (event) => { - pointerButtons = event.buttons ?? 0; - }; - const types = ['pointerdown', 'pointermove', 'pointerup']; - for (const type of types) canvas.addEventListener?.(type, onButtons); - const removeEnd = viewer.camera?.moveEnd?.addEventListener(queueHoverPick); - removeHoverWatchers = () => { - for (const type of types) canvas.removeEventListener?.(type, onButtons); - removeEnd?.(); - }; - } - function install(viewer) { if (state.clickHandler) return; state.clickHandler = new Cesium.ScreenSpaceEventHandler( @@ -121,11 +63,6 @@ export function createSelection({ state, parts }) { onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK, ); - state.clickHandler.setInputAction( - onMove, - Cesium.ScreenSpaceEventType.MOUSE_MOVE, - ); - watchHover(viewer); document.addEventListener('keydown', onKeyDown); picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick); } @@ -133,16 +70,7 @@ export function createSelection({ state, parts }) { function uninstall() { // Nothing to undo for a layer that was never enabled. if (!state.clickHandler) return; - removeHoverWatchers?.(); - removeHoverWatchers = null; - pointer = null; - pointerButtons = 0; - if (hoverCursor && state.viewer?.scene?.canvas) { - state.viewer.scene.canvas.style.cursor = ''; - hoverCursor = false; - } - if (state.clickHandler && !state.clickHandler.isDestroyed()) - state.clickHandler.destroy(); + if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy(); state.clickHandler = null; document.removeEventListener('keydown', onKeyDown); picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID); diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index be9bf304d..b36c701d7 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -3,17 +3,9 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { createSelection } from './selection.js'; -/** - * The selection installed on a stand-in viewer. Animation frames queue until - * `frame()` runs them. - */ +/** The selection installed on a stand-in viewer, with what its clicks reach. */ function harness({ selected = false } = {}) { - const saved = { - document: globalThis.document, - requestAnimationFrame: globalThis.requestAnimationFrame, - }; - const frames = []; - globalThis.requestAnimationFrame = (task) => frames.push(task); + const saved = globalThis.document; globalThis.document = new EventTarget(); // The document's keydown listeners, so a test can hand them any target. const keyListeners = new Set(); @@ -31,26 +23,17 @@ function harness({ selected = false } = {}) { if (type === 'keydown') keyListeners.delete(listener); removeListener(type, listener, options); }; - const sequences = { selected, cleared: 0 }; - const picks = []; - /** What the pointer is over: a line this layer owns, or nothing. */ + const sequences = { selected, cleared: 0, selects: [] }; + const opened = []; + /** What the pointer is over: a pick id, or nothing. */ const scene = { under: null }; const canvas = Object.assign(new EventTarget(), { - style: {}, // Keep Cesium's handler on the canvas; there is no real document here. disableRootEvents: true, onwheel: null, }); - const camera = { moveEnd: new Cesium.Event() }; const viewer = { - camera, - scene: { - canvas, - pick(position) { - picks.push(position); - return scene.under && { id: scene.under }; - }, - }, + scene: { canvas, pick: () => scene.under && { id: scene.under } }, }; const state = { viewer, @@ -68,20 +51,24 @@ function harness({ selected = false } = {}) { state, parts: { sequences: { + select: (id) => sequences.selects.push(id), clearSelection() { sequences.cleared++; sequences.selected = false; }, }, + openImage: (id) => opened.push(id), }, }); selection.install(viewer); const action = (type) => state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]); - const onMove = action('MOUSE_MOVE'); return { state, sequences, + opened, + scene, + click: () => action('LEFT_CLICK')({ position: { x: 10, y: 10 } }), /** A keydown reaching the document from `target`; the event as handled. */ key(key, target, { defaultPrevented = false } = {}) { const event = { @@ -95,94 +82,32 @@ function harness({ selected = false } = {}) { for (const listener of [...keyListeners]) listener(event); return event; }, - scene, - canvas, - picks, - move: (x) => onMove({ endPosition: { x, y: 10 } }), - camera, - /** A pointer event on the canvas with `buttons` held. */ - buttons(type, buttons) { - const event = new Event(type); - Object.defineProperty(event, 'buttons', { value: buttons }); - canvas.dispatchEvent(event); - }, - frame() { - for (const task of frames.splice(0)) task(); - }, restore() { selection.uninstall(); - Object.assign(globalThis, saved); + globalThis.document = saved; }, }; } -test('hover picks once a frame, at the latest position, and shows a pointer over a line', () => { +test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => { const h = harness(); try { - h.scene.under = 'mly:seq:1'; - for (let x = 0; x < 5; x++) h.move(x); - h.frame(); - assert.deepEqual( - h.picks.map(({ x }) => x), - [4], - 'one pick for the burst', - ); - assert.equal(h.canvas.style.cursor, 'pointer'); - h.scene.under = null; - h.move(6); - h.frame(); - assert.equal(h.canvas.style.cursor, '', 'off the line'); - h.frame(); - assert.equal(h.picks.length, 2, 'a still pointer costs nothing'); - } finally { - h.restore(); - } -}); - -test('a drag with any button picks nothing, and a release with a modifier held does not stick', () => { - const h = harness(); - try { - for (const held of [1, 2, 4]) { - h.buttons('pointerdown', held); - h.move(held); - h.frame(); + h.scene.under = 'mly:seq:abc'; + h.click(); + assert.deepEqual(h.sequences.selects, ['abc']); + h.scene.under = 'mly:img:42'; + h.click(); + assert.deepEqual(h.opened, ['42']); + for (const under of [null, 'cctv:7', 'sl:pos']) { + h.scene.under = under; + h.click(); } - assert.equal(h.picks.length, 0, 'left, right and middle drags'); - // Released with Shift held: Cesium's plain LEFT_UP never fires. - h.buttons('pointerup', 0); - h.move(5); - h.frame(); - assert.equal(h.picks.length, 1, 'hover picks again'); - } finally { - h.restore(); - } -}); - -test('when the camera rests, a still pointer is picked again so the cursor cannot stick', () => { - const h = harness(); - try { - h.scene.under = 'mly:seq:1'; - h.move(5); - h.frame(); - assert.equal(h.canvas.style.cursor, 'pointer'); - // The camera moves the line out from under a still pointer. - h.scene.under = null; - h.camera.moveEnd.raiseEvent(); - h.frame(); - assert.equal(h.picks.length, 2); - assert.equal(h.canvas.style.cursor, ''); - } finally { - h.restore(); - } -}); - -test('a hover frame queued before the layer went off does not pick', () => { - const h = harness(); - try { - h.move(2); - h.state.enabled = false; - h.frame(); - assert.equal(h.picks.length, 0, 'the layer went off'); + assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]); + // Mapillary switched off under a layer that is on: lines are not drawn. + h.state.providerOn = false; + h.scene.under = 'mly:seq:abc'; + h.click(); + assert.deepEqual(h.sequences.selects, ['abc']); } finally { h.restore(); } diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/sequences.js similarity index 97% rename from src/layers/streetLevel/providers/mapillary/sequences.js rename to src/layers/streetLevel/sequences.js index e86d5b6f9..a82aa2c88 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/sequences.js @@ -1,7 +1,7 @@ import * as Cesium from 'cesium'; -import { imageConeGlyph } from '../../glyphs.js'; -import { passesImageryFilter, resolveFilter } from '../../filter.js'; -import { createHorizonCull, metresBetween } from '../../view.js'; +import { imageConeGlyph } from './glyphs.js'; +import { passesImageryFilter, resolveFilter } from './filter.js'; +import { createHorizonCull, metresBetween } from './view.js'; import { COLORS, IMAGE_CONE_MIN_SPACING_M, diff --git a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs b/src/layers/streetLevel/sequences.test.mjs similarity index 96% rename from src/layers/streetLevel/providers/mapillary/sequences.test.mjs rename to src/layers/streetLevel/sequences.test.mjs index 5e5a1886b..e640408b1 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs +++ b/src/layers/streetLevel/sequences.test.mjs @@ -3,13 +3,6 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { createSequences } from './sequences.js'; -// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op. -const noop = () => {}; -const context2d = new Proxy({}, { get: () => noop, set: () => true }); -globalThis.document ??= { - createElement: () => ({ getContext: () => context2d }), -}; - const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); /** A billboard collection that remembers the cones on the globe. */ diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/source.js similarity index 100% rename from src/layers/streetLevel/providers/mapillary/source.js rename to src/layers/streetLevel/source.js diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/source.test.mjs similarity index 100% rename from src/layers/streetLevel/providers/mapillary/source.test.mjs rename to src/layers/streetLevel/source.test.mjs diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/viewer.js similarity index 100% rename from src/layers/streetLevel/providers/mapillary/viewer.js rename to src/layers/streetLevel/viewer.js diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 59c6b9d6f..cfdc8ee45 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -27,8 +27,7 @@ registerHooks({ return { format: 'module', shortCircuit: true, source }; }, }); -const { createMapillaryViewer } = - await import('./providers/mapillary/viewer.js'); +const { createMapillaryViewer } = await import('./viewer.js'); /** * A viewer host over `adapter`. `marker` records where the position marker diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index ffd975231..3a0956ef6 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -17,7 +17,7 @@ import { createWeatherSource } from '../layers/weather/source.js'; import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; -import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; +import { createMapillarySource } from '../layers/streetLevel/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index de9b739e4..e69651bba 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -1,4 +1,4 @@ -/** Shared Street Level test stand-ins: Mapillary source, photo viewer, ray-casting camera. */ +/** Shared Street Level test stand-ins: Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */ import * as Cesium from 'cesium'; const RAD = Math.PI / 180; @@ -48,6 +48,87 @@ export function fakePhotoViewer({ pose = null } = {}) { }; } +/** + * A stand-in Cesium viewer a layer can be enabled on: a canvas for the click + * handler, camera events with Cesium's flight bookkeeping (`flights` counts + * them, lists them and can `land` the current one), primitive lists and a + * credit display. With `view`, the camera looks straight down on Sacramento + * from 900 m, so coverage asks for tiles. + */ +export function fakeCesiumViewer({ view = false } = {}) { + const credits = []; + let flight = null; + const flights = { + started: 0, + cancelled: 0, + /** Every flight asked for, oldest first: `{ sphere, options }`. */ + list: [], + land() { + const current = flight; + flight = null; + current?.options.complete?.(); + }, + }; + const stopFlight = () => { + const current = flight; + flight = null; + current?.options.cancel?.(); + return Boolean(current); + }; + const canvas = Object.assign(new EventTarget(), { + style: {}, + // Keep Cesium's handler on the canvas; there is no real document here. + disableRootEvents: true, + onwheel: null, + ...(view ? { clientWidth: 100, clientHeight: 100 } : {}), + }); + const camera = Object.assign( + view + ? rayCamera({ + lon: -121.4944, + lat: 38.5816, + altitude: 900, + pitch: -90, + width: 100, + height: 100, + }) + : {}, + { + changed: new Cesium.Event(), + moveStart: new Cesium.Event(), + moveEnd: new Cesium.Event(), + flyToBoundingSphere(sphere, options = {}) { + stopFlight(); + flight = { sphere, options }; + flights.list.push(flight); + flights.started++; + }, + cancelFlight() { + if (stopFlight()) flights.cancelled++; + }, + }, + ); + return { + credits, + flights, + scene: { + canvas, + primitives: { add: (p) => p, remove() {} }, + groundPrimitives: { add: (p) => p, remove() {} }, + // Enough of a scene for the position marker to clamp to the ground. + frameState: { mode: Cesium.SceneMode.SCENE3D }, + updateHeight: () => () => {}, + getHeight: () => undefined, + }, + camera, + creditDisplay: { + addStaticCredit: (credit) => credits.push(credit), + removeStaticCredit: (credit) => + credits.splice(credits.indexOf(credit), 1), + }, + }; +} + /** * A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and * `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets. diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs index ce894cede..b560eb6fe 100644 --- a/src/tooling/mapillaryProvider.test.mjs +++ b/src/tooling/mapillaryProvider.test.mjs @@ -5,19 +5,15 @@ import { mapillaryProxy, TILE_ROUTE_MAX_PER_MIN, } from 'gods-eye-view/server/providers/mapillary'; -import { - listTileLayers, - stripTileLayers, -} from '../../server/providers/mapillary/trim.js'; +import { stripTileLayers } from '../../server/providers/mapillary/trim.js'; import { TILE_MAX_BYTES, - TILE_MEMORY_BUDGET_BYTES, + TILE_MEMORY_MAX_TILES, TILE_UPSTREAM_CONCURRENCY, TILE_TTL_MS, fetchTile, parseTilePath, _resetTileCacheForTest, - _tileMemoryForTest, } from '../../server/providers/mapillary/tiles.js'; /** Build a minimal MVT: layers with a name, a version and one opaque feature. */ @@ -545,26 +541,18 @@ test('a tile held in memory past its TTL is fetched again', async (t) => { ); }); -test('the memory cache evicts the least recently used tile past its budget', async () => { - const MB = 1024 * 1024; - // Three 30 MB tiles fit in the budget, a fourth does not. - const big = tile([{ name: 'sequence', payload: Buffer.alloc(30 * MB) }]); - assert.ok(3 * big.length < TILE_MEMORY_BUDGET_BYTES); - assert.ok(4 * big.length > TILE_MEMORY_BUDGET_BYTES); +test('the memory cache evicts the least recently used tile past its size', async () => { await withUpstream( - () => ok(big), - async ({ calls, call }) => { + () => ok(), + async ({ calls }) => { const get = async (x) => - (await call('/api/mapillary/tiles', `/coverage/14/${x}/20`)).headers[ - 'x-gev-cache' - ]; - for (const x of [20, 21, 22]) assert.equal(await get(x), 'upstream'); - assert.equal(await get(20), 'memory', 'A is used again'); - assert.equal(await get(23), 'upstream'); - assert.equal(_tileMemoryForTest().entries, 3); - for (const x of [20, 22, 23]) assert.equal(await get(x), 'memory'); - assert.equal(await get(21), 'upstream', 'B was evicted'); - assert.equal(calls.length, 5); + (await fetchTile(parseTilePath(`/coverage/14/${x}/20`))).source; + for (let x = 0; x < TILE_MEMORY_MAX_TILES; x++) await get(x); + assert.equal(await get(0), 'memory', 'the first is used again'); + await get(TILE_MEMORY_MAX_TILES); + assert.equal(await get(0), 'memory', 'so it stays'); + assert.equal(await get(1), 'upstream', 'the least recently used went'); + assert.equal(calls.length, TILE_MEMORY_MAX_TILES + 2); }, ); }); @@ -578,7 +566,6 @@ test('dropping a layer keeps the others byte-for-byte', () => { { name: 'overview' }, ]); const trimmed = stripTileLayers(bytes, ['image']); - assert.deepEqual(listTileLayers(trimmed), ['sequence', 'overview']); assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }])); }); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index e8e7e782a..c0f37384e 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -1,89 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { fixtureTile } from '../../scripts/qa-street-level.mjs'; import { answerMapillaryRequest, describeCall, - PHOTO_LINE, PHOTO_SEQUENCE_ID, photoImages, THUMB_HOST, } from '../../scripts/fixtures/street-level/mapillaryGraph.mjs'; import { redact } from '../../scripts/qa-browserEvidence.mjs'; -test('fixture tiles give the hermetic gate something real to filter', async () => { - const { decodeCoverageTile } = - await import('../layers/streetLevel/providers/mapillary/decode.js'); - const now = Date.UTC(2026, 9, 1); - const street = decodeCoverageTile(fixtureTile(14, 2662, 6286, now), { - x: 2662, - y: 6286, - z: 14, - }); - const grid = street.sequences.filter((s) => s.id !== PHOTO_SEQUENCE_ID); - assert.equal(grid.length, 8); - const pano = grid.filter((s) => s.isPano).length; - assert.ok(pano > 0 && pano < 8, '360° and flat both present'); - const year = 365 * 86_400_000; - const old = grid.filter((s) => now - s.capturedAt > year).length; - assert.ok(old > 0 && old < 8, 'recent and older both present'); - assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit'); - assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between'); -}); - -/* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */ - -test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => { - const { decodeCoverageTile } = - await import('../layers/streetLevel/providers/mapillary/decode.js'); - const { lonToTileX, latToTileY } = - await import('../layers/streetLevel/tileMath.js'); - const x = lonToTileX(PHOTO_LINE.lon, 14); - const rows = new Set( - [PHOTO_LINE.south, PHOTO_LINE.north].map((lat) => latToTileY(lat, 14)), - ); - assert.ok(rows.size > 1, 'the line crosses a tile edge'); - let south = Infinity; - let north = -Infinity; - for (const y of rows) { - const line = decodeCoverageTile(fixtureTile(14, x, y), { - x, - y, - z: 14, - }).sequences.find((s) => s.id === PHOTO_SEQUENCE_ID); - assert.ok(line, `tile 14/${x}/${y} carries the photo line`); - for (const [lon, lat] of line.parts[0]) { - assert.ok(Math.abs(lon - PHOTO_LINE.lon) < 1e-4); - south = Math.min(south, lat); - north = Math.max(north, lat); - } - } - assert.ok(Math.abs(south - PHOTO_LINE.south) < 1e-4); - assert.ok(Math.abs(north - PHOTO_LINE.north) < 1e-4); - // Far away there is no photo line, only the grid. - const elsewhere = decodeCoverageTile(fixtureTile(14, 100, 100), { - x: 100, - y: 100, - z: 14, - }); - assert.equal( - elsewhere.sequences.some((s) => s.id === PHOTO_SEQUENCE_ID), - false, - ); -}); - -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 = (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); -}); - const graph = (path, params) => `https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`; const answer = (url, method = 'GET') => { @@ -94,22 +19,6 @@ const answer = (url, method = 'GET') => { }; }; -test('the Graph fixtures answer the provider’s sequence lookups', () => { - const images = photoImages(); - const sequence = answer( - graph('images', { - sequence_ids: PHOTO_SEQUENCE_ID, - fields: 'id,geometry,compass_angle,captured_at,is_pano', - limit: '2000', - }), - ); - assert.equal(sequence.json.data.length, images.length); - assert.deepEqual( - answer(graph('images', { sequence_ids: 'other', fields: 'id' })).json.data, - [], - ); -}); - test('the Graph fixtures answer every call MapillaryJS makes to open a photo', () => { const [first, second] = photoImages(); const spatial = answer( diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 5c65596bf..10a679a06 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -7,7 +7,7 @@ import { } from './globePanel.js'; import { panelRuntime } from '../app/globePanelRuntime.js'; import { composeCatalog, coreTools } from './index.js'; -import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/providers/mapillary/policy.js'; +import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/policy.js'; /** Whether a CSP source list allows a URL (exact origin or `https://*.host`). */ function allows(sources, url) { diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 7fe42a1d3..84e2c3666 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -24,8 +24,8 @@ const setAttr = (node, key, value) => { }; /** - * Fills the Street Level panel body: filters, legend and viewer. The panel - * chrome itself belongs to the application shell. + * Fills the Street Level panel body: filters and viewer. The panel chrome + * itself belongs to the application shell. */ export class StreetLevelControls { constructor({ root, layer, actions }) { @@ -58,7 +58,6 @@ export class StreetLevelControls { errorText: byId('sl-error-text'), sinceRange: byId('sl-since'), sinceLabel: byId('sl-since-label'), - legend: byId('sl-legend'), viewerWrap: byId('sl-viewer-wrap'), viewerExpand: byId('sl-viewer-expand'), viewerClose: byId('sl-viewer-close'), @@ -376,7 +375,6 @@ export class StreetLevelControls { setAttr(el.sinceRange, 'aria-valuetext', view.since.label); } } - setProp(el.legend, 'hidden', !view.legend); } _renderViewer(view) { diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index fa1168b02..0593db7ea 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -223,7 +223,6 @@ function panelDom({ fullscreen = true, popover = false } = {}) { add(controls, 'button', { dataset: { slPano: pano } }); add(controls, 'input', { id: 'sl-since' }); add(controls, 'output', { id: 'sl-since-label' }); - add(root, 'ul', { id: 'sl-legend' }); add(root, 'div', { id: 'sl-coverage-meta' }); return { document, root, wrap }; } @@ -475,11 +474,6 @@ test('a layer that is on with Mapillary switched off reads OFF, and the pill bri dom.root.querySelector('#sl-coverage-meta').textContent, /Mapillary is off in this view/, ); - assert.equal( - dom.root.querySelector('#sl-legend').hidden, - true, - 'no legend', - ); status.click(); await settle(); assert.deepEqual(calls.setParams, [ diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 0ada603f8..8f5d39672 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -130,7 +130,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { null, filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 }, since: presentSince(filter.sinceDays, now), - legend: state.providerOn !== false, viewer: presentViewer(state), meta: presentMeta(state), }; diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 0381a5c39..f48510a24 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -118,7 +118,7 @@ color: #ff8a80; } -/* ── Filters and legend ──────────────────────────────────────────────────── */ +/* ── Filters ──────────────────────────────────────────────────── */ .sl-filters { display: flex; align-items: center; @@ -196,42 +196,6 @@ color: var(--text-primary); } -.sl-legend { - display: flex; - flex-wrap: wrap; - gap: 4px 12px; - margin: 0; - padding: 0; - list-style: none; -} - -.sl-legend li { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: var(--font-mono); - font-size: var(--sl-text); - letter-spacing: 0.06em; - color: var(--text-secondary); -} - -.sl-legend[hidden] { - display: none; -} - -.sl-legend-swatch { - width: 14px; - height: 3px; - border-radius: 2px; - display: inline-block; - background: var(--sl-green); -} - -/* COLORS.selected in src/layers/streetLevel/policy.js */ -.sl-legend-swatch.is-selected { - background: #00d4ff; -} - /* ── Embedded viewer ─────────────────────────────────────────────────────── */ .sl-viewer-wrap { position: relative; diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index efc70870a..e5fe73320 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -124,10 +124,6 @@ ANY DATE -
    -
  • Mapillary
  • -
  • Selected
  • -