diff --git a/CHANGELOG.md b/CHANGELOG.md index c42d5eb2c..b312678ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,14 @@ ## [Unreleased] -- Street Level: a street-level imagery layer modelled on the iD editor's photo - overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0 - imagery). One right-rail panel holds an ON/OFF pill, shared 360Β°/flat and - captured-since filters, a legend, and an embedded viewer with full-screen - EXPAND and FIT/FILL. Coverage lines and image cones are drawn from orbit - down to a single street, draped on the terrain and on Google 3D tiles; - opening a photo frames it with one camera flight. - Share links carry the provider switches, the filters and the panel's collapsed +- Street Level: Mapillary street-level imagery modelled on the iD editor's + photo overlay (free client token, CC BY-SA 4.0 imagery). One right-rail panel + holds an ON/OFF pill, 360Β°/flat and captured-since filters, a legend, and an + embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines (below + 60 km; from orbit the panel says to zoom in) and image cones are draped on + the terrain and on Google 3D tiles; MapillaryJS loads on the first photo, + and opening one frames it with one camera flight. + Share links carry the Mapillary switch, the filters and the panel's collapsed state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk), refuses cross-site requests, rate-limits per IP and backs off when Mapillary rejects the token or rate-limits. diff --git a/README.md b/README.md index 90342371d..6a976c7a5 100644 --- a/README.md +++ b/README.md @@ -334,13 +334,13 @@ _The Space Missions layer replaying a Falcon 9 ascent β€” labeled `RECONSTRUCTED ## πŸ“· Street Level -> Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token. +> Street-level photos from **Mapillary** in one panel. It needs a free client token. **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. Each provider draws in its own colour (Mapillary green; see the legend). The **IMAGERY** (ALL / 360Β° / FLAT) and **SINCE** filters apply to every provider. 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 the provider's site, and the globe credits "Β© Mapillary contributors, CC BY-SA 4.0" while Mapillary 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, 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. -Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. +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). ## πŸŽ–οΈ Field Missions diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfc0ecb5c..27e6b9993 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2928,7 +2928,7 @@ its criteria cannot be silently ignored. | Dams β–° | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | β€” | static | | Submarine Cables β—  | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | β€” | static | | FIRMS Active Fires β–² | NASA FIRMS live (VIIRS Γ—3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | -| Street Level πŸ“· | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≀9 tiles, cached 24 h) | +| Street Level πŸ“· | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≀9 tiles, cached 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25Β°β†’1Β° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | Fire Perimeters πŸ”₯ | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | @@ -2938,19 +2938,21 @@ Street Level is a collapsible right-rail panel (`#street-level-panel`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts collapsed. It opens itself only when the user, voice or a tool switches the layer on or a photo opens, never on a restore, and those opens are not stored. -Like the iD editor's photo overlay, it has one switch per registered provider -(share options, no panel chips), -shared 360Β°/flat and captured-since filters (relative days, so a link keeps its -meaning), one viewer host and one on-globe credit per active provider. Each -provider draws in one colour (`PROVIDER_COLORS` in -`src/layers/streetLevel/policy.js`); 360Β° cones are rings and the selected -sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f` -panoramas), `s` (since, days). Only Mapillary is registered. Without -`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED, its error line says -which key to add, and the filters are disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14 -sequence lines below 60 km; the proxy strips the unused `image` layer from z14 -tiles (12 MB β†’ ~80 KB) and shares one upstream fetch between concurrent -requests for a tile. +Like the iD editor's photo overlay, it shows Mapillary with 360Β°/flat and +captured-since filters (relative days, so a link keeps its meaning), one +viewer host and an on-globe credit while the layer is on. Lines and cones are +Mapillary green; 360Β° cones are rings and the selected sequence is GEV cyan. +Share options: `m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f` +panoramas), `s` (since, days). Without `MAPILLARY_CLIENT_TOKEN` the panel +reads KEY REQUIRED, its error line says which key to add, and the filters are +disabled. Coverage is z11–14 sequence lines below 60 km; above that nothing is +drawn and the panel says to zoom in. Tiles come from the visible ground within +range of the camera (rays meet the terrain height, not the bare ellipsoid), +nearest the camera first, at most 9. The proxy serves only z11–14, strips the +unused `image` layer from z14 tiles (12 MB β†’ ~80 KB) and shares one upstream +fetch between concurrent requests for a tile. A 429 pauses tile requests for +its Retry-After and then refreshes once; a rejected key stops them and reads +KEY REJECTED until the layer goes off. Sequence lines are `GroundPolylinePrimitive`s with `ClassificationType.BOTH`, so they drape on the terrain globe and on Google 3D @@ -2959,12 +2961,13 @@ photo marker are billboards clamped to the ground; they skip the depth test, so a horizon cull hides the ones behind the globe. Roads on elevated decks are drawn at ground level. -The header pill is the only layer switch; switching on also turns back on a -provider a share link or tool switched off. The viewer sits under the header +The header pill is the only layer switch; switching on also turns Mapillary +back on if a share link or tool switched it off. The viewer sits under the header with EXPAND, FIT/FILL and close above the image. EXPAND is the browser's Fullscreen API on the viewer element, which stays in the panel (Esc or SHRINK -leaves; the button is hidden where the API is missing). Opening a photo flies -the globe camera once to frame it; there is no camera follow. The flight goes +leaves; the button is hidden where the API is missing). MapillaryJS loads on the +first photo (no prewarm). Opening a photo flies the globe camera once to frame +it; there is no camera follow. The flight goes through the application's deferred navigation (`attachNavigation`: `begin` / `reassert`), which releases aircraft and satellite tracking and is refused in the cockpit; a photo takes its ticket when it starts opening and frames only @@ -2972,14 +2975,9 @@ if nothing newer took the camera while it loaded. SINCE is a stepped slider whos cut-off date. At phone width (≀720 px) the viewer height is derived from the rail band so the whole photo fits. -Providers implement the contract in `src/layers/streetLevel/registry.js`. The -core routes clicks by pick prefix, swaps viewer adapters in the one host, -manages each provider's credit and fans the filter out to every provider. To -add a provider: implement the definition, register it in -`src/app/layers/streetLevel.js`, add its boolean option to the `street-level` -group in `src/data/layerState.js` and its modules to -`scripts/package-boundaries.json`. Once a keyless provider registers, the -layer's `requiresKeyId` becomes null and the key gate must move per provider. +The layer (`src/layers/streetLevel/index.js`) owns the Mapillary parts +directly: `providers/mapillary/` holds the source, tile decoding, coverage, +sequence cones, nearest-image lookup and the MapillaryJS viewer. Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run diff --git a/package.json b/package.json index cbfe75a0b..d3f456999 100644 --- a/package.json +++ b/package.json @@ -260,7 +260,6 @@ "node": "./server/providers/mapillary.js" }, "./layers/street-level": "./src/layers/streetLevel/index.js", - "./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js", "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1fcafb021..6f4d7cac3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -867,16 +867,11 @@ "src/layers/streetLevel/index.js", "src/layers/streetLevel/policy.js", "src/layers/streetLevel/filter.js", - "src/layers/streetLevel/registry.js", - "src/layers/streetLevel/params.js", - "src/layers/streetLevel/pickRouter.js", - "src/layers/streetLevel/uiState.js", "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/providers/mapillary/index.js", "src/layers/streetLevel/state.js", "src/layers/streetLevel/providers/mapillary/policy.js", "src/layers/streetLevel/providers/mapillary/coverage.js", @@ -887,6 +882,7 @@ "src/layers/streetLevel/selection.js", "src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/providers/mapillary/nearest.js", "server/providers/mapillary.js", "server/providers/mapillary/routes.js", "server/providers/mapillary/constants.js", @@ -898,12 +894,8 @@ "scripts/qa-browserEvidence.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", "src/layers/streetLevel/filter.test.mjs", - "src/layers/streetLevel/registry.test.mjs", - "src/layers/streetLevel/params.test.mjs", - "src/layers/streetLevel/pickRouter.test.mjs", - "src/layers/streetLevel/uiState.test.mjs", "src/layers/streetLevel/view.test.mjs", - "src/layers/streetLevel/providers/mapillary/provider.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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 638e2d363..fc907e21c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1178,21 +1178,15 @@ "street-level-layer": { "exports": [ "./layers/street-level", - "./layers/street-level/mapillary", "./layers/street-level/mapillary/source" ], "modules": [ "src/layers/streetLevel/index.js", - "src/layers/streetLevel/providers/mapillary/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/uiState.js", - "src/layers/streetLevel/pickRouter.js", - "src/layers/streetLevel/params.js", - "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", "src/layers/streetLevel/policy.js", "src/layers/streetLevel/state.js", @@ -1206,6 +1200,7 @@ "src/layers/streetLevel/selection.js", "src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/providers/mapillary/nearest.js", "scripts/qa-street-level.mjs" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -2100,16 +2095,11 @@ "src/ui/streetLevelControls.js", "src/ui/streetLevelPresentation.js", "src/layers/streetLevel/index.js", - "src/layers/streetLevel/providers/mapillary/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/uiState.js", - "src/layers/streetLevel/pickRouter.js", - "src/layers/streetLevel/params.js", - "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", "src/layers/streetLevel/policy.js", "src/layers/streetLevel/state.js", @@ -2123,6 +2113,7 @@ "src/layers/streetLevel/selection.js", "src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/providers/mapillary/nearest.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", "src/sources/capability.js", @@ -2619,16 +2610,11 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js", "src/layers/streetLevel/index.js", - "src/layers/streetLevel/providers/mapillary/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/uiState.js", - "src/layers/streetLevel/pickRouter.js", - "src/layers/streetLevel/params.js", - "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", "src/layers/streetLevel/policy.js", "src/layers/streetLevel/state.js", @@ -2642,6 +2628,7 @@ "src/layers/streetLevel/selection.js", "src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/source.js", + "src/layers/streetLevel/providers/mapillary/nearest.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 5d0c028f7..138b73235 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -41,8 +41,8 @@ export const VIEWPORTS = Object.freeze([ { width: 1280, height: 800 }, ]); -/** Providers the layer registers: one legend swatch each, plus "Selected". */ -export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); +/** Legend swatches: Mapillary, then "Selected". */ +export const LEGEND_SWATCHES = 2; /** * Skips no setup can avoid: live runs cannot stage fixture-only cases. @@ -89,24 +89,14 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const DAY_MS = 86_400_000; /** - * Deterministic coverage for any tile: a grid of mixed 360Β°/flat, new/old - * sequences plus the photo sequence (PHOTO_LINE), or a few overview points. - * @returns {Uint8Array} empty for zooms that carry nothing + * Deterministic coverage for any street-zoom tile: a grid of mixed 360Β°/flat, + * new/old sequences plus the photo sequence (PHOTO_LINE). + * @returns {Uint8Array} empty below z11, which the layer never asks for */ export function fixtureTile(z, x, y, now = Date.now()) { const tile = { x, y, z }; const { west, east, south, north } = tileBounds(x, y, z); const at = (t, lo, hi) => lo + (hi - lo) * t; - if (z <= 5) - return encodeCoverageTile(tile, { - overview: [0.25, 0.5, 0.75].map((t, i) => ({ - id: `fx-${z}-${x}-${y}-o${i}`, - lon: at(t, west, east), - lat: at(t, south, north), - isPano: i === 1, - capturedAt: now - 30 * DAY_MS, - })), - }); if (z < 11) return new Uint8Array(0); const margin = (east - west) * 0.05; const sequences = []; @@ -654,12 +644,12 @@ async function main() { const info = await panel(); assert.ok(!isCollapsed(info.classes)); assert.equal(info.bodyDisplay, 'flex'); - // One swatch per source plus "Selected". + // Mapillary plus "Selected". assert.equal( await page.evaluate( () => document.querySelectorAll('#sl-legend li').length, ), - EXPECTED_PROVIDERS.length + 1, + LEGEND_SWATCHES, ); }, ); @@ -1016,7 +1006,7 @@ async function main() { await page.evaluate((imageId) => { void window.__godsEyeView.dataManager.layers .get('street-level') - .module.openImage('mapillary', imageId); + .module.openImage(imageId); }, id); await waitForImage(id); const street = (await ui()).street; @@ -1149,7 +1139,6 @@ async function main() { assert.equal(view.hidden, false); assert.ok(view.width > 200); assert.ok(view.when.length > 0, 'caption shows the capture date'); - assert.equal(street.providerId, 'mapillary'); firstImageId = street.imageId; assert.equal(view.link, 'MAPILLARY β†—'); if (fixtures) { @@ -1246,7 +1235,7 @@ async function main() { window.__godsEyeView.dataManager.layers.get( 'street-level', ).module; - const opened = await module.openImage('mapillary', id); + const opened = await module.openImage(id); const flying = Boolean(v.camera._currentFlight); const from = v.camera.positionCartographic.clone(); module.closeViewer(); diff --git a/server/providers/mapillary/constants.js b/server/providers/mapillary/constants.js index 4b4e0b624..202d2846a 100644 --- a/server/providers/mapillary/constants.js +++ b/server/providers/mapillary/constants.js @@ -5,12 +5,12 @@ export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp'; /** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */ export const TILE_LAYERS = Object.freeze({ - // Region-sized z6–10 tiles are refused; the app never asks for them. The + // Only the street zooms the app draws (z11–14); the rest are refused. The // z14 `image` layer is ~98% of a tile and unused (positions come from the // graph API), so it is dropped in transit. coverage: Object.freeze({ upstream: 'mly1_public', - zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]), + zoomRanges: Object.freeze([Object.freeze([11, 14])]), dropLayers: Object.freeze(['image']), }), }); diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 9b08316d6..8ec6fd361 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -1,14 +1,13 @@ import { createStreetLevelLayer } from '../../layers/streetLevel/index.js'; -import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; import * as render from '../../renderGovernor.js'; -/** A new provider registers here; its chip, credit and share bit follow. */ +/** The Street Level layer over the application's Mapillary source. */ export function createApplicationStreetLevel({ sources }) { return createStreetLevelLayer({ - providers: [createMapillaryProvider({ source: sources.mapillary })], + source: sources.mapillary, services: { sprites, picking, input, render }, }); } diff --git a/src/layers/streetLevel/credits.js b/src/layers/streetLevel/credits.js index 25f584a39..c28e10a7f 100644 --- a/src/layers/streetLevel/credits.js +++ b/src/layers/streetLevel/credits.js @@ -1,38 +1,32 @@ import * as Cesium from 'cesium'; /** - * One static Cesium credit per active provider: CC BY-SA imagery needs + * A static Cesium credit shown while the layer draws: CC BY-SA imagery needs * visible attribution that goes away with the imagery. */ -export function createCredits() { - /** @type {Map} provider id β†’ Cesium.Credit */ - const shown = new Map(); +export function createCredit(html) { + let shown = null; - function show(viewer, def) { - if (shown.has(def.id) || !viewer?.creditDisplay) return; + function show(viewer) { + if (shown || !viewer?.creditDisplay) return; try { - const credit = new Cesium.Credit(def.credit.html, true); + const credit = new Cesium.Credit(html, true); viewer.creditDisplay.addStaticCredit(credit); - shown.set(def.id, credit); + shown = credit; } catch { /* credit display unavailable */ } } - function hide(viewer, def) { - const credit = shown.get(def.id); - if (!credit) return; + function hide(viewer) { + if (!shown) return; try { - viewer?.creditDisplay?.removeStaticCredit?.(credit); + viewer?.creditDisplay?.removeStaticCredit?.(shown); } catch { /* credit display already torn down */ } - shown.delete(def.id); + shown = null; } - function hideAll(viewer) { - for (const id of [...shown.keys()]) hide(viewer, { id }); - } - - return { show, hide, hideAll }; + return { show, hide }; } diff --git a/src/layers/streetLevel/filter.js b/src/layers/streetLevel/filter.js index 425de5092..ad244576b 100644 --- a/src/layers/streetLevel/filter.js +++ b/src/layers/streetLevel/filter.js @@ -27,7 +27,7 @@ export function sameFilter(a, b) { /** * Stored filter (relative days, stable in share links) to the absolute form - * providers compare capture times against. + * coverage, cones and lookups compare capture times against. * @returns {{pano: string, sinceMs: number|null}} */ export function resolveFilter(filter, now = Date.now()) { diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 294e016f2..3eeda8bd8 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,51 +1,70 @@ -import { createState } from './state.js'; +import { createState, isActive } from './state.js'; import { createMarker } from './marker.js'; import { createCameraFraming } from './cameraFraming.js'; -import { createCredits } from './credits.js'; -import { createPickRouter } from './pickRouter.js'; +import { createCredit } from './credits.js'; import { createSelection } from './selection.js'; import { createViewerHost } from './viewerHost.js'; -import { requiresKeyIdFor, validateProviders } from './registry.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; -import { decodeParams, encodeParams } from './params.js'; -import { composeUIState, summarizeCoverage } from './uiState.js'; -import { viewCentre, whenIdle } from './view.js'; +import { viewCentre } from './view.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, - POSITION_PICK_ID, - STREET_LEVEL_LAYER_ID, -} from './policy.js'; +} from './providers/mapillary/policy.js'; export { STREET_LEVEL_LAYER_ID } from './policy.js'; +/** Coverage as the panel sees it while the `mapillary` switch is off. */ +const COVERAGE_OFF = Object.freeze({ + count: 0, + loading: false, + hint: '', + error: null, + keyRequired: false, + keyRejected: false, +}); + /** - * The Street Level layer over imagery providers (contract in registry.js). - * The core owns what they share; providers own coverage, sequences, viewer. - * @param {{providers: Array, services?: object}} options + * 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). */ export function createStreetLevelLayer({ - providers: definitions, + source, services = {}, + photoViewer = null, }) { - const definitionsFrozen = validateProviders(definitions); + if ( + !MAPILLARY_SOURCE_METHODS.every( + (method) => typeof source?.[method] === 'function', + ) + ) + throw new TypeError('A Mapillary source is required'); const state = createState({ services }); const parts = {}; const context = { state, parts }; - parts.credits = createCredits(); + const credit = createCredit(MAPILLARY_CREDIT_HTML); parts.marker = createMarker(context); parts.framing = createCameraFraming(context); - parts.router = createPickRouter(() => state.providers.values(), { - positionId: POSITION_PICK_ID, + parts.coverage = createCoverage({ state, source }); + parts.sequences = createSequences({ state, source, parts }); + parts.viewerHost = createViewerHost({ + state, + parts, + adapter: + photoViewer || createMapillaryViewer({ source, render: services.render }), }); - parts.viewerHost = createViewerHost(context); - parts.hasSelectedSequence = () => - [...state.providers.values()].some( - (entry) => entry.instance.sequenceStats?.()?.selectedId, - ); - parts.clearSequences = () => { - for (const entry of state.providers.values()) - entry.instance.clearSequence?.(); - }; + parts.openImage = openImage; + parts.reportSequenceError = reportSequenceError; parts.selection = createSelection(context); let notifyQueued = false; @@ -66,70 +85,64 @@ export function createStreetLevelLayer({ } state.notify = notify; - function providerContext(entry) { - return Object.freeze({ - services: state.services, - getFilter: () => resolveFilter(state.filter), - isActive: () => state.enabled && entry.on, - notify, - actions: { - openImage: (imageId) => openImage(entry.def.id, imageId), - /** - * Show a provider error, or withdraw it (null). A withdrawal clears - * only this provider's own error, never a newer one from elsewhere. - */ - reportError: (message) => { - if (message) { - state.street.error = entry.reportedError = message; - } else { - if (!entry.reportedError) return; - if (state.street.error === entry.reportedError) - state.street.error = null; - entry.reportedError = null; - } - notify(); - }, - }, - }); - } + /** The error a sequence lookup last showed, until it withdraws it. */ + let sequenceError = null; - for (const def of definitionsFrozen) { - const entry = { - def, - instance: null, - on: true, - status: null, - /** The error this provider last reported, until it withdraws it. */ - reportedError: null, - }; - entry.instance = def.create(providerContext(entry)); - state.providers.set(def.id, entry); - } - - const activeEntries = () => - [...state.providers.values()].filter((entry) => entry.on); - - async function refreshStatus(entry) { - try { - entry.status = await entry.instance.status(); - } catch { - entry.status = null; + /** + * Show a sequence error, or withdraw it (null). A withdrawal clears only + * that error, never a newer one from the viewer. + */ + function reportSequenceError(message) { + if (message) { + state.street.error = sequenceError = message; + } else { + if (!sequenceError) return; + if (state.street.error === sequenceError) state.street.error = null; + sequenceError = null; } notify(); } - function activate(entry) { - if (!state.viewer) return; - entry.instance.activate(state.viewer); - parts.credits.show(state.viewer, entry.def); - if (!entry.status) refreshStatus(entry); - whenIdle(() => parts.viewerHost.prewarm([entry]), 1500); + /** Coverage waits for the key status: no tile requests without a key. */ + async function checkKey() { + let configured; + try { + configured = (await source.getStatus())?.configured === true; + } catch { + configured = source.hasToken?.() === true; + } + parts.coverage.setKeyStatus(configured); } - function deactivate(entry) { - parts.viewerHost.unmount(entry.def.id); - entry.instance.deactivate(); - parts.credits.hide(state.viewer, entry.def); + function startDrawing() { + const viewer = state.viewer; + if (!viewer) return; + parts.sequences.setVisible(true); + parts.coverage.attach(viewer); + credit.show(viewer); + } + + /** Close the photo, clear lines and cones, forget refusals. */ + function stopDrawing() { + abortNearest(); + parts.viewerHost.unmount(); + parts.coverage.detach(); + parts.coverage.clear(); + parts.coverage.unblock(); + parts.sequences.clearSelection(); + parts.sequences.setVisible(false); + credit.hide(state.viewer); + } + + /** The share link's `mapillary` switch: off, the layer draws nothing. */ + function setProviderOn(on) { + if (state.providerOn === on) return; + state.providerOn = on; + if (state.enabled) { + if (on) startDrawing(); + else stopDrawing(); + } + notify(); } /** The nearest-image lookup in flight; any newer user action aborts it. */ @@ -140,97 +153,62 @@ export function createStreetLevelLayer({ nearestLookup = null; } - function openImage(providerId, imageId) { + function openImage(imageId) { abortNearest(); - return parts.viewerHost.open(providerId, imageId); - } - - function setProviderEnabled(providerId, on) { - const entry = state.providers.get(providerId); - if (!entry) return false; - const next = on !== false; - if (entry.on === next) return true; - entry.on = next; - if (state.enabled) { - if (next) activate(entry); - else deactivate(entry); - } - notify(); - return true; + return parts.viewerHost.open(imageId); } function setCoverageFilter(next) { const filter = normalizeFilter(next, state.filter); if (sameFilter(filter, state.filter)) return; state.filter = filter; - const resolved = resolveFilter(filter); - for (const entry of state.providers.values()) - entry.instance.setFilter(resolved); + parts.coverage.rebuild(); + parts.sequences.rerender(); notify(); } - function providerSnapshots() { - return [...state.providers.values()].map((entry) => { - const stats = entry.instance.coverageStats(); - return { - id: entry.def.id, - name: entry.def.name, - label: entry.def.label, - on: entry.on, - configured: entry.status ? entry.status.configured === true : null, - // A rejected key gates the provider exactly like a missing one. - keyRequired: stats.keyRequired === true || stats.keyRejected === true, - keyRejected: stats.keyRejected === true, - requiresKeyId: entry.def.requiresKeyId || null, - loading: stats.loading === true, - count: stats.count || 0, - hint: stats.hint || '', - error: stats.error || null, - color: entry.def.colors.coverage, - }; - }); - } - - function sequenceSnapshot() { - // The provider showing the open image answers first. - const owner = state.providers.get(state.street.providerId); - const candidates = owner - ? [owner, ...[...state.providers.values()].filter((e) => e !== owner)] - : [...state.providers.values()]; - for (const entry of candidates) { - const stats = entry.instance.sequenceStats?.(); - if (stats?.selectedId || stats?.loading) - return { - providerId: entry.def.id, - selectedId: stats.selectedId || null, - images: stats.images || 0, - loading: stats.loading === true, - }; - } - return { providerId: null, selectedId: null, images: 0, loading: false }; + function coverageStats() { + return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF; } function getUIState() { const { host, ...street } = state.street; - return composeUIState({ + const { keyRequired, keyRejected, ...coverage } = coverageStats(); + return { enabled: state.enabled, - filter: state.filter, - providers: providerSnapshots(), + providerOn: state.providerOn, + // A rejected key gates the layer exactly like a missing one. + keyRequired, + keyRejected, + filter: { ...state.filter }, + coverage, + legend: state.providerOn + ? [ + { + key: 'mapillary', + label: 'Mapillary', + color: MAPILLARY_COLORS.coverage, + }, + { key: 'selected', label: 'Selected', color: COLORS.selected }, + ] + : [], + sequence: { + selectedId: state.sequence.selectedId, + images: state.sequence.images.length, + loading: state.sequence.loading, + }, street, - sequence: sequenceSnapshot(), - }); + }; } const layer = { id: STREET_LEVEL_LAYER_ID, name: 'Street Level', icon: 'πŸ“·', - source: definitionsFrozen.map((def) => def.name).join(' Β· '), + source: 'Mapillary', updateInterval: 0, statsRefreshInterval: 1000, - requiresKeyId: requiresKeyIdFor(definitionsFrozen), - /** Registered providers, in chip order. */ - providerIds: definitionsFrozen.map((def) => def.id), + requiresKeyId: MAPILLARY_KEY_ID, init(viewer) { if (state.initialized) @@ -239,10 +217,9 @@ export function createStreetLevelLayer({ state.initialized = true; parts.marker.ensure(viewer); parts.marker.setVisible(false); - for (const entry of state.providers.values()) { - entry.instance.init(viewer); - refreshStatus(entry); - } + parts.sequences.ensureCollections(viewer); + parts.sequences.setVisible(false); + checkKey(); console.log('[Data:StreetLevel] Initialized'); }, @@ -251,16 +228,13 @@ export function createStreetLevelLayer({ state.viewer = viewer; parts.marker.setVisible(true); parts.selection.install(viewer); - for (const entry of activeEntries()) activate(entry); + if (state.providerOn) startDrawing(); notify(); }, disable() { state.enabled = false; - abortNearest(); - parts.viewerHost.unmount(); - for (const entry of state.providers.values()) entry.instance.deactivate(); - parts.credits.hideAll(state.viewer); + stopDrawing(); parts.selection.uninstall(); parts.marker.setVisible(false); notify(); @@ -272,8 +246,7 @@ export function createStreetLevelLayer({ destroy(viewer = state.viewer) { layer.disable(); - for (const entry of state.providers.values()) - entry.instance.destroy(viewer); + parts.sequences.destroy(viewer); parts.marker.destroy(viewer); parts.framing.attachNavigation(null); state.listeners.clear(); @@ -283,10 +256,9 @@ export function createStreetLevelLayer({ }, getStats() { - // The lifecycle polls this every second: summarise, don't snapshot. - const coverage = summarizeCoverage(providerSnapshots()); - let loadingLabel = ''; + const coverage = coverageStats(); const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED'; + let loadingLabel = ''; if (coverage.keyRequired) loadingLabel = keyLabel; else if (coverage.loading) loadingLabel = 'loading coverage...'; else if (coverage.hint && state.enabled) loadingLabel = coverage.hint; @@ -294,11 +266,11 @@ export function createStreetLevelLayer({ count: coverage.count, loading: coverage.loading, keyRequired: coverage.keyRequired, - error: coverage.keyRequired - ? coverage.keyRejected - ? coverage.error || keyLabel - : keyLabel - : coverage.error, + // A rejected key's message names the fix; a missing one says so. + error: + coverage.keyRequired && !coverage.keyRejected + ? keyLabel + : coverage.error, loadingLabel, }; }, @@ -312,21 +284,26 @@ export function createStreetLevelLayer({ parts.framing.attachNavigation(navigation); }, - /** Share-link and stored state: provider switches plus the filter. */ + /** + * Share-link and stored state, in the codec's keys (src/data/layerState.js): + * the `mapillary` switch plus the filter. + */ getParams() { - return encodeParams({ - providers: [...state.providers].map(([id, entry]) => [id, entry.on]), - filter: state.filter, - }); + return { + mapillary: state.providerOn, + pano: state.filter.pano, + sinceDays: state.filter.sinceDays, + }; }, + /** Unknown keys and malformed values are ignored, so any link applies. */ setParams(params = {}) { - const decoded = decodeParams(params, { - providerIds: state.providers.keys(), - filter: state.filter, - }); - for (const [id, on] of decoded.providers) setProviderEnabled(id, on); - setCoverageFilter(decoded.filter); + const input = params && typeof params === 'object' ? params : {}; + if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary); + const next = {}; + if ('pano' in input) next.pano = input.pano; + if ('sinceDays' in input) next.sinceDays = input.sinceDays; + setCoverageFilter(next); return true; }, @@ -337,80 +314,59 @@ export function createStreetLevelLayer({ return () => state.listeners.delete(listener); }, getUIState, - /** The DOM element provider viewers render into. */ + /** The DOM element the photo viewer renders into. */ attachViewerHost(element) { parts.viewerHost.attach(element); - if (element && state.enabled) - whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500); }, - setProviderEnabled, /** Imagery filter for coverage, cones and nearest-image lookups. */ setCoverageFilter, openImage, - /** Open the nearest image any active provider has around a point. */ + /** 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; - const { signal } = lookup; state.street.loading = true; state.street.error = null; notify(); - let lastError = null; - for (const entry of activeEntries()) { - let imageId = null; - try { - imageId = await entry.instance.nearestImage( - { lat: view.lat, lon: view.lon }, - { signal }, - ); - } catch (error) { - // Overtaken: whatever replaced it owns the panel, errors included. - if (signal.aborted) return false; - // One provider failing (no key, offline) must not hide the others. - lastError = error; - continue; - } - if (signal.aborted) return false; - if (!state.enabled) break; - // This provider was switched off while it looked: try the next. - if (!imageId || !entry.on) continue; - nearestLookup = null; - return openImage(entry.def.id, imageId); + 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; } - if (nearestLookup === lookup) nearestLookup = null; - // Switched off while it looked: nothing to open, nothing to report. - if (state.enabled && activeEntries().length) - state.street.error = - lastError?.message || - `No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`; + // 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 it everywhere on the map. */ + /** Close the image and deselect its sequence on the map. */ closeViewer() { abortNearest(); parts.viewerHost.close(); - parts.clearSequences(); + parts.sequences.clearSelection(); }, setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode), resizeViewer: () => parts.viewerHost.resize(), - selectSequence( - sequenceId, - providerId = state.providers.keys().next().value, - ) { - return state.providers - .get(providerId) - ?.instance.selectSequence?.(sequenceId); - }, - clearSequence: () => parts.clearSequences(), - refreshCoverage() { - for (const entry of activeEntries()) entry.instance.refreshCoverage(); - }, + selectSequence: (sequenceId) => parts.sequences.select(sequenceId), + clearSequence: () => parts.sequences.clearSelection(), + refreshCoverage: () => parts.coverage.refresh(), }; return layer; } diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 240a233b7..2627efea4 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -3,14 +3,19 @@ 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 { createMapillaryProvider } from './providers/mapillary/index.js'; -import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs'; +import { + 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. + * 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() { +function fakeViewer({ view = false } = {}) { const credits = []; /** Cesium's flight bookkeeping: a new flight cancels the current one. */ let flight = null; @@ -26,19 +31,20 @@ function fakeViewer() { // Keep Cesium's handler on the canvas; there is no real document here. disableRootEvents: true, onwheel: null, + ...(view ? { clientWidth: 100, clientHeight: 100 } : {}), }); - return { - credits, - flights, - scene: { - canvas, - primitives: { 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: { + 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(), @@ -51,6 +57,20 @@ function fakeViewer() { 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) => @@ -59,24 +79,41 @@ function fakeViewer() { }; } -/** A layer over `providers`, initialised and enabled on a stand-in viewer. */ -async function enabledLayer(t, providers = [fakeProvider()]) { +/** + * A layer over `source` and `photoViewer`, initialised on a stand-in viewer + * and (unless `enable` is false) enabled. + */ +async function startLayer( + t, + { + source = fakeMapillarySource(), + photoViewer = fakePhotoViewer(), + view = false, + enable = true, + } = {}, +) { 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 }), }); - const viewer = fakeViewer(); - const layer = createStreetLevelLayer({ providers }); + // Skip Cesium's one-time terrain table download for draped lines. + t.mock.method( + Cesium.GroundPolylinePrimitive, + 'initializeTerrainHeights', + async () => {}, + ); + const viewer = fakeViewer({ view }); + const layer = createStreetLevelLayer({ source, photoViewer }); layer.init(viewer); - layer.enable(viewer); + if (enable) layer.enable(viewer); t.after(() => { layer.destroy(); globalThis.document = saved; }); await settle(); - return { layer, viewer }; + return { layer, viewer, source, photoViewer }; } const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -91,180 +128,194 @@ function deferred() { return { promise, resolve, reject }; } -/** A provider whose viewer reports a pose for every image it opens. */ -function posingProvider() { - const provider = fakeProvider(); - const create = provider.create; - provider.create = (context) => { - const instance = create(context); - let emit = null; - instance.viewer.onPose = (listener) => { - emit = listener; - return () => { - emit = null; - }; - }; - instance.viewer.open = async (imageId) => { - provider.calls.open.push(imageId); - emit?.({ - providerId: 'mapillary', - imageId, - position: { lon: -121.49, lat: 38.58 }, - bearing: 90, - }); - }; - return instance; - }; - return provider; -} +/** A photo viewer reporting a pose for every image it opens. */ +const posingViewer = () => + fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } }); -/** nearestImage lookups that answer when the test says, with their signals. */ +/** Nearest-image lookups that answer when the test says, with their signals. */ function slowLookups() { const lookups = []; - const nearestImage = (point, { signal } = {}) => { + const nearestImages = (query, { signal } = {}) => { const answer = deferred(); - lookups.push({ point, signal, answer }); - return answer.promise; + lookups.push({ query, signal, answer }); + return answer.promise.then((id) => [{ id, is_pano: false }]); }; - return { lookups, nearestImage }; + return { lookups, source: fakeMapillarySource({ nearestImages }) }; } -test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => { - const { layer, viewer } = await enabledLayer(t); - const shown = () => viewer.credits.map((credit) => credit.html); - assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate'); - assert.match(shown()[0], /CC BY-SA 4\.0/); - layer.setProviderEnabled('mapillary', false); - assert.deepEqual(shown(), [], 'hidden with the provider'); - layer.setProviderEnabled('mapillary', true); - assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]); - layer.disable(); - assert.deepEqual(shown(), [], 'every credit goes with the layer'); +test('the layer is Mapillary under the codec keys it has always had', () => { + const layer = createStreetLevelLayer({ source: fakeMapillarySource() }); + assert.equal(layer.id, 'street-level'); + assert.equal(layer.requiresKeyId, 'mapillary'); + assert.deepEqual(layer.getParams(), { + mapillary: true, + pano: 'all', + sinceDays: 0, + }); + assert.throws(() => createStreetLevelLayer({ source: {} }), /source/); }); -test('getStats tells a rejected key from a missing one', async (t) => { - const provider = fakeProvider(); - const { layer } = await enabledLayer(t, [provider]); - provider.stats.loading = true; - assert.equal(layer.getStats().loadingLabel, 'loading coverage...'); - assert.equal(layer.getStats().keyRequired, false); +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); + assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable'); + assert.match(shown()[0], /CC BY-SA 4\.0/); + layer.setParams({ mapillary: false }); + assert.deepEqual(shown(), [], 'hidden with the switch'); + assert.deepEqual(layer.getUIState().legend, [], 'no legend either'); + layer.setParams({ mapillary: true }); + assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]); + layer.disable(); + assert.deepEqual(shown(), [], 'the credit goes with the layer'); +}); - provider.stats.keyRequired = true; - assert.deepEqual( - [layer.getStats().loadingLabel, layer.getStats().error], - ['KEY REQUIRED', 'KEY REQUIRED'], - ); +test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => { + for (const configured of [false, true]) { + const status = deferred(); + const source = fakeMapillarySource({ getStatus: () => status.promise }); + const { layer } = await startLayer(t, { source, view: true }); + assert.equal(source.calls.tiles.length, 0, 'nothing before the status'); + status.resolve({ configured }); + await settle(); + if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage'); + else { + assert.equal( + source.calls.tiles.length, + 0, + 'a key-less install never asks', + ); + assert.deepEqual( + [layer.getStats().loadingLabel, layer.getStats().error], + ['KEY REQUIRED', 'KEY REQUIRED'], + ); + assert.equal(layer.getUIState().keyRequired, true); + assert.equal(layer.getUIState().keyRejected, false); + } + layer.destroy(); + } +}); - // A rejected key gates the provider exactly like a missing one. - provider.stats.keyRequired = false; - provider.stats.keyRejected = true; - provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN'; +test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => { + const rejected = Object.assign(new Error('Mapillary rejected the token'), { + keyRejected: true, + }); + const source = fakeMapillarySource({ + getTile: async () => { + throw rejected; + }, + }); + const { layer, viewer } = await startLayer(t, { source, view: true }); + await settle(); const stats = layer.getStats(); assert.equal(stats.keyRequired, true); assert.equal(stats.loadingLabel, 'KEY REJECTED'); - assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN'); + assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/); const ui = layer.getUIState(); - assert.equal(ui.keyRequired, true); - assert.equal(ui.keyRejected, true); - assert.equal(ui.providers[0].keyRequired, true); + assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]); + layer.disable(); + layer.enable(viewer); + assert.equal(layer.getUIState().keyRejected, false, 'asked again once on'); }); -test('switching a provider off deactivates it and closes the image it shows', async (t) => { - const provider = fakeProvider(); - const { layer } = await enabledLayer(t, [provider]); +test('switching Mapillary off clears coverage and closes the image it shows', async (t) => { + const { layer, photoViewer } = await startLayer(t); layer.attachViewerHost({}); - assert.equal(await layer.openImage('mapillary', 'img1'), true); - assert.deepEqual(provider.calls.open, ['img1']); + assert.equal(await layer.openImage('img1'), true); + assert.deepEqual(photoViewer.calls.open, ['img1']); assert.equal(layer.getUIState().street.open, true); - layer.setProviderEnabled('mapillary', false); - assert.equal(provider.calls.deactivate, 1); - assert.equal(provider.calls.unmount, 1, 'its viewer is released'); + layer.setParams({ mapillary: false }); + assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released'); assert.equal(layer.getUIState().street.open, false); - assert.equal(layer.getUIState().street.providerId, null); + assert.equal(layer.getUIState().providerOn, false); }); test('openNearest without the panel reports it instead of loading forever', async (t) => { - const provider = fakeProvider({ nearestImage: async () => 'img1' }); - const { layer } = await enabledLayer(t, [provider]); + 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(provider.calls.mount, 0); + assert.equal(photoViewer.calls.mount, 0); }); -test('openNearest opens nothing once the layer went off during the lookup', async (t) => { - const answer = deferred(); - const provider = fakeProvider({ nearestImage: () => answer.promise }); - const { layer } = await enabledLayer(t, [provider]); - layer.attachViewerHost({}); - const opening = layer.openNearest({ lat: 38.58, lon: -121.49 }); - layer.disable(); - answer.resolve('img1'); - assert.equal(await opening, false); - assert.equal(provider.calls.mount, 0, 'no viewer was stood up'); - const { street } = layer.getUIState(); - assert.equal(street.open, false); - assert.equal(street.loading, false); - assert.equal(street.error, null); +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('openNearest skips a provider switched off during its lookup', async (t) => { - const answer = deferred(); - const provider = fakeProvider({ nearestImage: () => answer.promise }); - const { layer } = await enabledLayer(t, [provider]); - layer.attachViewerHost({}); - const opening = layer.openNearest({ lat: 38.58, lon: -121.49 }); - layer.setProviderEnabled('mapillary', false); - answer.resolve('img1'); - assert.equal(await opening, false); - assert.deepEqual(provider.calls.open, []); - assert.equal(layer.getUIState().street.loading, false); -}); - -test('a provider withdraws only the error it reported', async (t) => { - const provider = fakeProvider(); - const { layer } = await enabledLayer(t, [provider]); - const { actions } = provider.context(); - actions.reportError('Sequence images unavailable'); +test('a sequence error is withdrawn with the sequence, never the viewer’s own', async (t) => { + const source = fakeMapillarySource({ + getSequenceImages: async () => { + throw new Error('Sequence images unavailable'); + }, + }); + const { layer } = await startLayer(t, { source }); + await layer.selectSequence('seq-1'); assert.equal(layer.getUIState().street.error, 'Sequence images unavailable'); - actions.reportError(null); + layer.clearSequence(); assert.equal(layer.getUIState().street.error, null, 'withdrawn'); - // The viewer's own error is not the provider's to clear. + // The viewer's own error is not the sequence's to clear. + await layer.selectSequence('seq-2'); layer.attachViewerHost(null); - actions.reportError('Sequence images unavailable'); - await layer.openImage('mapillary', 'img1'); - actions.reportError(null); + await layer.openImage('img1'); + layer.clearSequence(); assert.match(layer.getUIState().street.error, /Open the Street Level panel/); }); test('closing the photo stops the globe flying to it', async (t) => { - const { layer, viewer } = await enabledLayer(t, [posingProvider()]); + const { layer, viewer } = await startLayer(t, { + photoViewer: posingViewer(), + }); layer.attachViewerHost({}); - assert.equal(await layer.openImage('mapillary', 'img1'), true); + assert.equal(await layer.openImage('img1'), true); assert.equal(viewer.flights.started, 1, 'the photo is framed'); layer.closeViewer(); assert.equal(viewer.flights.cancelled, 1); }); test('switching the layer off or destroying it stops the framing flight', async (t) => { - const { layer, viewer } = await enabledLayer(t, [posingProvider()]); + const { layer, viewer } = await startLayer(t, { + photoViewer: posingViewer(), + }); layer.attachViewerHost({}); - await layer.openImage('mapillary', 'img1'); + await layer.openImage('img1'); layer.disable(); assert.equal(viewer.flights.cancelled, 1, 'layer off'); layer.enable(viewer); - await layer.openImage('mapillary', 'img2'); + await layer.openImage('img2'); layer.destroy(); assert.equal(viewer.flights.cancelled, 2, 'destroyed'); }); test('closing the photo leaves a newer navigation flight alone', async (t) => { - const { layer, viewer } = await enabledLayer(t, [posingProvider()]); + const { layer, viewer } = await startLayer(t, { + photoViewer: posingViewer(), + }); layer.attachViewerHost({}); - await layer.openImage('mapillary', 'img1'); + await layer.openImage('img1'); assert.equal(viewer.flights.started, 1, 'the photo is being framed'); // A search result flies the globe elsewhere before the framing lands. viewer.camera.flyToBoundingSphere(null, {}); @@ -273,9 +324,8 @@ test('closing the photo leaves a newer navigation flight alone', async (t) => { }); test('an older nearest lookup that answers late cannot replace a newer one', async (t) => { - const { lookups, nearestImage } = slowLookups(); - const provider = fakeProvider({ nearestImage }); - const { layer } = await enabledLayer(t, [provider]); + 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 }); @@ -283,29 +333,27 @@ test('an older nearest lookup that answers late cannot replace a newer one', asy assert.equal(await newer, true); lookups[0].answer.resolve('older'); assert.equal(await older, false); - assert.deepEqual(provider.calls.open, ['newer']); + 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, nearestImage } = slowLookups(); - const provider = fakeProvider({ nearestImage }); - const { layer } = await enabledLayer(t, [provider]); + 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 goes through the provider's openImage action. - assert.equal(await provider.context().actions.openImage('picked'), true); + // 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(provider.calls.open, ['picked']); + 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, nearestImage } = slowLookups(); - const provider = fakeProvider({ nearestImage }); - const { layer, viewer } = await enabledLayer(t, [provider]); + const { lookups, source } = slowLookups(); + const { layer, viewer, photoViewer } = await startLayer(t, { source }); layer.attachViewerHost({}); const closed = layer.openNearest({ lat: 38.58, lon: -121.49 }); @@ -325,7 +373,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a layer.enable(viewer); lookups[1].answer.resolve('img1'); assert.equal(await disabled, false, 'nor does it open once back on'); - assert.deepEqual(provider.calls.open, []); + assert.deepEqual(photoViewer.calls.open, []); ({ street } = layer.getUIState()); assert.deepEqual( [street.open, street.loading, street.error], @@ -334,36 +382,8 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too'); }); -test('the Mapillary provider hands the lookup signal to its source', async () => { - const requests = []; - const source = { - hasToken: () => true, - getStatus: async () => ({ configured: true }), - getTile: async () => new Uint8Array(0), - getSequenceImages: async () => [], - nearestImages: async (query, options) => { - requests.push(options); - return [{ id: 7, is_pano: false, captured_at: 10 }]; - }, - }; - const instance = createMapillaryProvider({ source }).create({ - services: {}, - getFilter: () => ({ pano: 'all', sinceMs: null }), - isActive: () => true, - notify() {}, - actions: { openImage() {}, reportError() {} }, - }); - const { signal } = new AbortController(); - assert.equal( - await instance.nearestImage({ lat: 1, lon: 2 }, { signal }), - '7', - ); - assert.equal(requests[0]?.signal, signal); -}); - -test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => { - const provider = fakeProvider(); - const { layer } = await enabledLayer(t, [provider]); +test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => { + const { layer } = await startLayer(t); layer.setParams({ pano: 'flat', sinceDays: 365 }); assert.equal(layer.getParams().sinceDays, 365); layer.setParams({ sinceDays: 0 }); @@ -372,54 +392,39 @@ test('setParams takes "any date" (0 days) and a provider switch over the current pano: 'flat', sinceDays: 0, }); - assert.equal( - provider.filters.at(-1).sinceMs, - null, - 'providers drop the cut-off', - ); layer.setParams({ mapillary: false }); assert.equal(layer.getParams().mapillary, false); }); -/** A layer initialised on a stand-in viewer but never enabled. */ -function initialisedLayer(t, providers) { - const saved = globalThis.document; - const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); - globalThis.document = Object.assign(new EventTarget(), { - createElement: () => ({ getContext: () => drawing }), +test('setParams ignores unknown keys and malformed values', async (t) => { + const { layer } = await startLayer(t); + layer.setParams({ pano: 'pano', sinceDays: 730 }); + layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 }); + layer.setParams({ sinceDays: -3 }); + layer.setParams(null); + assert.deepEqual(layer.getParams(), { + mapillary: true, + pano: 'pano', + sinceDays: 730, }); - const viewer = fakeViewer(); - const layer = createStreetLevelLayer({ providers }); - layer.init(viewer); - t.after(() => { - layer.destroy(); - globalThis.document = saved; - }); - return { layer, viewer }; -} +}); -test('switching a provider on while the layer is off activates nothing (M01)', async (t) => { - const provider = fakeProvider(); - const prewarmed = []; - const create = provider.create; - provider.create = (context) => { - const instance = create(context); - instance.viewer.prewarm = async (host) => prewarmed.push(host); - return instance; - }; - const { layer, viewer } = initialisedLayer(t, [provider]); +test('switching Mapillary on while the layer is off draws nothing (M01)', async (t) => { + const { layer, viewer, source, photoViewer } = await startLayer(t, { + view: true, + enable: false, + }); layer.attachViewerHost({}); - layer.setProviderEnabled('mapillary', false); - layer.setProviderEnabled('mapillary', true); layer.setParams({ mapillary: false }); layer.setParams({ mapillary: true }); - await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle - assert.equal(provider.calls.activate, 0, 'no coverage drawn'); + await settle(); + assert.equal(source.calls.tiles.length, 0, 'no coverage drawn'); assert.deepEqual(viewer.credits, [], 'no credit shown'); - assert.deepEqual(prewarmed, [], 'no viewer stood up'); - assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept'); - // Enabling the layer is what activates it, once. + assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up'); + assert.equal(layer.getUIState().providerOn, true, 'the switch is kept'); + // Enabling the layer is what draws it. layer.enable(viewer); - assert.equal(provider.calls.activate, 1); + await settle(); + assert.ok(source.calls.tiles.length > 0); assert.equal(viewer.credits.length, 1); }); diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js deleted file mode 100644 index 1d87b1ebc..000000000 --- a/src/layers/streetLevel/params.js +++ /dev/null @@ -1,32 +0,0 @@ -import { normalizeFilter } from './filter.js'; - -/** - * Share-link params: one boolean per provider plus the filter. The codec in - * src/data/layerState.js names the same keys, so a new provider needs one there. - * @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input - */ -export function encodeParams({ providers, filter }) { - const params = {}; - for (const [id, on] of providers) params[id] = on === true; - params.pano = filter.pano; - params.sinceDays = filter.sinceDays; - return params; -} - -/** - * Read params back, ignoring unknown providers and malformed values so a link - * from a build with more providers still applies. - * @param {object} params - * @param {{providerIds: Iterable, filter: {pano: string, sinceDays: number}}} current - * @returns {{providers: Map, filter: {pano: string, sinceDays: number}}} - */ -export function decodeParams(params, { providerIds, filter }) { - const providers = new Map(); - const source = params && typeof params === 'object' ? params : {}; - for (const id of providerIds) - if (typeof source[id] === 'boolean') providers.set(id, source[id]); - const next = {}; - if ('pano' in source) next.pano = source.pano; - if ('sinceDays' in source) next.sinceDays = source.sinceDays; - return { providers, filter: normalizeFilter(next, filter) }; -} diff --git a/src/layers/streetLevel/params.test.mjs b/src/layers/streetLevel/params.test.mjs deleted file mode 100644 index 19a89a1ca..000000000 --- a/src/layers/streetLevel/params.test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { decodeParams, encodeParams } from './params.js'; - -test('encodeParams writes one boolean per provider plus the filter', () => { - assert.deepEqual( - encodeParams({ - providers: [ - ['mapillary', true], - ['panoramax', false], - ], - filter: { pano: 'flat', sinceDays: 365 }, - }), - { mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 }, - ); -}); - -test('decodeParams round-trips and ignores unknown or malformed keys', () => { - const current = { pano: 'all', sinceDays: 0 }; - const decoded = decodeParams( - { mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 }, - { providerIds: ['mapillary'], filter: current }, - ); - assert.deepEqual([...decoded.providers], [['mapillary', false]]); - assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 }); - - const partial = decodeParams( - { mapillary: '1', pano: 'weird' }, - { providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } }, - ); - assert.equal(partial.providers.size, 0, 'a string is not a switch'); - assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 }); - - const empty = decodeParams(null, { - providerIds: ['mapillary'], - filter: current, - }); - assert.equal(empty.providers.size, 0); - assert.deepEqual(empty.filter, current); -}); - -test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => { - const decoded = decodeParams( - { sinceDays: 0 }, - { providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } }, - ); - assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 }); - // The provider switch off is a value too, not an absence. - assert.deepEqual( - [ - ...decodeParams( - { mapillary: false }, - { providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } }, - ).providers, - ], - [['mapillary', false]], - ); -}); diff --git a/src/layers/streetLevel/pickRouter.js b/src/layers/streetLevel/pickRouter.js deleted file mode 100644 index 8a049f558..000000000 --- a/src/layers/streetLevel/pickRouter.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Route picked ids to the provider whose prefix they carry; the registry - * guarantees prefixes never overlap. - * @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders - * @param {{positionId?: string}} [options] Id of the core-owned position marker. - */ -export function createPickRouter(getProviders, { positionId = null } = {}) { - function resolve(id) { - if (typeof id !== 'string' || !id) return null; - if (positionId && id === positionId) - return { providerId: null, instance: null, id }; - for (const entry of getProviders()) - if (id.startsWith(entry.def.pickPrefix)) - return { providerId: entry.def.id, instance: entry.instance, id }; - return null; - } - return { - ownsPick: (id) => resolve(id) !== null, - resolve, - }; -} diff --git a/src/layers/streetLevel/pickRouter.test.mjs b/src/layers/streetLevel/pickRouter.test.mjs deleted file mode 100644 index d628dbc5d..000000000 --- a/src/layers/streetLevel/pickRouter.test.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createPickRouter } from './pickRouter.js'; - -const providers = [ - { def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } }, - { def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } }, -]; - -test('ids are routed by prefix to their provider instance', () => { - const router = createPickRouter(() => providers, { positionId: 'sl:pos' }); - assert.deepEqual(router.resolve('mly:seq:1'), { - providerId: 'mapillary', - instance: providers[0].instance, - id: 'mly:seq:1', - }); - assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax'); - assert.equal(router.ownsPick('mly:img:2'), true); -}); - -test('the core-owned marker is owned but routes to no provider', () => { - const router = createPickRouter(() => providers, { positionId: 'sl:pos' }); - assert.deepEqual(router.resolve('sl:pos'), { - providerId: null, - instance: null, - id: 'sl:pos', - }); - assert.equal(router.ownsPick('sl:pos'), true); -}); - -test('foreign and malformed ids are not ours', () => { - const router = createPickRouter(() => providers); - for (const id of ['cctv:1', '', null, undefined, 42, {}]) - assert.equal(router.ownsPick(id), false, String(id)); - assert.equal(router.resolve('sl:pos'), null, 'no marker id configured'); -}); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 23b85fa29..4575e6621 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -1,15 +1,10 @@ -/** Identity and shared tuning for the provider-neutral Street Level layer. */ +/** Identity and shared tuning for the Street Level layer. */ export const STREET_LEVEL_LAYER_ID = 'street-level'; -/** Pick id of the viewer position marker; the core owns it, not a provider. */ +/** Pick id of the viewer position marker. */ export const POSITION_PICK_ID = 'sl:pos'; -/** One colour per imagery source: chip, coverage lines, points, cones and legend. */ -export const PROVIDER_COLORS = Object.freeze({ - mapillary: '#05cb63', -}); - -/** Colours every provider shares: the selection highlight and the marker. */ +/** The selection highlight and the position marker. */ export const COLORS = Object.freeze({ selected: '#00d4ff', position: '#ffb300', @@ -21,8 +16,5 @@ export const PANO_MODES = Object.freeze(['all', 'pano', 'flat']); /** Longest "captured since" window the filter accepts, in days (~100 years). */ export const MAX_SINCE_DAYS = 36_500; -/** Filter every provider starts with: all imagery, any date. */ +/** Filter the layer starts with: all imagery, any date. */ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); - -/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */ -export const NEAREST_RADIUS_M = 50; diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index e6af2846a..7be043755 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -1,31 +1,18 @@ import * as Cesium from 'cesium'; import { decodeCoverageTile } from './decode.js'; -import { passesImageryFilter } from '../../filter.js'; -import { - createHorizonCull, - groundUnderCamera, - metresBetween, - viewFocus, - visibleBbox, -} from '../../view.js'; -import { - coverageZoomForHeight, - overviewZoomForHeight, - tileBounds, - tilesForBbox, -} from '../../tileMath.js'; +import { passesImageryFilter, resolveFilter } from '../../filter.js'; +import { isActive } from '../../state.js'; +import { cameraNadir, groundUnderCamera, visibleBbox } from '../../view.js'; +import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js'; import { COLORS, COVERAGE_LINE_WIDTH_PX, COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES, COVERAGE_MOVE_DEBOUNCE_MS, - COVERAGE_OVERVIEW_MAX_TILES, - COVERAGE_OVERVIEW_POINT_PX, KEY_REJECTED_MESSAGE, PICK_PREFIX, RATE_LIMITED_MESSAGE, - SEQUENCE_VIEW_NEAR_M, SEQUENCE_VIEW_RANGE_MIN_M, SEQUENCE_VIEW_RANGE_PER_HEIGHT, } from './policy.js'; @@ -67,9 +54,15 @@ export function sequenceIdFromPick(pickId) { return cut === -1 ? rest : rest.slice(0, cut); } +/** Hint above the street-zoom ceiling, where no coverage is drawn. */ +export const ZOOM_IN_HINT = 'Zoom in to see street-level coverage'; +/** Hint when no ground is in view within range (looking at the sky). */ +export const NO_GROUND_HINT = + 'Point the camera at the ground for street-level coverage'; + /** - * Camera-driven coverage: overview points (z0–5) from orbit, sequence lines - * (z11–14) near the ground, draped on terrain and 3D tiles alike. + * Camera-driven coverage: Mapillary sequence lines (z11–14) near the ground, + * draped on terrain and 3D tiles alike. Nothing is drawn from orbit. */ export function createCoverage({ state, source }) { const { render } = state.services; @@ -89,7 +82,7 @@ export function createCoverage({ state, source }) { /** The imagery filter, resolved now: "since N days" moves with the clock. */ function filter() { - return state.context.getFilter(); + return resolveFilter(state.filter); } /** @@ -154,65 +147,17 @@ export function createCoverage({ state, source }) { return primitives; } - function buildOverviewCollection(points) { - const collection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha( - 0.85, - ); - const drawn = []; - const current = filter(); - for (const point of points) { - if (!passesImageryFilter(point, current)) continue; - drawn.push( - collection.add({ - position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), - color: green, - pixelSize: COVERAGE_OVERVIEW_POINT_PX, - // Google 3D terrain and clouds must not hide the near side's dots; - // the horizon cull hides the far side's, which this lets through. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }), - ); - } - return { collection, points: drawn }; - } - - /** Every overview point on the globe, current zoom and old alike. */ - function* overviewPoints() { - for (const entry of drawnEntries()) - if (entry.overviewPoints) yield* entry.overviewPoints; - } - - // Overview points skip the depth test: hide the far hemisphere's. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: overviewPoints, - onChange: requestRender, - }); - function attachPrimitive(entry) { const scene = state.viewer?.scene; if (!scene) return; - if (entry.kind === 'sequence') { - // Lookup, picking and counts follow what is drawn, not the whole tile. - const sequences = drawnSequences(entry); - entry.sequences = new Map(sequences.map((s) => [s.id, s])); - entry.primitives = buildSequencePrimitives(sequences); - entry.count = sequences.length; - for (const { primitive } of entry.primitives) - scene.groundPrimitives.add(primitive); - watchSelection(); - } else { - const { collection, points } = buildOverviewCollection(entry.points); - entry.primitive = collection; - entry.overviewPoints = points; - entry.count = points.length; - scene.primitives.add(collection); - // Cull the new points now, then all of them whenever the camera moves. - horizon.update(points); - } + // Lookup, picking and counts follow what is drawn, not the whole tile. + const sequences = drawnSequences(entry); + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + entry.primitives = buildSequencePrimitives(sequences); + entry.count = sequences.length; + for (const { primitive } of entry.primitives) + scene.groundPrimitives.add(primitive); + watchSelection(); } function detachPrimitive(entry) { @@ -225,15 +170,6 @@ export function createCoverage({ state, source }) { } } entry.primitives = []; - if (entry.primitive) { - try { - scene?.primitives?.remove(entry.primitive); - } catch { - /* already gone */ - } - entry.primitive = null; - entry.overviewPoints = null; - } } function removeTile(key) { @@ -243,7 +179,7 @@ export function createCoverage({ state, source }) { state.coverage.tiles.delete(key); } - async function loadTile(tile, kind) { + async function loadTile(tile) { const key = tileKey(tile); if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) return; @@ -257,43 +193,23 @@ export function createCoverage({ state, source }) { source.getTile('coverage', tile.z, tile.x, tile.y, { signal: controller.signal, }), - kind === 'sequence' ? ensureTerrainReady() : null, + ensureTerrainReady(), ]); // Only an abort, a retire, a clear or a newer request for this tile // discards the bytes; a refresh that still wants the tile keeps them. - const current = () => - state.coverage.pending.get(key) === controller && - !state.coverage.tiles.has(key); if ( controller.signal.aborted || - !current() || - kind !== state.coverage.kind || + state.coverage.pending.get(key) !== controller || + state.coverage.tiles.has(key) || tile.z !== state.coverage.zoom ) return; const decoded = decodeCoverageTile(bytes, tile); - const entry = { kind, primitive: null, primitives: [], count: 0 }; - // Padded: vector tiles carry a small buffer past their edge. - const bounds = tileBounds(tile.x, tile.y, tile.z); - const pad = (bounds.east - bounds.west) * 0.05; - entry.bounds = { - west: bounds.west - pad, - east: bounds.east + pad, - south: bounds.south - pad, - north: bounds.north + pad, - }; - if (kind === 'sequence') { - // Newest first; the per-tile cap applies to what passes the filter. - entry.sequenceList = decoded.sequences.sort( - (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), - ); - entry.sequences = new Map(); - entry.total = decoded.sequences.length; - } else { - entry.points = decoded.overview; - entry.sequences = new Map(); - entry.total = decoded.overview.length; - } + const entry = { primitives: [], count: 0, sequences: new Map() }; + // Newest first; the per-tile cap applies to what passes the filter. + entry.sequenceList = decoded.sequences.sort( + (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), + ); attachPrimitive(entry); state.coverage.tiles.set(key, entry); state.coverage.lastError = null; @@ -304,14 +220,11 @@ export function createCoverage({ state, source }) { state.coverage.pending.get(key) !== controller ) return; - state.coverage.lastError = error?.message || 'Coverage tile failed'; - if (error?.keyRequired) state.keyRequired = true; - if (error?.keyRejected) { - // Every other tile would be refused too: stop asking. - state.keyRejected = true; - state.coverage.lastError = KEY_REJECTED_MESSAGE; - } - if (error?.retryAfterSec) holdFor(error.retryAfterSec); + // Every other tile would be refused too: stop asking. + if (error?.keyRejected) block('rejected'); + else if (error?.keyRequired) block('no-key'); + else if (error?.retryAfterSec) block('rate-limited', error.retryAfterSec); + else state.coverage.lastError = error?.message || 'Coverage tile failed'; } finally { // Only the request that still owns the key settles it: a superseded one // must not drop a newer request's entry (which is what counts as loading). @@ -324,29 +237,42 @@ export function createCoverage({ state, source }) { } /** - * Mapillary is rate-limiting: keep what is drawn, request nothing until the - * wait is over, then refresh once. + * Pause tile requests (see `blocked` in state.js). A rate limit keeps what + * is drawn and refreshes once its wait is over; a key problem outranks it, + * and a rejected key holds until `unblock` (the layer goes off). */ - function holdFor(seconds) { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdUntil = Date.now() + seconds * 1000; - state.coverage.lastError = RATE_LIMITED_MESSAGE; - state.coverage.holdTimer = setTimeout(() => { - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - if (state.coverage.lastError === RATE_LIMITED_MESSAGE) - state.coverage.lastError = null; - refresh(); - }, seconds * 1000); + function block(reason, retryAfterSec = 0) { + const current = state.coverage.blocked; + if (current === 'rejected') return; + if (reason === 'rate-limited' && current === 'no-key') return; + clearTimeout(state.coverage.blockTimer); + state.coverage.blockTimer = null; + state.coverage.blocked = reason; + if (reason === 'rate-limited') + state.coverage.blockTimer = setTimeout(() => { + state.coverage.blockTimer = null; + state.coverage.blocked = null; + refresh(); + }, retryAfterSec * 1000); } - /** Forget refusals when the provider goes off, so the next run asks again. */ - function resetErrors() { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; + /** The layer went off: forget refusals and errors, so the next run asks again. */ + function unblock() { + clearTimeout(state.coverage.blockTimer); + state.coverage.blockTimer = null; + if (['rejected', 'rate-limited'].includes(state.coverage.blocked)) + state.coverage.blocked = null; state.coverage.lastError = null; - state.keyRejected = false; + } + + /** The server's key status: until it answers, nothing is requested. */ + function setKeyStatus(configured) { + if (!configured) block('no-key'); + else if (['status', 'no-key'].includes(state.coverage.blocked)) { + state.coverage.blocked = null; + refresh(); + } + notify(); } /** Drop the previous zoom's tiles once the new ones are on screen. */ @@ -375,74 +301,56 @@ export function createCoverage({ state, source }) { } function notify() { - state.context.notify(); + state.notify?.(); } /** - * Whether the screen centre meets the ground further ahead than the camera - * is high (a view tilted above 45Β°), or misses it. + * The tiles for the camera: what the screen shows within range of it, at + * the zoom for its height above ground, nearest the camera first. */ - function isTilted({ nadir, ahead }, height) { - if (!ahead) return true; - return metresBetween(nadir, ahead) > Math.max(1, height); - } - - /** Recompute the tile set for the current camera and reconcile primitives. */ - function refresh() { - const viewer = state.viewer; - if (!viewer || !state.context.isActive() || state.keyRequired) return; - if (state.statusKnown === false) return; - if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; + function wantedTiles(viewer) { const ground = groundUnderCamera(viewer); const cameraHeight = viewer.camera?.positionCartographic?.height; const height = Number.isFinite(cameraHeight) ? cameraHeight - (ground ?? 0) : null; - const sequenceZoom = coverageZoomForHeight(height); - const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); - const zoom = sequenceZoom ?? overviewZoom; - const kind = sequenceZoom ? 'sequence' : 'overview'; - // At street zooms the rays meet the ground where it really is (1,600 m up - // in Denver) and stop short of the horizon. - const ranged = - kind === 'sequence' - ? { - groundHeight: ground, - maxRange: Math.max( - SEQUENCE_VIEW_RANGE_MIN_M, - height * SEQUENCE_VIEW_RANGE_PER_HEIGHT, - ), - } - : {}; - // Tilted street views rank tiles along the line of sight, from the ground - // under the camera to the ground at the centre of the screen. - const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; - let bbox = visibleBbox(viewer, { - ...ranged, - // A tilted view's screen rows jump from the first metres to the horizon: - // keep the ground around the camera too. Looking down needs no help. - nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null, + const zoom = coverageZoomForHeight(height); + if (zoom == null) return { hint: ZOOM_IN_HINT }; + // Rays meet the ground where it really is (1,600 m up in Denver) and stop + // short of the horizon, which would stretch the box to the world. + const bbox = visibleBbox(viewer, { + groundHeight: ground, + maxRange: Math.max( + SEQUENCE_VIEW_RANGE_MIN_M, + height * SEQUENCE_VIEW_RANGE_PER_HEIGHT, + ), }); - if (kind === 'overview' && (!bbox || zoom <= 1)) - bbox = [-180, -85, 180, 85]; - if (zoom == null || !bbox) { - state.coverage.hint = - 'Point the camera at the globe for street-level coverage'; + if (!bbox) return { hint: NO_GROUND_HINT }; + // Ranked from the ground under the camera: a tilted view's box centre + // can sit kilometres ahead of anything near. + const { tiles } = tilesForBbox(bbox, zoom, { + limit: COVERAGE_MAX_TILES, + from: cameraNadir(viewer), + }); + return { zoom, tiles }; + } + + /** Recompute the tile set for the current camera and reconcile primitives. */ + function refresh() { + const viewer = state.viewer; + if (!viewer || !isActive(state) || state.coverage.blocked) return; + const { zoom, tiles, hint } = wantedTiles(viewer); + if (hint) { + state.coverage.hint = hint; clear(); notify(); return; } state.coverage.hint = ''; - if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { + if (zoom !== state.coverage.zoom) { retire(); state.coverage.zoom = zoom; - state.coverage.kind = kind; } - const { tiles } = tilesForBbox(bbox, zoom, { - limit: - kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, - focus: focus && { from: focus.nadir, to: focus.ahead }, - }); const wanted = new Set(tiles.map(tileKey)); for (const key of [...state.coverage.tiles.keys()]) if (!wanted.has(key)) removeTile(key); @@ -453,7 +361,7 @@ export function createCoverage({ state, source }) { controller.abort(); state.coverage.pending.delete(key); } - for (const tile of tiles) loadTile(tile, kind); + for (const tile of tiles) loadTile(tile); if (!state.coverage.pending.size) purgeStale(); notify(); } @@ -491,9 +399,7 @@ export function createCoverage({ state, source }) { for (const key of [...state.coverage.tiles.keys()]) removeTile(key); purgeStale(); state.coverage.zoom = null; - state.coverage.kind = null; stopSelectionWatch(); - horizon.stop(); requestRender(); } @@ -590,17 +496,32 @@ export function createCoverage({ state, source }) { } /** - * Sequences (or overview points) drawn. A sequence that crosses a tile - * edge is in both tiles' lists, so sequences count once by id. + * Sequences drawn. A sequence that crosses a tile edge is in both tiles' + * lists, so sequences count once by id. */ function sequenceCount() { const ids = new Set(); - let points = 0; for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') - for (const id of entry.sequences.keys()) ids.add(id); - else points += entry.count; - return ids.size + points; + for (const id of entry.sequences.keys()) ids.add(id); + return ids.size; + } + + /** What the panel and the layer list show: count, LOADING, key gate, error. */ + function stats() { + const { blocked } = state.coverage; + return { + count: sequenceCount(), + loading: state.coverage.pending.size > 0, + hint: state.coverage.hint, + keyRequired: blocked === 'no-key' || blocked === 'rejected', + keyRejected: blocked === 'rejected', + error: + blocked === 'rejected' + ? KEY_REJECTED_MESSAGE + : blocked === 'rate-limited' + ? RATE_LIMITED_MESSAGE + : state.coverage.lastError, + }; } return { @@ -608,9 +529,11 @@ export function createCoverage({ state, source }) { detach, refresh, clear, - resetErrors, + unblock, + setKeyStatus, rebuild, recolorSequence, sequenceCount, + stats, }; } diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 867c72031..370b4fed7 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; -import { createCoverage } from './coverage.js'; +import { createCoverage, ZOOM_IN_HINT } from './coverage.js'; +import { freshCoverage } from '../../state.js'; import { encodeCoverageTile } from './coverageFixture.mjs'; import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; import { @@ -110,26 +111,15 @@ function setup() { const state = { viewer, services: {}, - keyRequired: false, - context: { - filter: { pano: 'all', sinceMs: null }, - getFilter: () => state.context.filter, - isActive: () => true, - notify() {}, - }, + enabled: true, + providerOn: true, + filter: { pano: 'all', sinceDays: 0 }, + notify() {}, coverage: { - zoom: null, - kind: null, - tiles: new Map(), - stale: new Map(), - staleTimer: null, - pending: new Map(), - lastError: null, - debounceTimer: null, - removeCameraListener: null, - // Skip Cesium's one-time terrain table download. + ...freshCoverage(), + // The key status is in; skip Cesium's one-time terrain table download. + blocked: null, terrainReady: Promise.resolve(), - hint: '', }, sequence: { selectedId: null }, }; @@ -165,7 +155,7 @@ test('a superseded tile request cannot strand lines on the globe', async () => { coverage.refresh(); // street zoom: request #1 for the tile assert.equal(forTile().length, 1); - viewer.view.height = 100_000; // zoom out: retire() drops request #1 + viewer.view.height = 100_000; // zoom out: clear() drops request #1 coverage.refresh(); viewer.view.height = 900; // back in before #1 settles: request #2 coverage.refresh(); @@ -231,19 +221,49 @@ test('panning off a tile that is still loading stops counting it as loading', as coverage.clear(); }); -test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => { - const { viewer, source, state, coverage } = setup(); +test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => { + const { viewer, source, state, coverage, bytes } = setup(); + coverage.refresh(); + source.calls[0].resolve(bytes); + await settle(); + assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines'); viewer.view.height = 20_000_000; coverage.refresh(); - assert.equal(state.coverage.zoom, 0); - assert.equal(state.coverage.hint, ''); - assert.deepEqual( - source.calls.map((call) => call.key), - ['0/0/0'], - ); + assert.equal(source.calls.length, 1, 'no tile is asked for'); + assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn'); + assert.equal(state.coverage.zoom, null); + assert.equal(coverage.stats().hint, ZOOM_IN_HINT); + viewer.view.height = 900; + coverage.refresh(); + assert.equal(coverage.stats().hint, '', 'back at street zoom'); + assert.equal(source.calls.length, 2); coverage.clear(); }); +test('the key status gates coverage: nothing before it answers, nothing without a key', () => { + for (const configured of [false, true]) { + const { source, state, coverage } = setup(); + state.coverage.blocked = 'status'; + coverage.refresh(); + assert.equal(source.calls.length, 0, 'nothing before the status answers'); + coverage.setKeyStatus(configured); + if (configured) + assert.equal(source.calls.length, 1, 'coverage loads once it is known'); + else { + assert.equal(source.calls.length, 0, 'a key-less install never asks'); + assert.equal(coverage.stats().keyRequired, true); + assert.equal(coverage.stats().keyRejected, false); + coverage.unblock(); + assert.equal( + coverage.stats().keyRequired, + true, + 'kept: it is no refusal', + ); + } + coverage.clear(); + } +}); + test('a rejected key stops coverage requests until the layer goes off', async () => { const { viewer, source, state, coverage } = setup(); coverage.refresh(); @@ -255,16 +275,18 @@ test('a rejected key stops coverage requests until the layer goes off', async () ); source.calls[0].reject(rejected); await settle(); - assert.equal(state.keyRejected, true); - assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/); + assert.equal(state.coverage.blocked, 'rejected'); + assert.equal(coverage.stats().keyRequired, true, 'gated like a missing key'); + assert.equal(coverage.stats().keyRejected, true); + assert.match(coverage.stats().error, /rejected MAPILLARY_CLIENT_TOKEN/); viewer.view.lon += 0.05; coverage.refresh(); assert.equal(source.calls.length, 1, 'panning asks for nothing more'); - // Turning the provider off forgets the verdict; the next run asks again. + // Turning the layer off forgets the verdict; the next run asks again. coverage.clear(); - coverage.resetErrors(); - assert.equal(state.keyRejected, false); - assert.equal(state.coverage.lastError, null); + coverage.unblock(); + assert.equal(coverage.stats().keyRejected, false); + assert.equal(coverage.stats().error, null); coverage.refresh(); assert.equal(source.calls.length, 2); coverage.clear(); @@ -280,55 +302,16 @@ test('a rate limit keeps the drawn tiles and asks again once the wait is over', await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); - assert.match(state.coverage.lastError, /rate-limited/); + assert.match(coverage.stats().error, /rate-limited/); + assert.equal(coverage.stats().keyRequired, false); viewer.view.lon += 0.05; coverage.refresh(); assert.equal(source.calls.length, 1, 'held: nothing is requested'); t.mock.timers.tick(30_000); assert.equal(source.calls.length, 2, 'one refresh once the wait is over'); - assert.equal(state.coverage.lastError, null); + assert.equal(coverage.stats().error, null); coverage.clear(); - coverage.resetErrors(); -}); - -test('overview points behind the horizon are hidden, not drawn through the globe', async () => { - const { viewer, source, state, coverage } = setup(); - const { view } = viewer; - view.height = 20_000_000; - Object.defineProperty(viewer.camera, 'positionWC', { - get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height), - }); - coverage.refresh(); - // One dot under the camera (Sacramento), one near its antipode. - source.calls[0].resolve( - encodeCoverageTile( - { x: 0, y: 0, z: 0 }, - { - overview: [ - { id: 'near', lon: -121.5, lat: 38.6 }, - { id: 'far', lon: 58.5, lat: -38.6 }, - ], - }, - ), - ); - await settle(); - const [entry] = state.coverage.tiles.values(); - const points = [0, 1].map((i) => entry.primitive.get(i)); - const west = (point) => - Cesium.Cartographic.fromCartesian(point.position).longitude < 0; - const near = points.find(west); - const far = points.find((point) => !west(point)); - frame(viewer.preRender); - assert.equal(near.show, true, 'the near side stays visible'); - assert.equal(far.show, false, 'the far side does not show through'); - // Fly round to the other hemisphere: the two swap. - view.lon = 58.5; - view.lat = -38.6; - frame(viewer.preRender); - assert.equal(near.show, false); - assert.equal(far.show, true); - coverage.clear(); - assert.equal(viewer.preRender.size, 0, 'the cull listener is gone'); + coverage.unblock(); }); test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360Β° lines', async () => { @@ -354,7 +337,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i capturedAt: 1_000_000_000_000 + i, isPano: true, }); - state.context.filter = { pano: 'pano', sinceMs: null }; + state.filter = { pano: 'pano', sinceDays: 0 }; coverage.refresh(); source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); await settle(); @@ -365,7 +348,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i // Picking and recolouring look sequences up among the drawn lines. assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up'); assert.equal(entry.sequences.has('flat-699'), false); - state.context.filter = { pano: 'all', sinceMs: null }; + state.filter = { pano: 'all', sinceDays: 0 }; coverage.rebuild(); assert.equal(entry.count, cap, 'all imagery is capped as before'); assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn'); @@ -512,36 +495,25 @@ function denverStreetView(aheadM) { }; } -test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => { +test('a tilted street view over ground 1,600 m up asks for the ground it looks at, nearest the camera first', () => { // The screen centre meets the ground at the centre of the next tile east. const { state, coverage, keys, offsets, key } = denverStreetView( (tileWidthM) => tileWidthM, ); assert.equal(state.coverage.zoom, 14, 'height measured from the ground'); - assert.deepEqual( - keys.slice(0, 2), - [key(0, 0), key(1, 0)], - 'the tile under the camera, then the one the screen centre looks at', - ); - assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too'); + assert.equal(keys[0], key(0, 0), 'the tile under the camera first'); + assert.ok(keys.includes(key(1, 0)), 'the tile the screen centre looks at'); for (const [dx, dy] of offsets) - assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`); + assert.ok(dx >= 0 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in view`); coverage.clear(); }); -test('a street view toward the horizon ranks only the ground within range', () => { - // The screen centre meets the ground 6 km out, past the 3 km range: the - // tiles are ranked around the camera, not along a line to the horizon. +test('a street view toward the horizon asks only for the ground within range', () => { + // The screen centre meets the ground 6 km out, past the 3 km range. const { coverage, keys, offsets, key } = denverStreetView(() => 6000); assert.equal(keys[0], key(0, 0), 'the tile under the camera first'); - // Behind and ahead are equally near the camera (ties keep row order); a - // line of sight to the far ground would rank the tile ahead first. - assert.ok( - keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)), - 'the tile ahead is not pulled forward', - ); for (const [dx, dy] of offsets) - assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`); + assert.ok(dx >= 0 && dx <= 1 && Math.abs(dy) <= 1, `${dx},${dy} near it`); coverage.clear(); }); @@ -556,7 +528,7 @@ test('a selection cleared while the old zoom is still shown uncolours its lines' coverage.recolorSequence('seq-1', true); assert.deepEqual(Array.from(line.attributes.color), colourValue(true)); // Zoom out: the street tile stays on screen while the new zoom loads... - viewer.view.height = 100_000; + viewer.view.height = 3000; coverage.refresh(); assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept'); // ...and the selection is cleared meanwhile. @@ -577,7 +549,7 @@ test('a selection made while the old zoom is still building is applied once it i await settle(); const [entry] = state.coverage.tiles.values(); const line = controlled(entry.primitives[0], false); - viewer.view.height = 100_000; + viewer.view.height = 3000; coverage.refresh(); assert.equal(state.coverage.stale.size, 1); frame(viewer.postRender); @@ -645,7 +617,7 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy t.mock.timers.tick(1000); assert.ok(source.calls.length > 1, 'asked again once the wait is over'); coverage.clear(); - coverage.resetErrors(); + coverage.unblock(); }); test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => { diff --git a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs index 8fb94821b..c598a9ed8 100644 --- a/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverageFixture.mjs @@ -86,11 +86,11 @@ function lineGeometry(parts) { * Encode a `mly1_public`-shaped tile from lon/lat features, for tests and * the hermetic QA gate. * @param {{x: number, y: number, z: number}} tile - * @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content] + * @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array>}>, extent?: number}} [content] */ export function encodeCoverageTile( tile, - { sequences = [], overview = [], extent = 4096 } = {}, + { sequences = [], extent = 4096 } = {}, ) { const pbf = new PbfWriter(); if (sequences.length) { @@ -111,17 +111,5 @@ export function encodeCoverageTile( ); pbf.writeMessage(3, (_, out) => layer.write(out), null); } - if (overview.length) { - const layer = layerWriter('overview', extent); - for (const point of overview) { - const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent); - layer.add(1, [9, zigzag(px), zigzag(py)], { - id: point.id, - captured_at: point.capturedAt ?? Date.now(), - is_pano: point.isPano === true, - }); - } - pbf.writeMessage(3, (_, out) => layer.write(out), null); - } return new Uint8Array(pbf.finish()); } diff --git a/src/layers/streetLevel/providers/mapillary/decode.js b/src/layers/streetLevel/providers/mapillary/decode.js index b4381dbad..a3e06a5e1 100644 --- a/src/layers/streetLevel/providers/mapillary/decode.js +++ b/src/layers/streetLevel/providers/mapillary/decode.js @@ -3,13 +3,13 @@ import { VectorTile } from '@mapbox/vector-tile'; import { tileLocalToLonLat } from '../../tileMath.js'; /** - * Decode a `mly1_public` coverage tile into sequences and (z0–5) overview - * points. The z14 `image` layer, which can hold >150k points, is skipped. + * Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14 + * `image` layer, which can hold >150k points, is skipped. * @param {Uint8Array} bytes * @param {{x:number,y:number,z:number}} address */ export function decodeCoverageTile(bytes, address) { - const result = { sequences: [], overview: [] }; + const result = { sequences: [] }; if (!bytes || !bytes.length) return result; const tile = new VectorTile(new PbfReader(bytes)); const { x, y, z } = address; @@ -41,30 +41,5 @@ export function decodeCoverageTile(bytes, address) { }); } } - const overviewLayer = tile.layers.overview; - if (overviewLayer) { - for (let i = 0; i < overviewLayer.length; i++) { - const feature = overviewLayer.feature(i); - const props = feature.properties || {}; - const point = feature.loadGeometry()?.[0]?.[0]; - if (!point) continue; - const [lon, lat] = tileLocalToLonLat( - point.x, - point.y, - overviewLayer.extent, - x, - y, - z, - ); - result.overview.push({ - id: String(props.id ?? feature.id ?? i), - lon, - lat, - capturedAt: Number(props.captured_at) || 0, - isPano: props.is_pano === true, - sequenceId: props.sequence_id ? String(props.sequence_id) : null, - }); - } - } return result; } diff --git a/src/layers/streetLevel/providers/mapillary/decode.test.mjs b/src/layers/streetLevel/providers/mapillary/decode.test.mjs index eeb1f0261..d8b3cf7b2 100644 --- a/src/layers/streetLevel/providers/mapillary/decode.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/decode.test.mjs @@ -17,7 +17,10 @@ function linePart(points) { return out; } -/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */ +/** + * A minimal mly1_public-shaped tile: one sequence line, plus one point in the + * low-zoom `overview` layer the layer never draws. + */ function sampleTile({ geometry = null } = {}) { const writer = new PbfWriter(); writer.writeMessage( @@ -94,7 +97,7 @@ function sampleTile({ geometry = null } = {}) { return new Uint8Array(writer.finish()); } -test('sequences and overview points decode into lon/lat records', () => { +test('sequences decode into lon/lat records; overview points are ignored', () => { const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 }); assert.equal(decoded.sequences.length, 1); const [sequence] = decoded.sequences; @@ -109,19 +112,13 @@ test('sequences and overview points decode into lon/lat records', () => { 'west half of tile 0/0 at z1', ); assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south'); - assert.equal(decoded.overview.length, 1); - assert.equal(decoded.overview[0].id, '3'); - assert.equal(decoded.overview[0].isPano, false); - assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre'); + assert.deepEqual(Object.keys(decoded), ['sequences']); }); test('an empty tile decodes to empty lists', () => { assert.deepEqual( decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }), - { - sequences: [], - overview: [], - }, + { sequences: [] }, ); }); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js deleted file mode 100644 index c97aacb88..000000000 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ /dev/null @@ -1,214 +0,0 @@ -import { createCoverage, sequenceIdFromPick } from './coverage.js'; -import { createSequences } from './sequences.js'; -import { createMapillaryViewer } from './viewer.js'; -import { passesImageryFilter } from '../../filter.js'; -import { metresBetween } from '../../view.js'; -import { - COLORS, - MAPILLARY_CREDIT_HTML, - MAPILLARY_KEY_ID, - MAPILLARY_LABEL, - MAPILLARY_NAME, - MAPILLARY_PROVIDER_ID, - MAPILLARY_SOURCE_METHODS, - NEAREST_LIMIT, - NEAREST_RADIUS_M, - PICK_PREFIX, -} from './policy.js'; - -/** Per-provider mutable state, created once per `create()`. */ -function createProviderState(context) { - return { - context, - services: context.services, - viewer: null, - keyRequired: false, - /** Coverage waits for the first status: no tile requests without a key. */ - statusKnown: false, - /** Mapillary refused the token: request nothing until re-enabled. */ - keyRejected: false, - status: null, - coverage: { - zoom: null, - /** Current zoom's tiles by `z/x/y` key. */ - tiles: new Map(), - /** Previous zoom's tiles, kept on screen until replacements land. */ - stale: new Map(), - staleTimer: null, - /** Tile key β†’ its request's controller; any entry means LOADING. */ - pending: new Map(), - lastError: null, - /** While Mapillary rate-limits: no requests before this time (ms). */ - holdUntil: 0, - holdTimer: null, - debounceTimer: null, - removeCameraListener: null, - terrainReady: null, - hint: '', - kind: null, - }, - sequence: { - selectedId: null, - images: [], - /** Recent sequences' thinned images, by sequence id. */ - cache: new Map(), - collection: null, - loading: false, - abort: null, - }, - }; -} - -/** - * Mapillary Street Level provider: tile coverage, Graph API image cones and - * the MapillaryJS viewer. `source` is ./source.js or a stand-in. - * @returns {import('../../registry.js').StreetLevelProvider} - */ -export function createMapillaryProvider({ source }) { - if ( - !MAPILLARY_SOURCE_METHODS.every( - (method) => typeof source?.[method] === 'function', - ) - ) - throw new TypeError('A Mapillary source is required'); - - return Object.freeze({ - id: MAPILLARY_PROVIDER_ID, - name: MAPILLARY_NAME, - label: MAPILLARY_LABEL, - requiresKeyId: MAPILLARY_KEY_ID, - pickPrefix: PICK_PREFIX.root, - colors: COLORS, - credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }), - - create(context) { - const state = createProviderState(context); - const parts = {}; - parts.coverage = createCoverage({ state, source, parts }); - parts.sequences = createSequences({ state, source, parts }); - const viewer = createMapillaryViewer({ - source, - render: context.services?.render, - }); - - return { - async status() { - try { - state.status = await source.getStatus(); - state.keyRequired = state.status?.configured !== true; - } catch { - state.status = null; - state.keyRequired = !source.hasToken(); - } - const first = !state.statusKnown; - state.statusKnown = true; - // Coverage held back until now: draw it if the layer is on. - if (first) parts.coverage.refresh(); - context.notify(); - return { configured: !state.keyRequired }; - }, - - init(cesiumViewer) { - state.viewer = cesiumViewer; - parts.sequences.ensureCollections(cesiumViewer); - parts.sequences.setVisible(false); - }, - - activate(cesiumViewer) { - state.viewer = cesiumViewer; - parts.sequences.setVisible(true); - parts.coverage.attach(cesiumViewer); - }, - - deactivate() { - parts.coverage.detach(); - parts.coverage.clear(); - parts.coverage.resetErrors(); - parts.sequences.clearSelection(); - parts.sequences.setVisible(false); - }, - - destroy(cesiumViewer) { - parts.coverage.detach(); - parts.coverage.clear(); - parts.coverage.resetErrors(); - parts.sequences.destroy(cesiumViewer); - }, - - refreshCoverage: () => parts.coverage.refresh(), - - /** The filter changed: redraw. Each draw reads it from the context. */ - setFilter() { - parts.coverage.rebuild(); - parts.sequences.rerender(); - }, - - coverageStats() { - return { - count: parts.coverage.sequenceCount(), - zoom: state.coverage.zoom, - kind: state.coverage.kind, - loading: state.coverage.pending.size > 0, - hint: state.coverage.hint, - error: state.coverage.lastError, - keyRequired: state.keyRequired, - keyRejected: state.keyRejected, - }; - }, - - handlePick(pickId) { - const sequenceId = sequenceIdFromPick(pickId); - if (sequenceId) { - parts.sequences.select(sequenceId); - return true; - } - if (pickId.startsWith(PICK_PREFIX.image)) { - context.actions.openImage(pickId.slice(PICK_PREFIX.image.length)); - return true; - } - return false; - }, - - selectSequence: (sequenceId) => parts.sequences.select(sequenceId), - clearSequence: () => parts.sequences.clearSelection(), - sequenceStats: () => ({ - selectedId: state.sequence.selectedId, - images: state.sequence.images.length, - loading: state.sequence.loading, - }), - - /** - * Nearest image that passes the imagery filter, or null. The core - * aborts `signal` once a newer request overtakes the lookup. - */ - async nearestImage({ lat, lon }, { signal } = {}) { - const images = await source.nearestImages( - { lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT }, - { signal }, - ); - // The API returns the images in the radius in no particular order. - const metres = (record) => { - const [lon2, lat2] = (record.computed_geometry || record.geometry) - ?.coordinates || [NaN, NaN]; - const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 }); - return Number.isFinite(d) ? d : Infinity; - }; - const byDistance = [...images].sort((a, b) => metres(a) - metres(b)); - const filter = context.getFilter(); - const hit = byDistance.find((record) => - passesImageryFilter( - { - isPano: record.is_pano === true, - capturedAt: Number(record.captured_at) || 0, - }, - filter, - ), - ); - return hit ? String(hit.id) : null; - }, - - viewer, - }; - }, - }); -} diff --git a/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs new file mode 100644 index 000000000..8e6fcb914 --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/mapillary.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { nearestImageId } from './nearest.js'; +import { COLORS, 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('Mapillary draws in its green; image deep links match the mapillary.com share format', () => { + assert.equal(COLORS.coverage, '#05cb63'); + assert.equal( + mapillaryImageUrl(1814275685699406), + 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', + ); + 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', () => { + 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. + const kept = thinImages( + [ + image('a', 179.99999), + image('b', -179.99999), // ~2 m from a, across the date line + image('c', -179.9999), // ~11 m from a + ], + 3, + ); + assert.deepEqual( + kept.map(({ id }) => id), + ['a', 'c'], + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/nearest.js b/src/layers/streetLevel/providers/mapillary/nearest.js new file mode 100644 index 000000000..89832b1e3 --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/nearest.js @@ -0,0 +1,38 @@ +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 6c038566e..36fa2e0b0 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -1,15 +1,10 @@ -import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; +import { COLORS as SHARED_COLORS } from '../../policy.js'; -export { NEAREST_RADIUS_M } from '../../policy.js'; - -/** Identity and tuning for the Mapillary street-level provider. */ -export const MAPILLARY_PROVIDER_ID = 'mapillary'; -export const MAPILLARY_NAME = 'Mapillary'; -export const MAPILLARY_LABEL = 'MAPILLARY'; +/** 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 for the provider. */ +/** Methods a source (./source.js or a stand-in) must have. */ export const MAPILLARY_SOURCE_METHODS = Object.freeze([ 'getStatus', 'getTile', @@ -24,17 +19,15 @@ export const PICK_PREFIX = Object.freeze({ image: 'mly:img:', }); -/** Mapillary green for lines, points and cones; GEV cyan for selection. */ +/** Mapillary green for lines, cones and the legend; GEV cyan for selection. */ export const COLORS = Object.freeze({ - coverage: PROVIDER_COLORS.mapillary, + coverage: '#05cb63', selected: SHARED_COLORS.selected, }); /** Camera-driven coverage refresh. */ export const COVERAGE_MOVE_DEBOUNCE_MS = 320; export const COVERAGE_MAX_TILES = 9; -/** Overview (z0–5) coverage points seen from orbit. */ -export const COVERAGE_OVERVIEW_MAX_TILES = 16; /** 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'; @@ -47,9 +40,6 @@ export const RATE_LIMITED_MESSAGE = */ export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500; export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10; -/** Ground always covered around the camera at street zooms, in metres. */ -export const SEQUENCE_VIEW_NEAR_M = 1_000; -export const COVERAGE_OVERVIEW_POINT_PX = 2.5; export const COVERAGE_MAX_SEQUENCES = 6000; export const COVERAGE_LINE_WIDTH_PX = 2.5; @@ -58,7 +48,9 @@ export const SEQUENCE_IMAGES_LIMIT = 2000; export const IMAGE_CONE_SIZE_PX = 26; export const IMAGE_CONE_MIN_SPACING_M = 3; -/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */ +/** 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. */ diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs deleted file mode 100644 index 35e8593bf..000000000 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ /dev/null @@ -1,252 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createMapillaryProvider } from './index.js'; -import { mapillaryImageUrl } from './policy.js'; -import { sequenceIdFromPick } from './coverage.js'; -import { thinImages } from './sequences.js'; -import { validateProviders } from '../../registry.js'; -import { PROVIDER_COLORS } from '../../policy.js'; -import { resolveFilter } from '../../filter.js'; -import * as Cesium from 'cesium'; -import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; - -function fakeSource({ nearest = [] } = {}) { - return { - hasToken: () => true, - getStatus: async () => ({ configured: true }), - getTile: async () => new Uint8Array(0), - getSequenceImages: async () => [], - nearestImages: async () => nearest, - }; -} - -function fakeContext(filter = { pano: 'all', sinceMs: null }) { - const opened = []; - return { - opened, - services: {}, - getFilter: typeof filter === 'function' ? filter : () => filter, - isActive: () => true, - notify: () => {}, - actions: { - openImage: (id) => { - opened.push(id); - }, - reportError: () => {}, - }, - }; -} - -test('the definition satisfies the Street Level provider contract', () => { - const def = createMapillaryProvider({ source: fakeSource() }); - assert.doesNotThrow(() => validateProviders([def])); - assert.equal(def.id, 'mapillary'); - assert.equal(def.label, 'MAPILLARY'); - assert.equal(def.requiresKeyId, 'mapillary'); - assert.equal(def.pickPrefix, 'mly:'); - assert.match(def.credit.html, /CC BY-SA 4\.0/); - // One colour per source: Mapillary is green everywhere it draws. - assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']); - assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary); - assert.equal(PROVIDER_COLORS.mapillary, '#05cb63'); - assert.throws(() => createMapillaryProvider({ source: {} }), /source/); -}); - -test('image deep links match the mapillary.com share format', () => { - assert.equal( - mapillaryImageUrl(1814275685699406), - 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', - ); - assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/'); -}); - -test('picks route sequences to selection and images to the core opener', () => { - const context = fakeContext(); - const instance = createMapillaryProvider({ source: fakeSource() }).create( - context, - ); - assert.equal(instance.handlePick('mly:img:77'), true); - assert.deepEqual(context.opened, ['77']); - assert.equal(instance.handlePick('cctv:1'), false); - assert.deepEqual(instance.sequenceStats(), { - selectedId: null, - images: 0, - loading: false, - }); -}); - -test('nearestImage honours the imagery filter and reports key status', async () => { - const nearest = [ - { id: 1, is_pano: false, captured_at: 10 }, - { id: 2, is_pano: true, captured_at: 20 }, - ]; - const source = fakeSource({ nearest }); - const flat = createMapillaryProvider({ source }).create(fakeContext()); - assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1'); - const pano = createMapillaryProvider({ source }).create( - fakeContext({ pano: 'pano', sinceMs: null }), - ); - assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2'); - const none = createMapillaryProvider({ source }).create( - fakeContext({ pano: 'all', sinceMs: 100 }), - ); - assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null); - assert.deepEqual(await flat.status(), { configured: true }); - assert.equal(flat.coverageStats().keyRequired, false); -}); - -test('any part of a multi-part sequence picks the whole sequence', () => { - 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('nearestImage picks the closest image, not the first the API returned', async () => { - const at = (id, lon, lat) => ({ - id, - is_pano: false, - captured_at: 10, - geometry: { type: 'Point', coordinates: [lon, lat] }, - }); - const source = fakeSource({ - nearest: [ - at('far', 2.0004, 1), // ~45 m east - at('near', 2.00005, 1), // ~5 m east - at('mid', 2, 1.0002), // ~22 m north - ], - }); - const instance = createMapillaryProvider({ source }).create(fakeContext()); - assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), '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({ - nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }], - }); - // The core's context resolves the stored filter whenever it is asked. - const instance = createMapillaryProvider({ source }).create( - fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })), - ); - assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent'); - t.mock.timers.tick(2 * day); - assert.equal( - await instance.nearestImage({ lat: 1, lon: 2 }), - null, - 'two days on, the image is older than the window', - ); -}); - -test('nearestImage measures the short way round the date line', async () => { - const at = (id, lon, lat) => ({ - id, - is_pano: false, - captured_at: 10, - geometry: { type: 'Point', coordinates: [lon, lat] }, - }); - const source = fakeSource({ - nearest: [ - at('same-side', 179.9995, 0), // ~45 m west, on the camera's side - at('across', -179.9999, 0), // ~11 m east, across Β±180Β° - ], - }); - const instance = createMapillaryProvider({ source }).create(fakeContext()); - assert.equal( - await instance.nearestImage({ lat: 0, lon: 179.9999 }), - 'across', - ); - // And from the other side. - const west = createMapillaryProvider({ - source: fakeSource({ - nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)], - }), - }).create(fakeContext()); - assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), '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. - const kept = thinImages( - [ - image('a', 179.99999), - image('b', -179.99999), // ~2 m from a, across the date line - image('c', -179.9999), // ~11 m from a - ], - 3, - ); - assert.deepEqual( - kept.map(({ id }) => id), - ['a', 'c'], - ); -}); - -/** A viewer looking straight down on downtown Sacramento from 900 m. */ -function streetViewer() { - const collection = () => ({ - add: (item) => item, - remove: () => true, - contains: () => false, - get length() { - return 0; - }, - }); - const event = () => ({ addEventListener: () => () => {} }); - const camera = Object.assign( - rayCamera({ - lon: -121.4944, - lat: 38.5816, - altitude: 900, - pitch: -90, - width: 100, - height: 100, - }), - { changed: event(), moveEnd: event() }, - ); - return { - camera, - scene: { - canvas: { clientWidth: 100, clientHeight: 100 }, - globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, - primitives: collection(), - groundPrimitives: collection(), - preRender: event(), - postRender: event(), - requestRender() {}, - }, - }; -} - -test('coverage asks for no tiles before the key status is known', async () => { - for (const configured of [false, true]) { - let answer; - const tiles = []; - const source = { - ...fakeSource(), - hasToken: () => configured, - getStatus: () => new Promise((resolve) => (answer = resolve)), - getTile: (...args) => { - tiles.push(args); - return new Promise(() => {}); - }, - }; - const instance = createMapillaryProvider({ source }).create(fakeContext()); - const viewer = streetViewer(); - instance.init(viewer); - const status = instance.status(); - instance.activate(viewer); - assert.equal(tiles.length, 0, 'nothing before the status answers'); - answer({ configured }); - await status; - if (configured) - assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known'); - else { - assert.equal(tiles.length, 0, 'a key-less install never asks'); - assert.equal(instance.coverageStats().keyRequired, true); - } - instance.destroy(viewer); - } -}); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index cc70f2794..4cbc145a6 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { imageConeGlyph } from '../../glyphs.js'; -import { passesImageryFilter } from '../../filter.js'; +import { passesImageryFilter, resolveFilter } from '../../filter.js'; import { createHorizonCull, metresBetween } from '../../view.js'; import { COLORS, @@ -53,7 +53,12 @@ export function createSequences({ state, source, parts }) { } function notify() { - state.context.notify(); + state.notify?.(); + } + + /** Show a sequence error, or withdraw it (null); see `reportSequenceError`. */ + function reportError(message) { + parts.reportSequenceError?.(message); } /** Drawn cone billboards. */ @@ -95,7 +100,7 @@ export function createSequences({ state, source, parts }) { pano: true, }); // Resolved now, so a "since N days" window keeps up with the clock. - const filter = state.context.getFilter(); + const filter = resolveFilter(state.filter); for (const image of images) { if (!passesImageryFilter(image, filter)) continue; const billboard = collection.add({ @@ -146,7 +151,7 @@ export function createSequences({ state, source, parts }) { state.sequence.loading = false; state.sequence.images = cached; renderCones(cached); - state.context.actions.reportError(null); + reportError(null); notify(); return; } @@ -172,15 +177,13 @@ export function createSequences({ state, source, parts }) { remember(sequenceId, images); state.sequence.images = images; renderCones(images); - state.context.actions.reportError(null); + reportError(null); } catch (error) { if (!controller.signal.aborted) { // Nothing to show: drop the highlight, so a click asks again. parts.coverage.recolorSequence(sequenceId, false); state.sequence.selectedId = null; - state.context.actions.reportError( - error?.message || 'Sequence images unavailable', - ); + reportError(error?.message || 'Sequence images unavailable'); } } finally { if (state.sequence.abort === controller) { @@ -200,7 +203,7 @@ export function createSequences({ state, source, parts }) { state.sequence.loading = false; clearCones(); // An error about the sequence goes with it. - state.context.actions.reportError(null); + reportError(null); requestRender(); notify(); } diff --git a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs index d644cfc87..03c9834b0 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs @@ -62,11 +62,8 @@ function setup() { const state = { services: {}, viewer: {}, - context: { - notify() {}, - getFilter: () => ({ pano: 'all', sinceMs: null }), - actions: { reportError: (message) => errors.push(message) }, - }, + filter: { pano: 'all', sinceDays: 0 }, + notify() {}, sequence: { selectedId: null, images: [], @@ -80,6 +77,7 @@ function setup() { coverage: { recolorSequence: (id, selected) => colours.push([id, selected]), }, + reportSequenceError: (message) => errors.push(message), }; const sequences = createSequences({ state, source, parts }); const drawn = () => diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index a5fb0ed30..8a9a041bf 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -1,18 +1,28 @@ -import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js'; +import { mapillaryImageUrl } from './policy.js'; /** - * Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a - * provider-neutral pose on every image, view or position change. - * @returns {import('../../registry.js').ViewerAdapter} + * @typedef {object} StreetPose + * @property {string} imageId + * @property {{lon: number, lat: number}} position + * @property {number|null} bearing + * @property {number|null} tilt + * @property {number|null} altitude + * @property {boolean} isPano + * @property {number|null} capturedAt Epoch milliseconds. + * @property {string|null} creator + * @property {string|null} sequenceId + * @property {string} externalUrl Deep link to the image on mapillary.com. + */ + +/** + * The panel's photo viewer: loads MapillaryJS on the first `mount` and emits + * a pose on every image, view or position change. */ export function createMapillaryViewer({ source, render } = {}) { let viewer = null; let Library = null; let container = null; let pendingOpen = null; - let prewarming = null; - /** In-flight viewer construction, so pre-warm and open never build two. */ - let creating = null; /** Bumped by `unmount`, so a construction it overtook builds nothing. */ let generation = 0; let renderMode = 'letterbox'; @@ -52,7 +62,7 @@ export function createMapillaryViewer({ source, render } = {}) { } } - /** Read MapillaryJS image metadata into the neutral pose shape. */ + /** Read MapillaryJS image metadata into the pose shape. */ function describe(image) { return { imageId: String(image.id), @@ -81,7 +91,6 @@ export function createMapillaryViewer({ source, render } = {}) { if (image) current = describe(image); if (!current || !viewer) return; emit({ - providerId: MAPILLARY_PROVIDER_ID, ...current, position: { lon: lngLat.lng, lat: lngLat.lat }, bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null, @@ -94,17 +103,8 @@ export function createMapillaryViewer({ source, render } = {}) { } } - function ensureViewer(host) { + async function ensureViewer(host) { if (viewer && container === host) return viewer; - if (creating?.container === host) return creating.promise; - const promise = createViewer(host).finally(() => { - if (creating?.promise === promise) creating = null; - }); - creating = { container: host, promise }; - return promise; - } - - async function createViewer(host) { const built = generation; destroyViewer(); const { Viewer } = await ensureLibrary(); @@ -178,9 +178,8 @@ export function createMapillaryViewer({ source, render } = {}) { unmount() { pendingOpen = null; - // The next mount builds afresh rather than joining a stale build. + // A build still waiting for the library gives up when it arrives. generation++; - creating = null; destroyViewer(); }, @@ -192,26 +191,6 @@ export function createMapillaryViewer({ source, render } = {}) { } }, - /** Build the viewer ahead of the first image. Safe to call repeatedly. */ - async prewarm(host) { - if (!host) return; - // An in-flight prewarm may give up (layer toggled mid-download): wait - // for it, then build if it did not. - if (prewarming) await prewarming; - if (viewer) return; - const run = (async () => { - try { - await ensureLibrary(); - if (!viewer) await ensureViewer(host); - } catch { - /* the real open reports errors */ - } - })(); - prewarming = run; - await run; - if (prewarming === run) prewarming = null; - }, - /** Show the whole image ('letterbox') or crop it to the frame ('fill'). */ setRenderMode(mode) { renderMode = mode === 'fill' ? 'fill' : 'letterbox'; diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js deleted file mode 100644 index 53feaf15b..000000000 --- a/src/layers/streetLevel/registry.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * The contract an imagery provider registers with the Street Level layer. - * - * @typedef {object} StreetLevelProvider - * @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key. - * @property {string} name Human name for captions and legends, e.g. 'Mapillary'. - * @property {string} label Chip text and link label, e.g. 'MAPILLARY'. - * @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless. - * @property {string} pickPrefix Every primitive id the provider creates starts with it. - * @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS. - * @property {{html: string}} credit On-globe attribution while the provider is active. - * @property {(context: ProviderContext) => ProviderInstance} create - * - * @typedef {object} ProviderContext Handed to `create()` once by the core. - * @property {object} services Scene services: picking, input, render, sprites. - * @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up. - * @property {() => boolean} isActive Layer enabled and this provider switched on. - * @property {() => void} notify Ask the core to publish a new UI snapshot. - * @property {{openImage: (imageId: string) => Promise, reportError: (message: string|null) => void}} actions - * - * @typedef {object} ProviderInstance - * @property {() => Promise<{configured: boolean}>} status - * @property {(viewer: object) => void} init Create collections, hidden. - * @property {(viewer: object) => void} activate Start drawing coverage for the camera. - * @property {() => void} deactivate Detach, clear coverage and selection, hide. - * @property {(viewer: object) => void} destroy - * @property {() => void} refreshCoverage - * @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`). - * @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats - * @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix. - * @property {(sequenceId: string) => Promise} [selectSequence] - * @property {() => void} [clearSequence] - * @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats] - * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise} nearestImage An aborted lookup gives up (it may reject with an AbortError). - * @property {ViewerAdapter} viewer - * - * @typedef {object} ViewerAdapter - * @property {(host: HTMLElement) => Promise} mount Idempotent; may lazy-load a library. - * @property {(imageId: string) => Promise} open Resolves once the image is on screen and a pose was emitted. - * @property {() => void} close Drop the image, keep the instance warm. - * @property {() => void} unmount Destroy the instance (host handed to another provider). - * @property {() => void} resize - * @property {(host: HTMLElement) => Promise} [prewarm] - * @property {(mode: 'letterbox'|'fill') => void} [setRenderMode] - * @property {(listener: (pose: StreetPose) => void) => () => void} onPose - * - * @typedef {object} StreetPose - * @property {string} providerId - * @property {string} imageId - * @property {{lon: number, lat: number}} position - * @property {number|null} bearing - * @property {number|null} tilt - * @property {number|null} altitude - * @property {boolean} isPano - * @property {number|null} capturedAt Epoch milliseconds. - * @property {string|null} creator - * @property {string|null} sequenceId - * @property {string} externalUrl Deep link to the image on the provider's site. - */ - -const REQUIRED = Object.freeze([ - 'id', - 'name', - 'label', - 'pickPrefix', - 'colors', - 'credit', - 'create', -]); - -const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/; - -/** - * Validate providers and freeze their order, which is also the chip order. - * @param {Array} providers - * @returns {ReadonlyArray} - */ -export function validateProviders(providers) { - if (!Array.isArray(providers) || providers.length === 0) - throw new TypeError('Street Level needs at least one imagery provider'); - const ids = new Set(); - const prefixes = []; - for (const provider of providers) { - const label = provider?.id ?? '(unnamed)'; - for (const key of REQUIRED) - if (provider?.[key] === undefined || provider?.[key] === null) - throw new TypeError(`Street Level provider ${label} lacks ${key}`); - if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id)) - throw new TypeError(`Street Level provider id ${label} is not a slug`); - if (ids.has(provider.id)) - throw new TypeError(`Duplicate Street Level provider ${provider.id}`); - if (typeof provider.create !== 'function') - throw new TypeError( - `Street Level provider ${label}: create is not a function`, - ); - if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix) - throw new TypeError(`Street Level provider ${label} needs a pick prefix`); - for (const other of prefixes) - if ( - other.startsWith(provider.pickPrefix) || - provider.pickPrefix.startsWith(other) - ) - throw new TypeError( - `Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`, - ); - if (!provider.credit?.html) - throw new TypeError(`Street Level provider ${label} needs a credit`); - if ( - typeof provider.colors?.coverage !== 'string' || - !provider.colors.coverage - ) - throw new TypeError( - `Street Level provider ${label} needs a coverage colour`, - ); - ids.add(provider.id); - prefixes.push(provider.pickPrefix); - } - return Object.freeze([...providers]); -} - -/** - * The key id every provider shares, or null if any is keyless or they differ - * (then each chip reports its own need). - * @param {ReadonlyArray} providers - * @returns {string|null} - */ -export function requiresKeyIdFor(providers) { - const keys = new Set(providers.map((p) => p.requiresKeyId || null)); - if (keys.size !== 1) return null; - return [...keys][0]; -} diff --git a/src/layers/streetLevel/registry.test.mjs b/src/layers/streetLevel/registry.test.mjs deleted file mode 100644 index 278bf5ffa..000000000 --- a/src/layers/streetLevel/registry.test.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { requiresKeyIdFor, validateProviders } from './registry.js'; - -const def = (overrides = {}) => ({ - id: 'mapillary', - name: 'Mapillary', - label: 'MAPILLARY', - requiresKeyId: 'mapillary', - pickPrefix: 'mly:', - colors: { coverage: '#05cb63' }, - credit: { html: 'Mapillary' }, - create: () => ({}), - ...overrides, -}); - -test('a valid list is frozen in registration order', () => { - const list = validateProviders([ - def(), - def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }), - ]); - assert.ok(Object.isFrozen(list)); - assert.deepEqual( - list.map((p) => p.id), - ['mapillary', 'panoramax'], - ); -}); - -test('an empty list, duplicate ids and malformed ids are rejected', () => { - assert.throws(() => validateProviders([]), /at least one/); - assert.throws(() => validateProviders(null), /at least one/); - assert.throws(() => validateProviders([def(), def()]), /Duplicate/); - assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/); -}); - -test('every required field is checked by name', () => { - for (const key of [ - 'name', - 'label', - 'pickPrefix', - 'colors', - 'credit', - 'create', - ]) - assert.throws( - () => validateProviders([def({ [key]: undefined })]), - new RegExp(`lacks ${key}`), - key, - ); - assert.throws(() => validateProviders([def({ create: 1 })]), /create/); - assert.throws(() => validateProviders([def({ credit: {} })]), /credit/); - assert.throws( - () => validateProviders([def({ colors: { pano: '#f0f' } })]), - /coverage colour/, - ); - assert.throws( - () => validateProviders([def({ pickPrefix: '' })]), - /pick prefix/, - ); -}); - -test('overlapping pick prefixes are rejected either way round', () => { - assert.throws( - () => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]), - /overlaps/, - ); - assert.throws( - () => - validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]), - /overlaps/, - ); - assert.doesNotThrow(() => - validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]), - ); -}); - -test('requiresKeyIdFor is the shared key, or null once providers differ', () => { - assert.equal(requiresKeyIdFor([def()]), 'mapillary'); - assert.equal( - requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]), - 'mapillary', - ); - assert.equal( - requiresKeyIdFor([ - def(), - def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }), - ]), - null, - ); - assert.equal( - requiresKeyIdFor([ - def(), - def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }), - ]), - null, - ); - assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null); -}); diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index e3afd99ac..fd395e6aa 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -1,30 +1,37 @@ import * as Cesium from 'cesium'; -import { STREET_LEVEL_LAYER_ID } from './policy.js'; +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'; /** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */ const HOVER_PICK_INTERVAL_MS = 120; -/** One click/hover handler for every provider's coverage and image cones. */ +/** One click/hover handler for coverage lines, image cones and the marker. */ export function createSelection({ state, parts }) { const { picking, input } = state.services; - function ownsPick(pickedId) { - return parts.router.ownsPick(pickedId); + /** Lines and cones (`mly:`) and the position marker are this layer's. */ + function ownsPick(id) { + return ( + typeof id === 'string' && + (id === POSITION_PICK_ID || id.startsWith(PICK_PREFIX.root)) + ); } + /** A line selects its sequence; a cone opens its image. */ function onClick(click) { const viewer = state.viewer; - if (!viewer || !state.enabled) return; + if (!viewer || !state.enabled || !state.providerOn) return; if (input?.isPointerFree && !input.isPointerFree()) return; const picked = viewer.scene.pick(click.position); const id = picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id; - const route = parts.router.resolve(id); - if (!route?.instance) return; - const entry = state.providers.get(route.providerId); - if (!entry?.on) return; - route.instance.handlePick(route.id); + if (typeof id !== 'string') return; + const sequenceId = sequenceIdFromPick(id); + if (sequenceId) parts.sequences.select(sequenceId); + else if (id.startsWith(PICK_PREFIX.image)) + parts.openImage(id.slice(PICK_PREFIX.image.length)); } /** @@ -42,9 +49,9 @@ export function createSelection({ state, parts }) { ) ) return; - if (!parts.hasSelectedSequence()) return; + if (!state.sequence.selectedId) return; event.preventDefault(); - parts.clearSequences(); + parts.sequences.clearSelection(); } let hoverQueued = false; diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index 5c9c82287..724efc713 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -58,17 +58,23 @@ function harness({ selected = false } = {}) { const state = { viewer, enabled: true, + providerOn: true, clickHandler: null, services: { picking: null, input: null }, + sequence: { + get selectedId() { + return sequences.selected ? 'seq-1' : null; + }, + }, }; const selection = createSelection({ state, parts: { - router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null }, - hasSelectedSequence: () => sequences.selected, - clearSequences() { - sequences.cleared++; - sequences.selected = false; + sequences: { + clearSelection() { + sequences.cleared++; + sequences.selected = false; + }, }, }, }); diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index 44340ddf7..1c8424447 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -7,9 +7,6 @@ import { FILTER_DEFAULT } from './policy.js'; export function freshStreet() { return { open: false, - providerId: null, - providerName: null, - providerLabel: null, imageId: null, position: null, bearing: null, @@ -25,7 +22,33 @@ export function freshStreet() { }; } -/** Mutable core state, created once per layer instance. */ +/** Coverage bookkeeping, before the first refresh. */ +export function freshCoverage() { + return { + zoom: null, + /** Current zoom's tiles by `z/x/y` key. */ + tiles: new Map(), + /** Previous zoom's tiles, kept on screen until replacements land. */ + stale: new Map(), + staleTimer: null, + /** Tile key β†’ its request's controller; any entry means LOADING. */ + pending: new Map(), + lastError: null, + /** + * Why tile requests are paused, or null: 'status' until the key status + * answers, 'no-key', 'rejected' (until the layer goes off) or + * 'rate-limited' (until `blockTimer` fires). + */ + blocked: 'status', + blockTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', + }; +} + +/** Mutable layer state, created once per layer instance. */ export function createState({ services }) { return { services, @@ -35,10 +58,10 @@ export function createState({ services }) { destroyed: false, listeners: new Set(), notify: null, - /** Shared imagery filter, in the stored (relative-days) form. */ + /** The share link's `mapillary` switch: off draws nothing. */ + providerOn: true, + /** Imagery filter, in the stored (relative-days) form. */ filter: { ...FILTER_DEFAULT }, - /** @type {Map} */ - providers: new Map(), street: { host: null, @@ -47,7 +70,23 @@ export function createState({ services }) { ...freshStreet(), }, + coverage: freshCoverage(), + sequence: { + selectedId: null, + images: [], + /** Recent sequences' thinned images, by sequence id. */ + cache: new Map(), + collection: null, + loading: false, + abort: null, + }, + marker: { collection: null, billboard: null }, clickHandler: null, }; } + +/** Coverage, cones and lookups run only while the layer and its switch are on. */ +export function isActive(state) { + return state.enabled && state.providerOn; +} diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index fe3c3ec18..926df9d9e 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -1,4 +1,4 @@ -/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */ +/** Web-Mercator tile math for Street Level coverage, without Cesium. */ const MAX_LAT = 85.05112878; @@ -77,78 +77,42 @@ export function normalizeBbox(input) { } /** - * Squared distance (in tiles) from tile (x, y)'s centre to the segment - * `from` β†’ `to` at zoom z, measured the short way round the date line. - */ -function segmentDistance2(tile, from, to, z) { - const n = 2 ** z; - const px = tile.x + 0.5; - const py = tile.y + 0.5; - const ax = tileXAt(from.lon, z); - const ay = tileYAt(from.lat, z); - let bx = to ? tileXAt(to.lon, z) : ax; - const by = to ? tileYAt(to.lat, z) : ay; - if (bx - ax > n / 2) bx -= n; - else if (ax - bx > n / 2) bx += n; - let best = Infinity; - for (const shift of [-n, 0, n]) { - const x = px + shift; - const vx = bx - ax; - const vy = by - ay; - const len2 = vx * vx + vy * vy; - const t = len2 - ? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2)) - : 0; - const dx = x - (ax + t * vx); - const dy = py - (ay + t * vy); - best = Math.min(best, dx * dx + dy * dy); - } - return best; -} - -/** - * Tiles at zoom z covering a bbox (west > east crosses the date line), from - * the centre outwards, capped at `limit`. With `focus`, tiles rank by distance - * from the segment `from` (ground under the camera) to `to` (screen centre): - * a tilted view's box centre can sit kilometres ahead of both. - * @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options] + * Tiles at zoom z covering a [west, south, east, north] box (west > east + * crosses the date line), nearest to `from` first (default: the box centre), + * capped at `limit`. Distances are measured the short way round Β±180Β°. + * @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options] * @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}} */ -export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { +export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) { + const [west, south, east, north] = Array.isArray(bbox) + ? bbox.map(Number) + : []; + const across = west > east; + // A box across the date line is split at Β±180Β°. + const halves = across + ? [ + [west, south, 180, north], + [-180, south, east, north], + ] + : [bbox]; + const tiles = halves + .map(normalizeBbox) + .filter(Boolean) + .flatMap((box) => tileGrid(box, z)); const n = 2 ** z; - const tiles = []; - let centre; - if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) { - // A box across the date line comes as west > east: split it at Β±180Β°. - const [west, south, east, north] = bbox.map(Number); - for (const half of [ - [west, south, 180, north], - [-180, south, east, north], - ]) { - const box = normalizeBbox(half); - if (box) tiles.push(...tileGrid(box, z).tiles); - } - centre = { - x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n, - y: (latToTileY(north, z) + latToTileY(south, z)) / 2, - }; - } else { - const box = normalizeBbox(bbox); - if (!box) return { tiles: [], truncated: false, total: 0 }; - const grid = tileGrid(box, z); - tiles.push(...grid.tiles); - centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 }; - } - const distance2 = focus?.from - ? (tile) => segmentDistance2(tile, focus.from, focus.to, z) - : (tile) => { - // Measured the short way round the date line. - const dx = Math.abs(tile.x - centre.x); - return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; - }; + const centre = from || { + lon: wrapLon(west + (east - west + (across ? 360 : 0)) / 2), + lat: (south + north) / 2, + }; + const cx = tileXAt(wrapLon(centre.lon), z); + const cy = tileYAt(centre.lat, z); // Each tile's distance is computed once; ties keep their row order. const ranked = tiles - .map((tile) => ({ tile, d: distance2(tile) })) + .map((tile) => { + const dx = Math.abs(tile.x + 0.5 - cx); + const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2; + return { tile, d }; + }) .sort((a, b) => a.d - b.d) .map(({ tile }) => tile); const truncated = ranked.length > limit; @@ -159,7 +123,7 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { }; } -/** The tiles at zoom z in a normalized box, row by row, and their range. */ +/** The tiles at zoom z in a normalized box, row by row. */ function tileGrid(box, z) { const x0 = lonToTileX(box.west, z); const x1 = lonToTileX(box.east, z); @@ -168,7 +132,7 @@ function tileGrid(box, z) { const tiles = []; for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) tiles.push({ x, y, z }); - return { tiles, x0, x1, y0, y1 }; + return tiles; } /** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */ @@ -190,17 +154,3 @@ export function coverageZoomForHeight(heightM) { if (heightM > 1_800) return 13; return 14; } - -/** - * Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy; - * null below, where `coverageZoomForHeight` takes over. - */ -export function overviewZoomForHeight(heightM) { - if (!Number.isFinite(heightM) || heightM <= 60_000) return null; - if (heightM > 15_000_000) return 0; - if (heightM > 7_000_000) return 1; - if (heightM > 3_000_000) return 2; - if (heightM > 1_200_000) return 3; - if (heightM > 400_000) return 4; - return 5; -} diff --git a/src/layers/streetLevel/tileMath.test.mjs b/src/layers/streetLevel/tileMath.test.mjs index b9b88d490..065b423e6 100644 --- a/src/layers/streetLevel/tileMath.test.mjs +++ b/src/layers/streetLevel/tileMath.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { coverageZoomForHeight, - overviewZoomForHeight, latToTileY, lonToTileX, normalizeBbox, @@ -74,16 +73,6 @@ test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () assert.equal(coverageZoomForHeight(Number.NaN), null); }); -test('overviewZoomForHeight covers the globe above the sequence ceiling', () => { - assert.equal(overviewZoomForHeight(30_000), null); - assert.equal(overviewZoomForHeight(100_000), 5); - assert.equal(overviewZoomForHeight(900_000), 4); - assert.equal(overviewZoomForHeight(2_000_000), 3); - assert.equal(overviewZoomForHeight(5_000_000), 2); - assert.equal(overviewZoomForHeight(10_000_000), 1); - assert.equal(overviewZoomForHeight(25_000_000), 0); -}); - test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => { assert.equal(lonToTileX(180, 1), 1); assert.equal(lonToTileX(179.99, 1), 1); @@ -108,65 +97,51 @@ test('a box across the date line takes tiles on both sides of it, not the far si assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247)); }); -test('a box across the date line ranks the tiles at the line first, without and with a focus', () => { +test('a box across the date line ranks from the line, or from the camera across it', () => { const z = 14; const n = 2 ** z; const bbox = [179.9, 0, -179.9, 0.1]; const key = (t) => `${t.x}/${t.y}`; - const keys = (list) => new Set(list.map(key)); const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)]; const plain = tilesForBbox(bbox, z); assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side'); // The box centre is the date line itself, not the middle of the map: the - // middle two rows of the columns touching it come first, then the ring - // around them, all within two columns of the line. - const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2]; - assert.deepEqual( - keys(plain.tiles.slice(0, 4)), - new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]), - ); + // columns touching it come first, then the ones beside them. + for (const tile of plain.tiles.slice(0, 4)) + assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`); for (const tile of plain.tiles.slice(4, 12)) assert.ok( [n - 2, n - 1, 0, 1].includes(tile.x), `${key(tile)} near the line`, ); - // A line of sight across the date line just north of the equator: the two - // tiles it crosses come first, then the two in the row below them. + // From a camera metres from the line: its tile, then the one across it. const row = latToTileY(0.05, z); - const from = { lon: 179.99, lat: 0.05 }; - const to = { lon: -179.99, lat: 0.05 }; - for (const focus of [ - { from, to }, - { from: to, to: from }, - ]) { - const ranked = tilesForBbox(bbox, z, { focus }).tiles; - assert.deepEqual( - keys(ranked.slice(0, 2)), - new Set([`${n - 1}/${row}`, `0/${row}`]), - ); - assert.deepEqual( - keys(ranked.slice(2, 4)), - new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]), - ); - } + const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z); + const lat = (rowSouth + rowNorth) / 2; + const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } }); + assert.deepEqual(west.tiles.slice(0, 2).map(key), [ + `${n - 1}/${row}`, + `0/${row}`, + ]); + const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } }); + assert.deepEqual(east.tiles.slice(0, 2).map(key), [ + `0/${row}`, + `${n - 1}/${row}`, + ]); }); -test('a focus ranks tiles along the line of sight, not from the box centre', () => { +test('ranking from the camera keeps the ground under it, not the box centre', () => { // A box that runs far north of the camera, as a tilted view's does: the - // box centre is kilometres from both the camera and the screen centre. + // box centre is kilometres from the camera. const from = { lon: -121.4944, lat: 38.5816 }; - const to = { lon: -121.4944, lat: 38.596 }; const bbox = [-121.6, 38.57, -121.38, 38.9]; const z = 14; const key = (t) => `${t.x}/${t.y}`; const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`; - const centre = `${lonToTileX(to.lon, z)}/${latToTileY(to.lat, z)}`; const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key); - assert.equal(plain.includes(under), false, 'the old ranking drops them'); - const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } }); - const keys = ranked.tiles.map(key); - assert.ok(keys.includes(under), 'the tile under the camera'); - assert.ok(keys.includes(centre), 'the tile at the centre of the screen'); + assert.equal(plain.includes(under), false, 'the box centre drops it'); + const ranked = tilesForBbox(bbox, z, { limit: 9, from }); + assert.equal(key(ranked.tiles[0]), under, 'the tile under the camera first'); assert.equal(ranked.total > 9, true); }); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js deleted file mode 100644 index 5d8fbd02b..000000000 --- a/src/layers/streetLevel/uiState.js +++ /dev/null @@ -1,74 +0,0 @@ -import { COLORS } from './policy.js'; - -/** - * @typedef {object} ProviderSnapshot - * @property {string} id - * @property {string} name - * @property {string} label - * @property {boolean} on - * @property {boolean|null} configured null until the status call answers - * @property {boolean} keyRequired no usable key: missing or rejected - * @property {boolean} [keyRejected] the provider refused the configured key - * @property {string|null} requiresKeyId - * @property {boolean} loading - * @property {number} count - * @property {string} hint - * @property {string|null} error - * @property {string} color The source's one colour. - */ - -/** - * Totals over switched-on providers. Key-gated only when every one lacks its - * key; `keyRejected` when that is because a key was refused. - * @param {Array} providers - */ -export function summarizeCoverage(providers) { - const active = providers.filter((p) => p.on); - return { - count: active.reduce((sum, p) => sum + (p.count || 0), 0), - loading: active.some((p) => p.loading), - hint: active.find((p) => p.hint)?.hint || '', - error: active.find((p) => p.error)?.error || null, - keyRequired: - active.length > 0 && active.every((p) => p.keyRequired === true), - keyRejected: - active.length > 0 && - active.every((p) => p.keyRequired === true) && - active.some((p) => p.keyRejected === true), - }; -} - -/** - * The panel snapshot from core state and provider snapshots. The legend has - * one swatch per active source, then the shared selection colour. - * @param {{enabled: boolean, filter: object, providers: Array, street: object, sequence: object}} input - */ -export function composeUIState({ - enabled, - filter, - providers, - street, - sequence, -}) { - const active = providers.filter((p) => p.on); - const { keyRequired, keyRejected, ...coverage } = - summarizeCoverage(providers); - const legend = active.map((provider) => ({ - key: provider.id, - label: provider.name, - color: provider.color, - })); - if (active.length) - legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); - return { - enabled, - keyRequired, - keyRejected, - filter: { ...filter }, - providers: providers.map((p) => ({ ...p })), - coverage, - legend, - sequence: { ...sequence }, - street: { ...street }, - }; -} diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs deleted file mode 100644 index ea2add40b..000000000 --- a/src/layers/streetLevel/uiState.test.mjs +++ /dev/null @@ -1,139 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { composeUIState, summarizeCoverage } from './uiState.js'; -import { COLORS } from './policy.js'; -import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs'; - -const provider = (overrides = {}) => - providerSnapshot({ count: 10, ...overrides }); -const base = { - enabled: true, - filter: { pano: 'all', sinceDays: 0 }, - street: { open: false }, - sequence: { selectedId: null, images: 0, loading: false }, -}; - -test('counts add up across active providers only', () => { - const ui = composeUIState({ - ...base, - providers: [ - provider(), - provider({ id: 'panoramax', name: 'Panoramax', count: 5, on: false }), - provider({ id: 'kartaview', name: 'KartaView', count: 7, loading: true }), - ], - }); - assert.equal(ui.coverage.count, 17); - assert.equal(ui.coverage.loading, true); -}); - -test('the layer is key-gated only when every switched-on provider lacks its key', () => { - const gated = composeUIState({ - ...base, - providers: [provider({ keyRequired: true })], - }); - assert.equal(gated.keyRequired, true); - const mixed = composeUIState({ - ...base, - providers: [ - provider({ keyRequired: true }), - provider({ id: 'panoramax', name: 'Panoramax', requiresKeyId: null }), - ], - }); - assert.equal(mixed.keyRequired, false, 'a keyless provider still draws'); - const offOnly = composeUIState({ - ...base, - providers: [provider({ keyRequired: true, on: false })], - }); - assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate'); -}); - -test('the legend is one swatch per active source, in its colour, then Selected', () => { - const single = composeUIState({ ...base, providers: [provider()] }); - assert.deepEqual(single.legend, [ - { key: 'mapillary', label: 'Mapillary', color: '#05cb63' }, - { key: 'selected', label: 'Selected', color: COLORS.selected }, - ]); - const two = composeUIState({ - ...base, - providers: [ - provider(), - provider({ id: 'panoramax', name: 'Panoramax', color: '#a66bff' }), - ], - }); - assert.deepEqual( - two.legend.map((entry) => [entry.label, entry.color]), - [ - ['Mapillary', '#05cb63'], - ['Panoramax', '#a66bff'], - ['Selected', COLORS.selected], - ], - ); - const oneOff = composeUIState({ - ...base, - providers: [ - provider(), - provider({ id: 'panoramax', name: 'Panoramax', on: false }), - ], - }); - assert.deepEqual( - oneOff.legend.map((entry) => entry.key), - ['mapillary', 'selected'], - ); - assert.deepEqual( - composeUIState({ ...base, providers: [provider({ on: false })] }).legend, - [], - ); -}); - -test('hint and error come from the first active provider that has one', () => { - const ui = composeUIState({ - ...base, - providers: [ - provider({ hint: '', error: null }), - provider({ id: 'b', name: 'B', hint: 'Look down', error: 'boom' }), - ], - }); - assert.equal(ui.coverage.hint, 'Look down'); - assert.equal(ui.coverage.error, 'boom'); - assert.deepEqual(ui.filter, base.filter); - assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter'); -}); - -test('summarizeCoverage is what getStats reports, without building a snapshot', () => { - assert.deepEqual( - summarizeCoverage([ - provider({ count: 4, hint: 'Look down' }), - provider({ id: 'b', name: 'B', count: 6, loading: true, error: 'boom' }), - provider({ id: 'c', name: 'C', count: 99, on: false, keyRequired: true }), - ]), - { - count: 10, - loading: true, - hint: 'Look down', - error: 'boom', - keyRequired: false, - keyRejected: false, - }, - ); - assert.equal(summarizeCoverage([]).keyRequired, false); -}); - -test('a rejected key gates like a missing one and is named as rejected', () => { - const rejected = provider({ - keyRequired: true, - keyRejected: true, - error: 'Mapillary rejected MAPILLARY_CLIENT_TOKEN', - }); - const summary = summarizeCoverage([rejected]); - assert.equal(summary.keyRequired, true); - assert.equal(summary.keyRejected, true); - assert.equal(summary.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN'); - const state = composeUIState({ ...base, providers: [rejected] }); - assert.equal(state.keyRequired, true); - assert.equal(state.keyRejected, true); - // A provider that is merely missing its key is not "rejected". - assert.equal( - summarizeCoverage([provider({ keyRequired: true })]).keyRejected, - false, - ); -}); diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 7685ebd08..899dc8715 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -10,16 +10,6 @@ export function metresBetween(a, b) { ); } -/** - * Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks - * (Node) it runs a frame later with a null deadline. - */ -export function whenIdle(task, timeout) { - if (typeof globalThis.requestIdleCallback === 'function') - globalThis.requestIdleCallback(task, { timeout }); - else setTimeout(() => task(null), 16); -} - /** * Ellipsoidal height (m) of the globe terrain under the camera; null where * the globe is hidden (Google 3D) or not yet loaded. @@ -63,13 +53,14 @@ function groundHit(camera, point, ellipsoid, maxRange) { /** * Visible [west, south, east, north] degrees, or null. Only screen rays that * hit count, so a horizon cannot inflate the box to the world. - * `groundHeight` raises the ellipsoid to the ground, `maxRange` (m) drops - * near-horizon hits, and `nearRange` (m) always includes the ground around - * the camera. + * `groundHeight` raises the ellipsoid to the ground (rays to the bare + * ellipsoid under Denver land kilometres too far out) and `maxRange` (m) + * drops near-horizon hits; with it, too few hits give null rather than + * Cesium's view rectangle, which can span the horizon. */ export function visibleBbox( viewer, - { grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {}, + { grid = 5, groundHeight = null, maxRange = null } = {}, ) { const scene = viewer?.scene; const camera = viewer?.camera; @@ -92,27 +83,6 @@ export function visibleBbox( } } } - // The ground around the camera always counts: a street-level view's screen - // rows skip from the horizon to the first few metres. - const nadir = camera.positionCartographic; - if (Number.isFinite(nearRange) && nearRange > 0 && nadir) { - const dLat = nearRange / 111_320; - const dLon = dLat / Math.max(0.05, Math.cos(nadir.latitude)); - const lat = Cesium.Math.toDegrees(nadir.latitude); - const lon = Cesium.Math.toDegrees(nadir.longitude); - for (const [dx, dy] of [ - [-1, -1], - [-1, 1], - [1, -1], - [1, 1], - ]) - hits.push( - Cesium.Cartographic.fromDegrees( - lon + dx * dLon, - Math.max(-85, Math.min(85, lat + dy * dLat)), - ), - ); - } if (hits.length >= 4) { let west = Infinity; let south = Infinity; @@ -145,6 +115,7 @@ export function visibleBbox( } if (west !== east && north - south > 0) return [west, south, east, north]; } + if (Number.isFinite(maxRange)) return null; const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid); if (!rectangle) return null; return [ @@ -166,50 +137,17 @@ export function viewCentre(viewer) { if (lon > 180) lon -= 360; return { lat: (bbox[1] + bbox[3]) / 2, lon }; } - const carto = viewer.camera?.positionCartographic; - if (!carto) return null; - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - }; + return cameraNadir(viewer); } -/** - * Ground the camera looks at, for ranking tiles: `nadir` under the camera and - * `ahead` at the screen centre (null on a miss or beyond `maxRange`). - */ -export function viewFocus( - viewer, - { groundHeight = null, maxRange = null } = {}, -) { - const camera = viewer?.camera; - const carto = camera?.positionCartographic; +/** The point on the ground under the camera, as {lon, lat} degrees. */ +export function cameraNadir(viewer) { + const carto = viewer?.camera?.positionCartographic; if (!carto) return null; - const nadir = { + return { lon: Cesium.Math.toDegrees(carto.longitude), lat: Cesium.Math.toDegrees(carto.latitude), }; - const canvas = viewer.scene?.canvas; - const width = canvas?.clientWidth || canvas?.width || 0; - const height = canvas?.clientHeight || canvas?.height || 0; - let ahead = null; - if (width > 0 && height > 0) { - const hit = groundHit( - camera, - new Cesium.Cartesian2(width / 2, height / 2), - raisedEllipsoid( - viewer.scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, - groundHeight, - ), - maxRange, - ); - if (hit) - ahead = { - lon: Cesium.Math.toDegrees(hit.longitude), - lat: Cesium.Math.toDegrees(hit.latitude), - }; - } - return { nadir, ahead }; } /** diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index 5a78ead69..5d1e8c530 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -2,11 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { + cameraNadir, createHorizonCull, groundUnderCamera, metresBetween, viewCentre, - viewFocus, visibleBbox, } from './view.js'; import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; @@ -113,20 +113,22 @@ test('a tilted street view over high ground boxes the streets it looks at, not t // the box is 20 km wide and starts past the street at the screen centre. assert.ok(unranged[2] - unranged[0] > 0.2); assert.ok(unranged[1] > ahead, 'the old box missed the street in view'); - const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 }; + const options = { groundHeight: 1610, maxRange: 6000 }; const [west, south, east, north] = visibleBbox(view, options); assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box'); - assert.ok(south < 39.74 && north > ahead, 'camera and screen centre inside'); - const focus = viewFocus(view, options); - assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9); + assert.ok(south < ahead && north > ahead, 'the screen centre is inside'); assert.ok( - Math.abs(focus.ahead.lat - ahead) < 0.002, - 'centre ray on the street', + south > 39.74, + 'it starts ahead of the camera, where the view does', + ); + assert.ok(west < -104.99 && east > -104.99); + const nadir = cameraNadir(view); + assert.ok( + Math.abs(nadir.lat - 39.74) < 1e-9 && Math.abs(nadir.lon + 104.99) < 1e-9, ); - assert.ok(Math.abs(focus.ahead.lon - -104.99) < 1e-6); }); -test('looking at the horizon from eye height still boxes the ground around the camera', () => { +test('looking at the horizon from eye height boxes the near ground, not the horizon', () => { const view = pinhole({ lon: -121.4944, lat: 38.5816, @@ -137,10 +139,19 @@ test('looking at the horizon from eye height still boxes the ground around the c const [west, south, east, north] = visibleBbox(view, { groundHeight: 10, maxRange: 2500, - nearRange: 1000, }); - assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008); - assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008); + assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead'); + assert.ok(north - 38.5816 < 2500 / 111_000, 'no further than the range'); + assert.ok(west < -121.4944 && east > -121.4944); + // Looking at the sky, nothing is in range: no box, never the horizon's. + const sky = pinhole({ + lon: -121.4944, + lat: 38.5816, + ground: 10, + agl: 2, + pitch: 60, + }); + assert.equal(visibleBbox(sky, { groundHeight: 10, maxRange: 2500 }), null); }); test('metresBetween measures the short way round the date line', () => { diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 56a83d04f..c22ef999c 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -1,31 +1,25 @@ import { freshStreet } from './state.js'; /** - * Owns the panel's viewer element and the provider adapter mounted in it. - * Adapter poses update `state.street` and the marker. + * Owns the panel's viewer element and the photo viewer mounted in it (the + * MapillaryJS adapter, or a stand-in). Its poses update `state.street` and + * the marker; MapillaryJS loads on the first open. */ -export function createViewerHost({ state, parts }) { - /** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */ - let active = null; - /** @type {{id: string, promise: Promise}|null} */ +export function createViewerHost({ state, parts, adapter }) { + /** Set once mounted, until `unmount`. */ + let unsubscribe = null; + /** The mount in flight, shared by concurrent opens. */ let mounting = null; let openSeq = 0; - /** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */ - const warmed = new Map(); function notify() { state.notify?.(); } - function providerEntry(providerId) { - return state.providers.get(providerId) || null; - } - function applyPose(pose) { - if (!active || !state.street.open || pose.providerId !== active.id) return; + if (!unsubscribe || !state.street.open) return; const previousSequence = state.street.sequenceId; Object.assign(state.street, { - providerId: pose.providerId, imageId: pose.imageId, position: pose.position ? { ...pose.position } : null, bearing: Number.isFinite(pose.bearing) ? pose.bearing : null, @@ -46,63 +40,43 @@ export function createViewerHost({ state, parts }) { } function selectCurrentSequence() { - const { sequenceId, providerId } = state.street; - const entry = providerEntry(providerId); - if (!sequenceId || !entry?.instance.selectSequence || !state.enabled) - return; - const current = entry.instance.sequenceStats?.()?.selectedId; - if (current !== sequenceId) entry.instance.selectSequence(sequenceId); + const { sequenceId } = state.street; + if (!sequenceId || !state.enabled) return; + if (state.sequence.selectedId !== sequenceId) + parts.sequences.select(sequenceId); } /** - * Mount a provider's adapter. It becomes `active` only on success, so a - * failed mount is retried on the next open; concurrent opens share one. + * Mount the viewer. It counts as mounted only on success, so a failed + * mount is retried on the next open; concurrent opens share one. */ - function mount(entry) { - if (active?.id === entry.def.id) return Promise.resolve(active.adapter); - if (mounting?.id === entry.def.id) return mounting.promise; - if (active) { - active.adapter.close(); - active.unsubscribe(); - active.adapter.unmount(); - active = null; - } - const adapter = entry.instance.viewer; + function mount() { + if (unsubscribe) return Promise.resolve(); + if (mounting) return mounting; const promise = (async () => { await adapter.mount(state.street.host); - if (mounting?.promise !== promise) { - // Unmounted (layer off, provider switched) while loading. A newer - // mount of this provider shares the adapter and its viewer: tearing - // it down here would leave that mount active with no viewer. - const reused = - active?.id === entry.def.id || mounting?.id === entry.def.id; - if (!reused) adapter.unmount(); + if (mounting !== promise) { + // Unmounted (layer off) while loading. A newer mount shares the + // viewer: tearing it down here would leave that mount without one. + if (!mounting && !unsubscribe) adapter.unmount(); throw new Error('Street-level viewer was closed'); } // Listen only once current: an outdated mount releasing the same // `applyPose` would otherwise unhook the mount that replaced it. - const unsubscribe = adapter.onPose(applyPose); - active = { id: entry.def.id, adapter, unsubscribe }; - warmed.delete(entry.def.id); + unsubscribe = adapter.onPose(applyPose); adapter.setRenderMode?.(state.street.renderMode); - return adapter; })(); - mounting = { id: entry.def.id, promise }; - promise.then( - () => { - if (mounting?.promise === promise) mounting = null; - }, - () => { - if (mounting?.promise === promise) mounting = null; - }, - ); + mounting = promise; + const settle = () => { + if (mounting === promise) mounting = null; + }; + promise.then(settle, settle); return promise; } /** Open an image: true once its first pose is in, false if it failed or was overtaken. */ - async function open(providerId, imageId) { - const entry = providerEntry(providerId); - if (!entry || !imageId) return false; + 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; @@ -114,17 +88,10 @@ export function createViewerHost({ state, parts }) { const current = () => seq === openSeq; // Claimed now, honoured after loading only if nothing newer took the camera. const ticket = parts.framing.begin(); - Object.assign(state.street, { - loading: true, - error: null, - open: true, - providerId, - providerName: entry.def.name, - providerLabel: entry.def.label, - }); + Object.assign(state.street, { loading: true, error: null, open: true }); notify(); try { - const adapter = await mount(entry); + await mount(); if (!current()) return false; await adapter.open(String(imageId)); if (!current()) return false; @@ -141,11 +108,11 @@ export function createViewerHost({ state, parts }) { return true; } - /** Close the image and stop any framing flight; the adapter stays warm. */ + /** Close the image and stop any framing flight; the viewer stays mounted. */ function close() { openSeq++; parts.framing.cancel(); - active?.adapter.close(); + if (unsubscribe) adapter.close(); Object.assign(state.street, freshStreet()); parts.marker.clear(); notify(); @@ -158,7 +125,7 @@ export function createViewerHost({ state, parts }) { function resize() { try { - active?.adapter.resize(); + if (unsubscribe) adapter.resize(); } catch { /* no-op */ } @@ -167,61 +134,21 @@ export function createViewerHost({ state, parts }) { function setRenderMode(mode) { state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; try { - active?.adapter.setRenderMode?.(state.street.renderMode); + if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode); } catch { /* adapter not ready */ } notify(); } - /** Stand active providers' viewers up ahead of the first image. */ - async function prewarm(entries) { - const host = state.street.host; - if (!host || !state.enabled) return; - for (const entry of entries) { - const adapter = entry.instance.viewer; - if (!adapter.prewarm || !entry.on) continue; - try { - await adapter.prewarm(host); - } catch { - /* the real open reports errors */ - } - if (active?.id === entry.def.id) continue; - // Switched off while it loaded: release it now rather than keep it. - if (!state.enabled || !entry.on) adapter.unmount(); - else warmed.set(entry.def.id, adapter); - } + /** Close the image and destroy the viewer (and its WebGL context). */ + function unmount() { + close(); + mounting = null; + unsubscribe?.(); + unsubscribe = null; + adapter.unmount(); } - /** Tear down every viewer, or only `providerId`'s (active or prewarmed). */ - function unmount(providerId) { - if ( - !providerId || - active?.id === providerId || - mounting?.id === providerId - ) { - close(); - mounting = null; - if (active) { - active.unsubscribe(); - active.adapter.unmount(); - active = null; - } - } - for (const [id, adapter] of warmed) { - if (providerId && id !== providerId) continue; - warmed.delete(id); - adapter.unmount(); - } - } - - return { - attach, - open, - close, - resize, - setRenderMode, - prewarm, - unmount, - }; + return { attach, open, close, resize, setRenderMode, unmount }; } diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index a2d7ccf2b..624e361ee 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -33,27 +33,16 @@ const { createMapillaryViewer } = /** * A viewer host over `adapter`. `marker` records where the position marker * was put (null once cleared), `framing` counts framing calls and - * `selected` every sequence the provider was asked to select. + * `selected` every sequence the layer was asked to select. */ function harness(adapter) { const state = { enabled: true, notify() {}, - providers: new Map(), street: { host: {}, renderMode: 'letterbox' }, + sequence: { selectedId: null }, }; - const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' }; const selected = []; - let selectedId = null; - const instance = { - viewer: adapter, - selectSequence(id) { - selected.push(id); - selectedId = id; - }, - sequenceStats: () => ({ selectedId }), - }; - state.providers.set('mapillary', { def, instance }); const framing = { begun: 0, started: 0, cancelled: 0 }; const marker = []; const parts = { @@ -67,17 +56,23 @@ function harness(adapter) { frame: (ticket) => ticket && framing.started++, cancel: () => framing.cancelled++, }, + sequences: { + select(id) { + selected.push(id); + state.sequence.selectedId = id; + }, + }, }; return { state, framing, marker, selected, - /** Esc on the map: the provider drops its selected sequence. */ + /** Esc on the map: the selected sequence is dropped. */ clearSequence() { - selectedId = null; + state.sequence.selectedId = null; }, - host: createViewerHost({ state, parts }), + host: createViewerHost({ state, parts, adapter }), }; } @@ -102,7 +97,6 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) { /** A pose event from the library, as MapillaryJS fires them. */ emitPose(id, overrides = {}) { emit?.({ - providerId: 'mapillary', imageId: id, sequenceId: `seq-${id}`, position: { lon: 1, lat: 2 }, @@ -139,10 +133,10 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) { test('a failed mount is retried on the next open instead of being cached', async () => { const adapter = fakeAdapter({ failMounts: 1 }); const { state, host } = harness(adapter); - await host.open('mapillary', 'a'); + await host.open('a'); assert.equal(state.street.error, 'library failed to load'); assert.equal(adapter.calls.listeners, 0, 'the pose listener was released'); - await host.open('mapillary', 'b'); + await host.open('b'); assert.equal(state.street.error, null); assert.equal(adapter.calls.mount, 2); assert.deepEqual(adapter.calls.open, ['b']); @@ -153,7 +147,7 @@ test('a failed mount is retried on the next open instead of being cached', async test('concurrent opens share one mount and one pose listener', async () => { const adapter = fakeAdapter(); const { host } = harness(adapter); - await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]); + await Promise.all([host.open('a'), host.open('b')]); assert.equal(adapter.calls.mount, 1); assert.equal(adapter.calls.listeners, 1); }); @@ -161,62 +155,22 @@ test('concurrent opens share one mount and one pose listener', async () => { test('unmounting while a mount is in flight does not leave it active', async () => { const adapter = fakeAdapter(); const { state, host } = harness(adapter); - const opening = host.open('mapillary', 'a'); + const opening = host.open('a'); host.unmount(); await opening; assert.equal(adapter.calls.listeners, 0); - assert.equal(adapter.calls.unmount, 1); + assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount'); assert.equal(state.street.open, false); // Not left active: the next open mounts again. - await host.open('mapillary', 'b'); + await host.open('b'); assert.equal(adapter.calls.mount, 2); assert.equal(state.street.imageId, 'b'); }); -test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => { - const adapter = fakeAdapter(); - let release = null; - adapter.prewarm = () => - new Promise((resolve) => { - release = resolve; - }); - const { state, host } = harness(adapter); - const entry = state.providers.get('mapillary'); - entry.on = true; - - // Warmed, then the layer is switched off: unmount releases the warm viewer. - const warming = host.prewarm([entry]); - release(); - await warming; - assert.equal(adapter.calls.unmount, 0); - host.unmount(); - assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed'); - - // Switched off while the library was still loading: released on arrival. - const late = host.prewarm([entry]); - state.enabled = false; - release(); - await late; - assert.equal(adapter.calls.unmount, 2); -}); - -test('switching one provider off releases only its viewer', async () => { - const adapter = fakeAdapter(); - adapter.prewarm = async () => {}; - const { state, host } = harness(adapter); - const entry = state.providers.get('mapillary'); - entry.on = true; - await host.prewarm([entry]); - host.unmount('panoramax'); - assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone'); - host.unmount('mapillary'); - assert.equal(adapter.calls.unmount, 1); -}); - test('a pose that arrives after the photo was closed does not bring it back', async () => { const adapter = fakeAdapter(); const { state, host } = harness(adapter); - await host.open('mapillary', 'a'); + await host.open('a'); host.close(); const before = { ...state.street }; // The library's `image` event for a photo that was still loading. @@ -239,16 +193,16 @@ test('switching the layer off and on during a cold first open keeps the shared v const gate = deferred(); const adapter = fakeAdapter({ gate }); const { state, host } = harness(adapter); - const first = host.open('mapillary', 'a'); // the library is still loading + const first = host.open('a'); // the library is still loading host.unmount(); // layer off … - const second = host.open('mapillary', 'b'); // … on again, and a new click + const second = host.open('b'); // … on again, and a new click gate.resolve(); await Promise.all([first, second]); - assert.equal(adapter.calls.unmount, 0, 'the outdated mount left it alone'); + assert.equal(adapter.calls.unmount, 1, 'only the layer going off'); assert.equal(adapter.calls.listeners, 1); assert.equal(state.street.error, null); assert.equal(state.street.imageId, 'b'); - await host.open('mapillary', 'c'); + await host.open('c'); assert.equal(state.street.error, null, 'later opens are not stuck'); assert.equal(state.street.imageId, 'c'); }); @@ -258,7 +212,7 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); test('every pose the viewer reports moves the marker; only the open frames the camera', async () => { const adapter = fakeAdapter(); const { host, marker, framing } = harness(adapter); - await host.open('mapillary', 'a'); + await host.open('a'); assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]); // The user walks on: the marker goes along, the globe camera stays. adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 }); @@ -276,7 +230,7 @@ test('a pose on another sequence selects it, but not while the image still loads const openGate = deferred(); const adapter = fakeAdapter({ openGate }); const { state, host, selected, clearSequence } = harness(adapter); - const opening = host.open('mapillary', 'a'); + const opening = host.open('a'); await settle(); assert.equal(state.street.loading, true, 'the image is downloading'); assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in'); @@ -299,7 +253,7 @@ test('a pose on another sequence selects it, but not while the image still loads test('closing the photo clears the marker', async () => { const adapter = fakeAdapter(); const { host, marker } = harness(adapter); - await host.open('mapillary', 'a'); + await host.open('a'); assert.notEqual(marker.at(-1), null); host.close(); assert.equal(marker.at(-1), null); @@ -309,7 +263,7 @@ test('an open still mounting when the photo is closed does nothing when the moun const gate = deferred(); const adapter = fakeAdapter({ gate }); const { state, host, framing, selected } = harness(adapter); - const opening = host.open('mapillary', 'a'); // MapillaryJS still loading + const opening = host.open('a'); // MapillaryJS still loading await settle(); host.close(); gate.resolve(); @@ -322,7 +276,7 @@ test('an open still mounting when the photo is closed does nothing when the moun [false, false, null], ); // The mounted viewer serves the next open. - assert.equal(await host.open('mapillary', 'b'), true); + assert.equal(await host.open('b'), true); assert.equal(state.street.imageId, 'b'); }); @@ -330,7 +284,7 @@ test('an image still opening when the photo is closed is neither framed nor sele const openGate = deferred(); const adapter = fakeAdapter({ openGate }); const { state, host, framing, selected } = harness(adapter); - const opening = host.open('mapillary', 'a'); + const opening = host.open('a'); await settle(); assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading'); host.close(); @@ -408,16 +362,16 @@ test('the Mapillary viewer survives the layer going off and on during its first const { gate, viewers } = fakeLibrary(); const adapter = createMapillaryViewer({ source: { token: 't' } }); const { state, host } = harness(adapter); - const first = host.open('mapillary', 'img1'); + const first = host.open('img1'); await Promise.resolve(); host.unmount(); - const second = host.open('mapillary', 'img2'); + const second = host.open('img2'); await Promise.resolve(); gate.resolve(); await Promise.all([first, second]); assert.equal(state.street.error, null); assert.equal(state.street.imageId, 'img2'); - await host.open('mapillary', 'img3'); + await host.open('img3'); assert.equal(state.street.error, null, 'later opens are not stuck'); assert.equal(state.street.imageId, 'img3'); assert.equal(viewers.live, 1); @@ -439,20 +393,6 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b assert.equal(viewers.live, 0, 'nothing left holding a WebGL context'); }); -test('a prewarm that gave up when the layer went off and on mid-download is followed by one that builds', async () => { - const { gate, viewers } = fakeLibrary(); - const adapter = createMapillaryViewer({ source: { token: 't' } }); - const element = {}; - const first = adapter.prewarm(element); - adapter.unmount(); // layer off while MapillaryJS downloads - const second = adapter.prewarm(element); // layer on again - gate.resolve(); - await Promise.all([first, second]); - assert.equal(viewers.created, 1, 'the second prewarm built the viewer'); - assert.equal(viewers.live, 1); - adapter.unmount(); -}); - test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => { const { gate, viewers } = fakeLibrary(); gate.resolve(); @@ -467,7 +407,7 @@ test('closing the photo stops sequence playback in the hidden viewer', async () gate.resolve(); const adapter = createMapillaryViewer({ source: { token: 't' } }); const { host } = harness(adapter); - await host.open('mapillary', 'img1'); + await host.open('img1'); const [viewer] = viewers.instances; assert.equal(viewer.playback.stops, 0); host.close(); @@ -478,13 +418,11 @@ test('closing the photo stops sequence playback in the hidden viewer', async () test('closing the photo or switching the layer off stops its framing flight', async () => { const adapter = fakeAdapter(); const { framing, host } = harness(adapter); - await host.open('mapillary', 'a'); + await host.open('a'); assert.equal(framing.started, 1, 'the open framed the photo'); host.close(); assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it'); - await host.open('mapillary', 'b'); - host.unmount('panoramax'); - assert.equal(framing.cancelled, 1, 'another provider leaves it alone'); + await host.open('b'); host.unmount(); assert.equal(framing.cancelled, 2, 'layer off: likewise'); }); @@ -557,7 +495,7 @@ test('a photo asks for the camera when it starts opening and frames with that ti const openGate = deferred(); const adapter = fakeAdapter({ openGate }); const { host, framing } = harness(adapter); - const opening = host.open('mapillary', 'a'); + const opening = host.open('a'); assert.equal(framing.begun, 1, 'claimed before the download'); await settle(); assert.equal(framing.started, 0); diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index b3d6c3a13..6ae8d95ed 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -1,83 +1,49 @@ -/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */ +/** Shared Street Level test stand-ins: Mapillary source, photo viewer, ray-casting camera. */ import * as Cesium from 'cesium'; -import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; const RAD = Math.PI / 180; -/** One provider as `getUIState().providers` lists it. */ -export function providerSnapshot(overrides = {}) { +/** + * A Mapillary source for the layer: a configured key, empty tiles, and the + * given lookups; `calls` records tile requests. + */ +export function fakeMapillarySource(overrides = {}) { + const calls = { tiles: [] }; return { - id: 'mapillary', - name: 'Mapillary', - label: 'MAPILLARY', - on: true, - configured: true, - keyRequired: false, - requiresKeyId: 'mapillary', - loading: false, - count: 0, - hint: '', - error: null, - color: '#05cb63', + calls, + hasToken: () => true, + getStatus: async () => ({ configured: true }), + getTile: async (...args) => { + calls.tiles.push(args); + return new Uint8Array(0); + }, + getSequenceImages: async () => [], + nearestImages: async () => [], ...overrides, }; } /** - * The smallest provider the core accepts. Mutate `stats` to change what - * coverageStats() answers; `calls`, `filters` and `context()` record use. + * A photo viewer in place of MapillaryJS: `calls` records use, and `open` + * reports a pose for the image (when `pose` is given) as the real one does. */ -export function fakeStreetLevelProvider({ - id = 'mapillary', - pickPrefix = 'mly:', - html = MAPILLARY_CREDIT_HTML, - nearestImage = async () => null, -} = {}) { - const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 }; - const filters = []; - const stats = { - count: 0, - zoom: null, - kind: null, - loading: false, - hint: '', - error: null, - keyRequired: false, - }; - let context = null; +export function fakePhotoViewer({ pose = null } = {}) { + const calls = { mount: 0, open: [], unmount: 0 }; + let emit = null; return { calls, - filters, - stats, - context: () => context, - id, - name: 'Mapillary', - label: 'MAPILLARY', - requiresKeyId: null, - pickPrefix, - colors: { coverage: '#05cb63' }, - credit: { html }, - create: (providerContext) => { - context = providerContext; - return { - status: async () => ({ configured: true }), - init() {}, - activate: () => calls.activate++, - deactivate: () => calls.deactivate++, - destroy() {}, - refreshCoverage() {}, - setFilter: (filter) => filters.push(filter), - coverageStats: () => ({ ...stats }), - handlePick: () => false, - nearestImage, - viewer: { - mount: async () => calls.mount++, - open: async (imageId) => calls.open.push(imageId), - close() {}, - unmount: () => calls.unmount++, - resize() {}, - onPose: () => () => {}, - }, + mount: async () => calls.mount++, + async open(imageId) { + calls.open.push(imageId); + if (pose) emit?.({ imageId, ...pose }); + }, + close() {}, + unmount: () => calls.unmount++, + resize() {}, + onPose(listener) { + emit = listener; + return () => { + emit = null; }; }, }; diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs index 8f54630a4..d6e5a0e9e 100644 --- a/src/tooling/mapillaryProvider.test.mjs +++ b/src/tooling/mapillaryProvider.test.mjs @@ -294,8 +294,8 @@ for (const [why, retryAfter] of [ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => { assert.equal( - normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key, - 'coverage/3/1/2', + normalizeTileAddress({ layer: 'coverage', z: 11, x: 1, y: 2 }).key, + 'coverage/11/1/2', ); assert.deepEqual( normalizeTileAddress({ layer: 'coverage', z: '14', x: '5', y: '6' }) @@ -307,8 +307,8 @@ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', ( { layer: 'points', z: 14, x: 1, y: 1 }, { layer: 'signs', z: 14, x: 1, y: 1 }, { layer: 'coverage', z: 15, x: 1, y: 1 }, - { layer: 'coverage', z: 2, x: 4, y: 0 }, - { layer: 'coverage', z: 2, x: 1.5, y: 0 }, + { layer: 'coverage', z: 11, x: 2048, y: 0 }, + { layer: 'coverage', z: 11, x: 1.5, y: 0 }, { layer: 'coverage', z: -1, x: 0, y: 0 }, ]) assert.throws( @@ -327,14 +327,14 @@ test('normalizeTileAddress refuses prototype keys as layer names', () => { ); }); -test('coverage zooms the app never requests (z6–10) are refused with a 400', async () => { - for (const z of [6, 7, 8, 9, 10]) +test('coverage zooms the app never requests (below z11) are refused with a 400', async () => { + for (const z of [0, 3, 5, 6, 7, 8, 9, 10]) assert.throws( () => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }), TileRequestError, `z${z}`, ); - for (const z of [0, 5, 11, 14]) + for (const z of [11, 12, 13, 14]) assert.equal( normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }).z, z, @@ -345,7 +345,7 @@ test('coverage zooms the app never requests (z6–10) are refused with a 400', a const res = await call('/api/mapillary/tiles', '/coverage/9/5/5'); assert.equal(res.statusCode, 400); assert.deepEqual(json(res), { - error: 'Tile zoom for coverage must be 0–5 or 11–14', + error: 'Tile zoom for coverage must be 11–14', }); assert.equal(calls.length, 0, 'Mapillary is not asked'); }, diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 942839040..421673a1f 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import { createApplicationStreetLevel } from '../app/layers/streetLevel.js'; import { fixtureTile, - EXPECTED_PROVIDERS, + LEGEND_SWATCHES, isCollapsed, PARK, STRICT_ALLOWED_SKIPS, @@ -25,13 +25,13 @@ import { redact } from '../../scripts/qa-browserEvidence.mjs'; globalThis.requestAnimationFrame ??= (callback) => setTimeout(() => callback(Date.now()), 0); -test('the harness expects exactly the providers the app registers, in order', () => { - // Any method a provider asks its source for is a no-op: only ids matter. +test('the harness expects the legend the app layer draws', () => { + // Any method the layer asks its source for is a no-op: only the legend matters. const source = new Proxy({}, { get: () => () => {} }); const layer = createApplicationStreetLevel({ sources: { mapillary: source }, }); - assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]); + assert.equal(layer.getUIState().legend.length, LEGEND_SWATCHES); }); test('a panel reads as collapsed only with the collapsed class', () => { @@ -68,13 +68,8 @@ test('fixture tiles give the hermetic gate something real to filter', async () = 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'); - const orbit = decodeCoverageTile(fixtureTile(3, 1, 3, now), { - x: 1, - y: 3, - z: 3, - }); - assert.ok(orbit.overview.length > 0, 'overview points from orbit'); - assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between'); + assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit'); + assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between'); }); /* ── The real status route probe ─────────────────────────────────────── */ diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 1c0ec6c99..df302ce6c 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -180,15 +180,10 @@ export class StreetLevelControls { async _toggleEnabled() { const enabled = this.actions.isEnabled?.() === true; - // The pill is the only switch: switching on also brings back a provider - // a share link or a tool turned off, or the layer would draw nothing. - const dark = enabled - ? [] - : (this._state?.providers || []).filter((entry) => entry.on === false); - if (dark.length) - this._setParams( - Object.fromEntries(dark.map((entry) => [entry.id, true])), - ); + // The pill is the only switch: switching on also brings back Mapillary + // if a share link or a tool turned it off, or the layer would draw nothing. + if (!enabled && this._state?.providerOn === false) + this._setParams({ mapillary: true }); try { await this.actions.setEnabled?.(!enabled); } catch (error) { diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index e81129a65..44239e018 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -9,7 +9,7 @@ import { REGISTERED_LAYER_IDS, encodeLayerStateParams, } from '../data/layerState.js'; -import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs'; +import { fakeMapillarySource } from '../testSupport/streetLevelFakes.mjs'; /* ── A small DOM: just what the panel controls touch ───────────────────── */ @@ -215,12 +215,7 @@ async function withDom(fn, options) { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); -/* ── A Street Level layer with one stand-in provider ───────────────────── */ - -function standInProvider() { - const def = fakeStreetLevelProvider({ html: 'Mapillary' }); - return { filters: def.filters, def }; -} +/* ── A Street Level layer over a stand-in Mapillary source ─────────────── */ /** Every other registered layer, as a module with no options. */ function plainLayer(id) { @@ -254,8 +249,7 @@ const memoryStorage = () => { /** The production path: controls β†’ dataManager β†’ layer β†’ durable layer state. */ async function productionPanel(dom) { - const provider = standInProvider(); - const layer = createStreetLevelLayer({ providers: [provider.def] }); + const layer = createStreetLevelLayer({ source: fakeMapillarySource() }); const manager = new DataLayerManager({}); for (const id of REGISTERED_LAYER_IDS) manager.register(id === 'street-level' ? layer : plainLayer(id)); @@ -288,7 +282,7 @@ async function productionPanel(dom) { encodeLayerStateParams(params, coordinator.getDurableState()); return (params.get('lo') || '').split('_'); }; - return { provider, layer, manager, coordinator, controls, share }; + return { layer, manager, coordinator, controls, share }; } const TOKEN = LAYER_STATE_REGISTRY.find( @@ -297,11 +291,14 @@ const TOKEN = LAYER_STATE_REGISTRY.find( test('a 360Β° click reaches the share link through the data manager', () => withDom(async (dom) => { - const { provider, coordinator, controls, share } = - await productionPanel(dom); + const { layer, coordinator, controls, share } = await productionPanel(dom); dom.root.querySelectorAll('[data-sl-pano]')[1].click(); // 360Β° await settle(); - assert.equal(provider.filters.at(-1).pano, 'pano', 'the layer applied it'); + assert.equal( + layer.getUIState().filter.pano, + 'pano', + 'the layer applied it', + ); assert.equal( coordinator.getDurableState().options['street-level'].pano, 'pano', @@ -362,35 +359,20 @@ function uiState({ } = {}) { return { enabled, + providerOn: on, keyRequired, + keyRejected: false, filter: { pano, sinceDays: 0 }, - providers: [ - { - id: 'mapillary', - name: 'Mapillary', - label: 'MAPILLARY', - color: '#05cb63', - on, - configured: !keyRequired, - keyRequired, - requiresKeyId: 'mapillary', - loading: false, - count: 3, - hint: '', - error: null, - }, - ], coverage: { count: 3, loading: false, hint: '', error: null }, legend: legend || [ { key: 'mapillary', label: 'Mapillary', color: '#05cb63' }, { key: 'selected', label: 'Selected', color: '#00d4ff' }, ], - sequence: { providerId: null, selectedId: null, images: 0, loading: false }, + sequence: { selectedId: null, images: 0, loading: false }, street: { open, loading: false, renderMode, - providerId: open ? 'mapillary' : null, imageId: open ? 'img-1' : null, error: null, }, @@ -422,18 +404,14 @@ function stubPanel(dom, state, extraActions = {}) { return { layer, calls, controls }; } -test('the header pill switches the layer, bringing back a provider switched off elsewhere', () => +test('the header pill switches the layer, bringing back Mapillary switched off elsewhere', () => withDom(async (dom) => { const status = dom.root.querySelector('#sl-status'); const on = stubPanel(dom, uiState()); status.click(); await settle(); assert.deepEqual(on.calls.setEnabled, [false]); - assert.deepEqual( - on.calls.setParams, - [], - 'switching off keeps the provider', - ); + assert.deepEqual(on.calls.setParams, [], 'switching off keeps the switch'); on.controls.destroy(); const dark = stubPanel(dom, uiState({ enabled: false, on: false })); status.click(); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 41bce731d..155ec2ce1 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -67,13 +67,10 @@ function presentStatus(state) { : { text: 'OFF', tone: '', pressed, title }; } -/** How to add the missing key, for the provider that needs one. */ -function keyHint(state) { - const provider = (state.providers || []).find( - (entry) => entry.keyRequired === true && entry.requiresKeyId, - ); - const requirement = provider && keySetupRequirement(provider.requiresKeyId); - return requirement ? `${provider.name}: ${requirement}` : null; +/** How to add the missing Mapillary key. */ +function keyHint() { + const requirement = keySetupRequirement('mapillary'); + return requirement ? `Mapillary: ${requirement}` : null; } function presentViewer(state) { @@ -90,7 +87,7 @@ function presentViewer(state) { captionLeft: street.creator ? `Image by ${street.creator}` : '', captionRight: right.join(' Β· '), link: street.externalUrl || null, - linkLabel: street.providerLabel ? `${street.providerLabel} β†—` : '', + linkLabel: street.externalUrl ? 'MAPILLARY β†—' : '', }; } @@ -115,7 +112,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { status: presentStatus(state), controlsDisabled: state.keyRequired === true, error: keyMissing - ? keyHint(state) + ? keyHint() : state.street.error || state.coverage.error || null, filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 }, since: presentSince(filter.sinceDays, now), diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index 85b6fae40..ed1dabba4 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -5,29 +5,23 @@ import { SINCE_STOPS, sinceStopIndex, } from './streetLevelPresentation.js'; -import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; - -const provider = providerSnapshot; - function snapshot(overrides = {}) { const base = { enabled: false, + providerOn: true, keyRequired: false, + keyRejected: false, filter: { pano: 'all', sinceDays: 0 }, - providers: [provider()], coverage: { loading: false, count: 0, hint: '', error: null }, legend: [ { key: 'mapillary', label: 'Mapillary', color: '#05cb63' }, { key: 'selected', label: 'Selected', color: '#00d4ff' }, ], - sequence: { providerId: null, selectedId: null, images: 0, loading: false }, + sequence: { selectedId: null, images: 0, loading: false }, street: { open: false, loading: false, error: null, - providerId: null, - providerName: null, - providerLabel: null, imageId: null, position: null, bearing: null, @@ -70,7 +64,6 @@ test('KEY REQUIRED says how to add the key instead of a raw error code', () => { enabled: true, keyRequired: true, coverage: { error: 'no_key' }, - providers: [provider({ keyRequired: true, error: 'no_key' })], }), ); assert.equal(view.status.text, 'KEY REQUIRED'); @@ -86,7 +79,6 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => { keyRequired: true, keyRejected: true, coverage: { error }, - providers: [provider({ keyRequired: true, keyRejected: true, error })], }), ); assert.equal(view.status.text, 'KEY REJECTED'); @@ -96,7 +88,7 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => { }); test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => { - // A keyless provider can still report loading (its status check, a retry): + // A keyless layer can still report loading (its status check, a retry): // the pill must say what is wrong, not that something is on its way. const loading = { enabled: true, coverage: { loading: true } }; assert.equal( @@ -221,15 +213,12 @@ test('the meta line never mixes the visible-sequence count with the selected seq assert.equal(hinted.meta, 'Point the camera at the globe'); }); -test('viewer caption reads "Image by" left, date right, and links to the provider', () => { +test('viewer caption reads "Image by" left, date right, and links to Mapillary', () => { const view = presentStreetLevelPanel( snapshot({ enabled: true, street: { open: true, - providerId: 'mapillary', - providerName: 'Mapillary', - providerLabel: 'MAPILLARY', imageId: '1814275685699406', creator: 'mapfool', capturedAt: Date.UTC(2023, 9, 8),