diff --git a/package.json b/package.json index a7a857df9..a342e10bf 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,7 @@ "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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9d9b5f8a9..773541a41 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -848,6 +848,18 @@ "src/layers/streetLevel/providers/mapillary/source.test.mjs", "src/streetLevelMarkup.test.mjs", "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/cameraFollow.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", @@ -865,5 +877,12 @@ "server/providers/mapillary/trim.js", "src/layers/streetLevel/tileMath.test.mjs", "scripts/qa-street-level.mjs", - "src/tooling/streetLevelQa.test.mjs" + "src/tooling/streetLevelQa.test.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/providers/mapillary/provider.test.mjs", + "src/layers/streetLevel/providers/mapillary/decode.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e5853cd1e..3bc32f94a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1117,10 +1117,23 @@ "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/cameraFollow.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", "src/layers/streetLevel/providers/mapillary/policy.js", "src/layers/streetLevel/providers/mapillary/coverage.js", @@ -1998,6 +2011,18 @@ "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/cameraFollow.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", "src/layers/streetLevel/providers/mapillary/policy.js", "src/layers/streetLevel/providers/mapillary/coverage.js", @@ -2471,6 +2496,18 @@ "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/cameraFollow.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", "src/layers/streetLevel/providers/mapillary/policy.js", "src/layers/streetLevel/providers/mapillary/coverage.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 490870c0b..1733888f6 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** * Browser QA for the Street Level layer against a running dev - * server: the panel's place in the right rail, the keyless gate, coverage, - * the embedded viewer and its expanded dialog. Run with `npm run qa:street-level -- --url http://localhost:4173`. + * server: the panel's place in the right rail, the provider chips, the + * keyless gate, coverage and its credit, switching a provider off and on, + * the imagery filter, the embedded viewer and its expanded dialog. Run with `npm run qa:street-level -- --url http://localhost:4173`. * Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run. */ import assert from 'node:assert/strict'; @@ -14,6 +15,9 @@ export const VIEWPORTS = Object.freeze([ { width: 1280, height: 800 }, ]); +/** Provider chips the panel must show, in order (one per registered provider). */ +export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); + /** Expected right-rail order once the layout controller has run. */ export const RAIL_ORDER = Object.freeze([ 'pp-toggles', @@ -119,11 +123,19 @@ async function main() { ); await sleep(500); await step( - 'expanding the strip shows the body and the four-swatch legend', + 'expanding the strip shows the body, one chip per provider and the legend', async () => { const info = await panel(); assert.ok(!isCollapsed(info.classes)); assert.equal(info.bodyDisplay, 'flex'); + const chips = await page.evaluate(() => + [ + ...document.querySelectorAll( + '#sl-provider-chips .data-toggle-chip', + ), + ].map((chip) => chip.dataset.chipId), + ); + assert.deepEqual(chips, EXPECTED_PROVIDERS); assert.equal( await page.evaluate( () => document.querySelectorAll('#sl-legend li').length, @@ -145,6 +157,15 @@ async function main() { const info = await panel(); assert.equal(info.controlsDisabled, true); assert.equal(info.status, 'KEY REQUIRED'); + assert.equal( + await page.evaluate(() => + document + .querySelector('#sl-provider-chips [data-chip-id="mapillary"]') + .classList.contains('chip-error'), + ), + true, + 'the keyless provider chip reads as an error', + ); }, ); console.log( @@ -188,7 +209,7 @@ async function main() { const u = window.__godsEyeView.dataManager.layers .get('street-level') .module.getUIState(); - return u.coverage.sequences > 0 && !u.coverage.loading; + return u.coverage.count > 0 && !u.coverage.loading; }, { timeout: 90_000 }, ); @@ -201,14 +222,76 @@ async function main() { ); }, ); + const ui = () => + page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(), + ); + await step( + 'switching the provider chip off clears its coverage and credit', + async () => { + await page.click('#sl-provider-chips [data-chip-id="mapillary"]'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().coverage.count === 0, + { timeout: 15_000 }, + ); + const state = await ui(); + assert.equal(state.providers[0].on, false); + assert.deepEqual(state.legend, [], 'no active provider, no legend'); + await page.waitForFunction( + () => !document.body.innerHTML.includes('Mapillary contributors'), + { timeout: 15_000 }, + ); + assert.deepEqual( + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getParams(), + ), + { mapillary: false, pano: 'all', sinceDays: 0 }, + ); + }, + ); + await step('switching it back on restores coverage', async () => { + await page.click('#sl-provider-chips [data-chip-id="mapillary"]'); + await page.waitForFunction( + () => { + const u = window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(); + return u.coverage.count > 0 && !u.coverage.loading; + }, + { timeout: 90_000 }, + ); + assert.equal((await ui()).providers[0].on, true); + }); + await step( + 'the 360° filter keeps at most the unfiltered sequence count', + async () => { + const before = (await ui()).coverage.count; + await page.click('[data-sl-pano="pano"]'); + await sleep(600); + const after = (await ui()).coverage.count; + assert.ok(after <= before, `${after} ≤ ${before}`); + assert.equal((await ui()).filter.pano, 'pano'); + await page.click('[data-sl-pano="all"]'); + await sleep(600); + assert.equal((await ui()).coverage.count, before); + }, + ); await step( 'opening the nearest image shows the viewer with a caption', async () => { - await page.evaluate(() => - window.__godsEyeView.dataManager.layers + // Fire and forget: the open can outlive one CDP call, so poll instead. + await page.evaluate(() => { + void window.__godsEyeView.dataManager.layers .get('street-level') - .module.openNearest(), - ); + .module.openNearest(); + }); await page.waitForFunction( () => { const s = window.__godsEyeView.dataManager.layers @@ -254,6 +337,8 @@ async function main() { const view = await page.evaluate(() => ({ hidden: document.getElementById('sl-viewer-wrap').hidden, when: document.getElementById('sl-image-when').textContent.trim(), + link: document.getElementById('sl-image-link').textContent.trim(), + href: document.getElementById('sl-image-link').href, width: Math.round( document.getElementById('sl-viewer').getBoundingClientRect().width, ), @@ -261,6 +346,9 @@ 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'); + assert.equal(view.link, 'MAPILLARY ↗'); + assert.match(view.href, /mapillary\.com\/app\/\?pKey=/); }, ); await step( diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index cb7b98183..250afd0f2 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -156,7 +156,10 @@ export function createApplicationCatalog({ source: sources['fire-perimeters'], }), createApplicationAlpr({ surface, source: sources.alpr }), - createApplicationStreetLevel({ surface, source: sources.mapillary }), + createApplicationStreetLevel({ + surface, + sources: { mapillary: sources.mapillary }, + }), satellites, createApplicationLaunches({ source: sources.launches, satellites }), createApplicationTraffic({ source: sources.traffic }), diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index f0e729b3f..394f9098b 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -1,13 +1,18 @@ 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'; -/** Construct the Street Level layer using the application scene owners and a supplied source. */ -export function createApplicationStreetLevel({ surface, source }) { +/** + * Construct the Street Level layer with every imagery provider this build + * ships, using the application scene owners and the supplied sources. A new + * provider registers here; its chip, credit and share bit follow. + */ +export function createApplicationStreetLevel({ surface, sources }) { return createStreetLevelLayer({ - source, + providers: [createMapillaryProvider({ source: sources.mapillary })], services: { sprites, picking, diff --git a/src/data/layerState.js b/src/data/layerState.js index 7dccd3699..7a2acf57f 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -398,6 +398,17 @@ const OPTION_GROUPS = Object.freeze({ ), booleanOption('viirs', 'v', false), ]), + 'street-level': Object.freeze([ + // One switch per registered imagery provider (src/app/layers/streetLevel.js). + booleanOption('mapillary', 'm', true), + enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], { + all: 'a', + pano: 'p', + flat: 'f', + }), + // "Captured since" as relative days, so a link means the same next year. + boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }), + ]), radio: Object.freeze([ Object.freeze({ key: 'filter', @@ -540,7 +551,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ Object.freeze({ id: 'street-level', token: '3', - disposition: 'enabled-only', + disposition: 'enabled+options', + optionOwner: 'street-level', }), Object.freeze({ id: 'telegeography-submarine-cables', diff --git a/src/layers/streetLevel/cameraFollow.js b/src/layers/streetLevel/cameraFollow.js new file mode 100644 index 000000000..599d9aba8 --- /dev/null +++ b/src/layers/streetLevel/cameraFollow.js @@ -0,0 +1,77 @@ +import * as Cesium from 'cesium'; +import { FOLLOW_EYE_HEIGHT_M } from './policy.js'; + +/** + * Drive the globe camera from the street-level pose: either continuously + * ("camera follows view") or once, framing the image from behind. + */ +export function createCameraFollow({ state }) { + const { render } = state.services; + + function requestRender() { + render?.governorRequestRender?.('street-level-follow'); + } + + function groundHeightAt(lon, lat, fallback) { + const scene = state.viewer?.scene; + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + let height = null; + try { + if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); + } catch { + /* not sampleable yet */ + } + if (!Number.isFinite(height)) height = scene?.globe?.getHeight?.(carto); + if (!Number.isFinite(height)) height = fallback; + return Number.isFinite(height) ? height : 0; + } + + /** Put the Cesium camera where the street-level camera is. */ + function followCamera() { + const { position, bearing, tilt, follow, altitude } = state.street; + if (!follow || !position || !state.viewer) return; + const ground = groundHeightAt(position.lon, position.lat, altitude); + state.viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + position.lon, + position.lat, + ground + FOLLOW_EYE_HEIGHT_M, + ), + orientation: { + heading: Cesium.Math.toRadians(bearing || 0), + pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0), + roll: 0, + }, + }); + requestRender(); + } + + /** Frame the current image from a short distance behind it. */ + function lookAtPosition() { + const { position, bearing, altitude } = state.street; + if (!position || !state.viewer) return; + const ground = groundHeightAt(position.lon, position.lat, altitude); + state.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), + 4, + ), + { + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(bearing || 0), + Cesium.Math.toRadians(-32), + 140, + ), + duration: 1.6, + }, + ); + } + + function setFollow(enabled) { + state.street.follow = enabled === true; + if (state.street.follow) followCamera(); + state.notify?.(); + } + + return { followCamera, lookAtPosition, setFollow }; +} diff --git a/src/layers/streetLevel/credits.js b/src/layers/streetLevel/credits.js new file mode 100644 index 000000000..de1a9f322 --- /dev/null +++ b/src/layers/streetLevel/credits.js @@ -0,0 +1,39 @@ +import * as Cesium from 'cesium'; + +/** + * One on-screen Cesium credit per active provider. Street-level imagery is + * typically CC BY-SA, which needs visible attribution that goes away with + * the imagery, so these are static credits added and removed per provider. + */ +export function createCredits() { + /** @type {Map} provider id → Cesium.Credit */ + const shown = new Map(); + + function show(viewer, def) { + if (shown.has(def.id) || !viewer?.creditDisplay) return; + try { + const credit = new Cesium.Credit(def.credit.html, true); + viewer.creditDisplay.addStaticCredit(credit); + shown.set(def.id, credit); + } catch { + /* credit display unavailable */ + } + } + + function hide(viewer, def) { + const credit = shown.get(def.id); + if (!credit) return; + try { + viewer?.creditDisplay?.removeStaticCredit?.(credit); + } catch { + /* credit display already torn down */ + } + shown.delete(def.id); + } + + function hideAll(viewer) { + for (const id of [...shown.keys()]) hide(viewer, { id }); + } + + return { show, hide, hideAll, has: (id) => shown.has(id) }; +} diff --git a/src/layers/streetLevel/filter.js b/src/layers/streetLevel/filter.js new file mode 100644 index 000000000..5a86c2f7b --- /dev/null +++ b/src/layers/streetLevel/filter.js @@ -0,0 +1,58 @@ +import { FILTER_DEFAULT, MAX_SINCE_DAYS, PANO_MODES } from './policy.js'; + +const DAY_MS = 86_400_000; + +/** + * Merge a partial filter change into the current one. Unknown panorama modes + * and negative or non-integer day counts leave the current value alone. + * @param {{pano?: string, sinceDays?: number}|null} next + * @param {{pano: string, sinceDays: number}} [current] + * @returns {{pano: string, sinceDays: number}} + */ +export function normalizeFilter(next, current = FILTER_DEFAULT) { + const pano = PANO_MODES.includes(next?.pano) ? next.pano : current.pano; + let sinceDays = current.sinceDays; + if (next && 'sinceDays' in next) { + const days = Number(next.sinceDays); + if (Number.isInteger(days) && days >= 0) + sinceDays = Math.min(days, MAX_SINCE_DAYS); + } + return { pano, sinceDays }; +} + +/** True when two filters would show the same imagery. */ +export function sameFilter(a, b) { + return a?.pano === b?.pano && a?.sinceDays === b?.sinceDays; +} + +/** + * Turn the stored filter (relative days, stable in share links) into the + * absolute form providers compare capture times against. + * @returns {{pano: string, sinceMs: number|null}} + */ +export function resolveFilter(filter, now = Date.now()) { + const days = Number(filter?.sinceDays) || 0; + return { + pano: PANO_MODES.includes(filter?.pano) ? filter.pano : 'all', + sinceMs: days > 0 ? now - days * DAY_MS : null, + }; +} + +/** + * Whether one capture passes the resolved imagery filter: panorama mode + * ('all' | 'pano' | 'flat') and an optional earliest capture time. + * @param {{isPano?: boolean, capturedAt?: number}} record + * @param {{pano: string, sinceMs: number|null}|null} filter + */ +export function passesImageryFilter(record, filter) { + if (!filter) return true; + if (filter.pano === 'pano' && !record.isPano) return false; + if (filter.pano === 'flat' && record.isPano) return false; + if ( + Number.isFinite(filter.sinceMs) && + filter.sinceMs > 0 && + (record.capturedAt || 0) < filter.sinceMs + ) + return false; + return true; +} diff --git a/src/layers/streetLevel/filter.test.mjs b/src/layers/streetLevel/filter.test.mjs new file mode 100644 index 000000000..e2f3a1c3a --- /dev/null +++ b/src/layers/streetLevel/filter.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + normalizeFilter, + passesImageryFilter, + resolveFilter, + sameFilter, +} from './filter.js'; + +const DAY = 86_400_000; + +test('normalizeFilter merges a partial change and ignores junk', () => { + const current = { pano: 'all', sinceDays: 0 }; + assert.deepEqual(normalizeFilter({ pano: 'pano' }, current), { + pano: 'pano', + sinceDays: 0, + }); + assert.deepEqual(normalizeFilter({ pano: 'sideways' }, current), current); + assert.deepEqual(normalizeFilter({ sinceDays: 365 }, current), { + pano: 'all', + sinceDays: 365, + }); + assert.deepEqual(normalizeFilter({ sinceDays: -3 }, current), current); + assert.deepEqual(normalizeFilter({ sinceDays: 1.5 }, current), current); + assert.deepEqual(normalizeFilter({ sinceDays: 99_999_999 }, current), { + pano: 'all', + sinceDays: 36_500, + }); + assert.deepEqual(normalizeFilter(null, current), current); + assert.deepEqual(normalizeFilter({ pano: 'flat' }), { + pano: 'flat', + sinceDays: 0, + }); +}); + +test('resolveFilter turns relative days into an absolute cut-off', () => { + const now = Date.UTC(2026, 8, 25); + assert.deepEqual(resolveFilter({ pano: 'all', sinceDays: 0 }, now), { + pano: 'all', + sinceMs: null, + }); + assert.deepEqual(resolveFilter({ pano: 'flat', sinceDays: 10 }, now), { + pano: 'flat', + sinceMs: now - 10 * DAY, + }); + assert.equal(resolveFilter({ pano: 'nope' }, now).pano, 'all'); +}); + +test('passesImageryFilter applies panorama mode and the cut-off', () => { + const pano = { isPano: true, capturedAt: 200 }; + const flat = { isPano: false, capturedAt: 50 }; + assert.equal(passesImageryFilter(pano, null), true); + assert.equal( + passesImageryFilter(pano, { pano: 'pano', sinceMs: null }), + true, + ); + assert.equal( + passesImageryFilter(flat, { pano: 'pano', sinceMs: null }), + false, + ); + assert.equal( + passesImageryFilter(flat, { pano: 'flat', sinceMs: null }), + true, + ); + assert.equal( + passesImageryFilter(pano, { pano: 'flat', sinceMs: null }), + false, + ); + assert.equal(passesImageryFilter(flat, { pano: 'all', sinceMs: 100 }), false); + assert.equal(passesImageryFilter(pano, { pano: 'all', sinceMs: 100 }), true); + assert.equal(passesImageryFilter({}, { pano: 'all', sinceMs: 1 }), false); +}); + +test('sameFilter compares the stored form', () => { + assert.equal( + sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 0 }), + true, + ); + assert.equal( + sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 1 }), + false, + ); +}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 18b934635..a792cc7ab 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,58 +1,55 @@ import { createState } from './state.js'; -import { createCoverage, visibleBbox } from './providers/mapillary/coverage.js'; -import { createSequences } from './providers/mapillary/sequences.js'; -import { createViewerBridge } from './providers/mapillary/viewer.js'; +import { createMarker } from './marker.js'; +import { createCameraFollow } from './cameraFollow.js'; +import { createCredits } from './credits.js'; +import { createPickRouter } from './pickRouter.js'; import { createSelection } from './selection.js'; -import * as Cesium from 'cesium'; +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 } from './uiState.js'; +import { viewCentre } from './view.js'; import { - COLORS, - COVERAGE_RECENT_DAYS, - MAPILLARY_CREDIT_HTML, - MAPILLARY_KEY_ID, - STREET_LEVEL_LAYER_ID, - NEAREST_LIMIT, NEAREST_RADIUS_M, -} from './providers/mapillary/policy.js'; + POSITION_PICK_ID, + STREET_LEVEL_LAYER_ID, +} from './policy.js'; -export { STREET_LEVEL_LAYER_ID } from './providers/mapillary/policy.js'; - -const SOURCE_METHODS = [ - 'getStatus', - 'getTile', - 'getImage', - 'getSequenceImages', - 'nearestImages', -]; +export { STREET_LEVEL_LAYER_ID } from './policy.js'; /** - * Construct the Mapillary street-level layer: camera-driven coverage, - * per-sequence image cones and the embedded viewer. Application services - * are injected; nothing here reads the DOM except the viewer host the UI - * hands over. + * Construct the Street Level layer from a list of imagery providers (see + * registry.js for the contract). The core owns what every provider shares: + * the enable state and per-provider switches, the imagery filter, one click + * handler, one viewer host, the position marker, camera follow, credits and + * share-link parameters. Providers own their coverage, sequences and viewer. + * @param {{providers: Array, services?: object}} options */ -export function createStreetLevelLayer({ source, services = {} }) { - if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function')) - throw new TypeError('A Mapillary source is required'); +export function createStreetLevelLayer({ + providers: definitions, + services = {}, +}) { + const definitionsFrozen = validateProviders(definitions); const state = createState({ services }); const parts = {}; - const context = { state, source, parts }; - parts.coverage = createCoverage(context); - parts.sequences = createSequences(context); - parts.viewer = createViewerBridge(context); - parts.selection = createSelection(context); - - // Street-level actions shared by clicks and the UI. - parts.street = { - async openImage(imageId) { - const host = state.street.host; - if (!host) { - state.street.error = 'Open the Street Level panel to view imagery'; - notify(); - return; - } - await parts.viewer.open(imageId, host); - }, + const context = { state, parts }; + parts.credits = createCredits(); + parts.marker = createMarker(context); + parts.follow = createCameraFollow(context); + parts.router = createPickRouter(() => state.providers.values(), { + positionId: POSITION_PICK_ID, + }); + 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.selection = createSelection(context); /** Run low-priority work when the browser is idle (or soon, headless). */ function scheduleIdle(task) { @@ -79,140 +76,175 @@ export function createStreetLevelLayer({ source, services = {} }) { } state.notify = notify; - async function refreshStatus() { + function providerContext(entry) { + return Object.freeze({ + services: state.services, + getViewer: () => state.viewer, + getFilter: () => resolveFilter(state.filter), + isActive: () => state.enabled && entry.on, + notify, + actions: { + openImage: (imageId) => openImage(entry.def.id, imageId), + reportError: (message) => { + state.street.error = message || null; + notify(); + }, + }, + }); + } + + for (const def of definitionsFrozen) { + const entry = { def, instance: null, on: true, status: 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 { - state.status = await source.getStatus(); - state.statusError = null; - state.keyRequired = state.status?.configured !== true; - } catch (error) { - state.statusError = error?.message || 'status unavailable'; - state.keyRequired = !source.hasToken(); + entry.status = await entry.instance.status(); + } catch { + entry.status = null; } notify(); } - /** Colour key for the coverage web, in the order the panel lists it. */ - function coverageLegend() { - const years = Math.round(COVERAGE_RECENT_DAYS / 365); - return [ - { key: 'recent', label: `Recent (≤${years} yr)`, color: COLORS.coverage }, - { key: 'older', label: 'Older', color: COLORS.coverageOld }, - { key: 'pano', label: '360°', color: COLORS.pano }, - { key: 'selected', label: 'Selected', color: COLORS.selected }, - ]; + function activate(entry) { + if (!state.viewer) return; + entry.instance.activate(state.viewer); + parts.credits.show(state.viewer, entry.def); + if (!entry.status) refreshStatus(entry); + scheduleIdle(() => parts.viewerHost.prewarm([entry])); } - /** CC BY-SA attribution shown on the globe while the layer is on. */ - function presentCredit(viewer) { - if (state.credit || !viewer?.creditDisplay) return; - try { - state.credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true); - viewer.creditDisplay.addStaticCredit(state.credit); - } catch { - state.credit = null; + function deactivate(entry) { + if (state.street.providerId === entry.def.id) parts.viewerHost.unmount(); + entry.instance.deactivate(); + parts.credits.hide(state.viewer, entry.def); + } + + function openImage(providerId, imageId) { + 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; } - function hideCredit(viewer) { - if (!state.credit) return; - try { - viewer?.creditDisplay?.removeStaticCredit?.(state.credit); - } catch { - /* credit display already torn down */ + 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); + 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, + keyRequired: stats.keyRequired === true, + requiresKeyId: entry.def.requiresKeyId || null, + loading: stats.loading === true, + count: stats.count || 0, + hint: stats.hint || '', + error: stats.error || null, + legend: entry.def.legend, + }; + }); + } + + function sequenceSnapshot() { + const owner = state.providers.get(state.street.providerId); + const candidates = owner + ? [owner, ...state.providers.values()] + : [...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, + }; } - state.credit = null; - } - - /** Centre of the visible ground, or the camera's own footprint. */ - function viewCentre() { - const viewer = state.viewer; - if (!viewer) return null; - const bbox = visibleBbox(viewer); - if (bbox) - return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 }; - const carto = viewer.camera.positionCartographic; - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - }; + return { providerId: null, selectedId: null, images: 0, loading: false }; } function getUIState() { - return { + const { host, ...street } = state.street; + return composeUIState({ enabled: state.enabled, - keyRequired: state.keyRequired, - coverage: { - zoom: state.coverage.zoom, - kind: state.coverage.kind, - filter: { ...state.coverage.filter }, - legend: coverageLegend(), - loading: state.coverage.loading > 0, - sequences: parts.coverage.sequenceCount(), - hint: state.coverage.hint, - error: state.coverage.lastError, - }, - sequence: { - selectedId: state.sequence.selectedId, - images: state.sequence.images.length, - loading: state.sequence.loading, - }, - street: { - open: state.street.open, - follow: state.street.follow, - loading: state.street.loading, - error: state.street.error, - imageId: state.street.imageId, - position: state.street.position ? { ...state.street.position } : null, - bearing: state.street.bearing, - isPano: state.street.isPano, - capturedAt: state.street.capturedAt, - sequenceId: state.street.sequenceId, - creator: state.street.creator || null, - renderMode: state.street.renderMode, - }, - }; + filter: state.filter, + providers: providerSnapshots(), + street, + sequence: sequenceSnapshot(), + }); } const layer = { id: STREET_LEVEL_LAYER_ID, name: 'Street Level', icon: '📷', - source: 'Mapillary', + source: definitionsFrozen.map((def) => def.name).join(' · '), updateInterval: 0, statsRefreshInterval: 1000, - requiresKeyId: MAPILLARY_KEY_ID, + requiresKeyId: requiresKeyIdFor(definitionsFrozen), + /** Registered providers, in chip order. */ + providerIds: definitionsFrozen.map((def) => def.id), init(viewer) { if (state.initialized) throw new Error('Street Level layer is already initialized'); state.viewer = viewer; state.initialized = true; - parts.sequences.ensureCollections(viewer); - parts.sequences.setVisible(false); - refreshStatus(); + parts.marker.ensure(viewer); + parts.marker.setVisible(false); + for (const entry of state.providers.values()) { + entry.instance.init(viewer); + refreshStatus(entry); + } console.log('[Data:StreetLevel] Initialized'); }, enable(viewer) { state.enabled = true; - presentCredit(viewer); - parts.sequences.setVisible(true); + state.viewer = viewer; + parts.marker.setVisible(true); parts.selection.install(viewer); - parts.coverage.attach(viewer); - if (!state.status) refreshStatus(); - scheduleIdle(() => parts.viewer.prewarm()); + for (const entry of activeEntries()) activate(entry); notify(); }, disable() { state.enabled = false; - hideCredit(state.viewer); - parts.coverage.detach(); - parts.coverage.clear(); - parts.sequences.clearSelection(); + parts.viewerHost.unmount(); + for (const entry of state.providers.values()) { + entry.instance.deactivate(); + parts.credits.hide(state.viewer, entry.def); + } parts.selection.uninstall(); - parts.viewer.destroy(); - parts.sequences.setVisible(false); + parts.marker.setVisible(false); notify(); }, @@ -222,7 +254,9 @@ export function createStreetLevelLayer({ source, services = {} }) { destroy(viewer = state.viewer) { layer.disable(); - parts.sequences.destroy(viewer); + for (const entry of state.providers.values()) + entry.instance.destroy(viewer); + parts.marker.destroy(viewer); state.listeners.clear(); state.viewer = null; state.initialized = false; @@ -230,21 +264,40 @@ export function createStreetLevelLayer({ source, services = {} }) { }, getStats() { + const ui = getUIState(); let loadingLabel = ''; - if (state.keyRequired) loadingLabel = 'KEY REQUIRED'; - else if (state.coverage.loading > 0) loadingLabel = 'loading coverage...'; - else if (state.coverage.hint && state.enabled) - loadingLabel = state.coverage.hint; + if (ui.keyRequired) loadingLabel = 'KEY REQUIRED'; + else if (ui.coverage.loading) loadingLabel = 'loading coverage...'; + else if (ui.coverage.hint && state.enabled) + loadingLabel = ui.coverage.hint; return { - count: parts.coverage.sequenceCount(), - sequences: parts.coverage.sequenceCount(), - loading: state.coverage.loading > 0, - keyRequired: state.keyRequired, - error: state.keyRequired ? 'KEY REQUIRED' : state.coverage.lastError, + count: ui.coverage.count, + sequences: ui.coverage.count, + loading: ui.coverage.loading, + keyRequired: ui.keyRequired, + error: ui.keyRequired ? 'KEY REQUIRED' : ui.coverage.error, loadingLabel, }; }, + /** Share-link and stored state: provider switches plus the filter. */ + getParams() { + return encodeParams({ + providers: [...state.providers].map(([id, entry]) => [id, entry.on]), + filter: state.filter, + }); + }, + + 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); + return true; + }, + // ── Public surface used by the panel ──────────────────────────────── subscribe(listener) { if (typeof listener !== 'function') return () => {}; @@ -252,40 +305,39 @@ export function createStreetLevelLayer({ source, services = {} }) { return () => state.listeners.delete(listener); }, getUIState, - /** The DOM element the MapillaryJS viewer renders into. */ + /** The DOM element provider viewers render into. */ attachViewerHost(element) { - state.street.host = element || null; - if (element && state.street.open) parts.viewer.resize(); + parts.viewerHost.attach(element); if (element && state.enabled) - scheduleIdle(() => parts.viewer.prewarm(element)); + scheduleIdle(() => parts.viewerHost.prewarm(activeEntries())); }, + setProviderEnabled, + isProviderEnabled: (id) => state.providers.get(id)?.on === true, /** Imagery filter for coverage, cones and nearest-image lookups. */ - setCoverageFilter(next) { - parts.coverage.setFilter(next); - parts.sequences.rerender(); - }, - getCoverageFilter: () => ({ ...state.coverage.filter }), - openImage: (imageId) => parts.street.openImage(imageId), + setCoverageFilter, + getCoverageFilter: () => ({ ...state.filter }), + openImage, + /** Open the nearest image any active provider has around a point. */ async openNearest(point) { - const view = point || viewCentre(); + const view = point || viewCentre(state.viewer); if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) return false; state.street.loading = true; state.street.error = null; notify(); try { - const images = await source.nearestImages({ - lat: view.lat, - lon: view.lon, - radius: NEAREST_RADIUS_M, - limit: NEAREST_LIMIT, - }); - if (!images.length) - throw new Error( - `No Mapillary imagery within ${NEAREST_RADIUS_M} m of the view centre`, - ); - await parts.street.openImage(images[0].id); - return true; + for (const entry of activeEntries()) { + const imageId = await entry.instance.nearestImage({ + lat: view.lat, + lon: view.lon, + }); + if (!imageId) continue; + await openImage(entry.def.id, imageId); + return true; + } + throw new Error( + `No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`, + ); } catch (error) { state.street.error = error?.message || 'Nearest image unavailable'; state.street.loading = false; @@ -293,21 +345,27 @@ export function createStreetLevelLayer({ source, services = {} }) { return false; } }, - /** - * Close the image and deselect it everywhere on the map: position - * marker, highlighted sequence and its cones. - */ + /** Close the image and deselect it everywhere on the map. */ closeViewer() { - parts.viewer.close(); - parts.sequences.clearSelection(); + parts.viewerHost.close(); + parts.clearSequences(); + }, + setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode), + setFollow: (enabled) => parts.follow.setFollow(enabled), + lookAtImage: () => parts.follow.lookAtPosition(), + 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(); }, - setViewerRenderMode: (mode) => parts.viewer.setRenderMode(mode), - setFollow: (enabled) => parts.viewer.setFollow(enabled), - lookAtImage: () => parts.viewer.lookAtPosition(), - resizeViewer: () => parts.viewer.resize(), - selectSequence: (id) => parts.sequences.select(id), - clearSequence: () => parts.sequences.clearSelection(), - refreshCoverage: () => parts.coverage.refresh(), }; return layer; } diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js new file mode 100644 index 000000000..be9e88f51 --- /dev/null +++ b/src/layers/streetLevel/marker.js @@ -0,0 +1,68 @@ +import * as Cesium from 'cesium'; +import { positionMarkerGlyph } from './glyphs.js'; +import { COLORS, POSITION_PICK_ID } from './policy.js'; + +const SPRITE_ID = 'street-level:marker'; + +/** The on-globe marker for the image the viewer currently shows. */ +export function createMarker({ state }) { + const { render, sprites } = state.services; + + function requestRender() { + render?.governorRequestRender?.('street-level-marker'); + } + + function ensure(viewer) { + if (state.marker.collection) return; + state.marker.collection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.marker.collection); + sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection); + } + + /** Move (or create) the marker; a null position removes it. */ + function set(position, bearing) { + const collection = state.marker.collection; + if (!collection) return; + if (!position) { + collection.removeAll(); + state.marker.billboard = null; + requestRender(); + return; + } + const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat); + if (!state.marker.billboard) { + state.marker.billboard = collection.add({ + id: POSITION_PICK_ID, + position: cartesian, + image: positionMarkerGlyph({ color: COLORS.position }), + imageId: 'sl-position', + width: 40, + height: 40, + alignedAxis: Cesium.Cartesian3.UNIT_Z, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + }); + } else state.marker.billboard.position = cartesian; + state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0); + requestRender(); + } + + function setVisible(visible) { + if (state.marker.collection) state.marker.collection.show = visible; + requestRender(); + } + + function destroy(viewer) { + const collection = state.marker.collection; + if (!collection) return; + sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); + viewer?.scene?.primitives?.remove(collection); + state.marker.collection = null; + state.marker.billboard = null; + } + + return { ensure, set, clear: () => set(null), setVisible, destroy }; +} diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js new file mode 100644 index 000000000..12f77320e --- /dev/null +++ b/src/layers/streetLevel/params.js @@ -0,0 +1,33 @@ +import { normalizeFilter } from './filter.js'; + +/** + * Share-link and stored parameters: one boolean per registered provider plus + * the shared imagery filter. The codec in src/data/layerState.js names the + * same keys, so a provider added later also needs an option 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 parameters back. Unknown providers and malformed values are ignored, + * so a link written for a build with more providers still applies cleanly. + * @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 new file mode 100644 index 000000000..8a4ba4991 --- /dev/null +++ b/src/layers/streetLevel/params.test.mjs @@ -0,0 +1,40 @@ +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); +}); diff --git a/src/layers/streetLevel/pickRouter.js b/src/layers/streetLevel/pickRouter.js new file mode 100644 index 000000000..8bfdb7ad9 --- /dev/null +++ b/src/layers/streetLevel/pickRouter.js @@ -0,0 +1,22 @@ +/** + * Route picked primitive ids to the provider whose prefix they carry. The + * registry guarantees prefixes never overlap, so the first match is the only + * match. + * @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 new file mode 100644 index 000000000..d628dbc5d --- /dev/null +++ b/src/layers/streetLevel/pickRouter.test.mjs @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..a4a4780d1 --- /dev/null +++ b/src/layers/streetLevel/policy.js @@ -0,0 +1,26 @@ +/** Identity and shared tuning for the provider-neutral 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. */ +export const POSITION_PICK_ID = 'sl:pos'; + +/** Colours every provider shares: the selection highlight and the marker. */ +export const COLORS = Object.freeze({ + selected: '#00d4ff', + position: '#ffb300', +}); + +/** Panorama modes the imagery filter understands. */ +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. */ +export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); + +/** Nearest-image search when the user asks to look at a place. */ +export const NEAREST_RADIUS_M = 50; + +/** Eye height above the sampled ground when the globe camera follows the viewer. */ +export const FOLLOW_EYE_HEIGHT_M = 2.4; diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 04e8f4c77..905d2c1c9 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -1,5 +1,7 @@ import * as Cesium from 'cesium'; import { decodeCoverageTile } from './decode.js'; +import { passesImageryFilter } from '../../filter.js'; +import { cameraHeightAboveGround, visibleBbox } from '../../view.js'; import { coverageZoomForHeight, overviewZoomForHeight, @@ -41,92 +43,6 @@ function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) { : Cesium.Color.fromCssColorString(COLORS.coverageOld).withAlpha(0.7); } -/** - * Whether one capture passes the imagery filter: panorama mode - * ('all' | 'pano' | 'flat') and an optional earliest capture time. - */ -export function passesImageryFilter(record, filter) { - if (!filter) return true; - if (filter.pano === 'pano' && !record.isPano) return false; - if (filter.pano === 'flat' && record.isPano) return false; - if ( - Number.isFinite(filter.sinceMs) && - filter.sinceMs > 0 && - (record.capturedAt || 0) < filter.sinceMs - ) - return false; - return true; -} - -/** - * Camera height above the surface under the camera, in metres. Falls back to - * the ellipsoidal height when the globe has no height sample yet. - */ -function cameraHeightAboveGround(viewer) { - const carto = viewer?.camera?.positionCartographic; - if (!carto) return null; - const ground = viewer.scene?.globe?.getHeight?.(carto); - return carto.height - (Number.isFinite(ground) ? ground : 0); -} - -/** - * Visible bbox as [west, south, east, north] degrees, or null when the camera - * does not see the ground. A grid of screen rays is cast onto the ellipsoid - * and only the rays that hit count, so a view that includes the horizon (or a - * canvas whose frustum is stale) cannot inflate the box to the whole world; - * `computeViewRectangle` is the fallback when too few rays land. - */ -export function visibleBbox(viewer, { grid = 5 } = {}) { - const scene = viewer?.scene; - const camera = viewer?.camera; - if (!scene || !camera) return null; - const width = scene.canvas?.clientWidth || scene.canvas?.width || 0; - const height = scene.canvas?.clientHeight || scene.canvas?.height || 0; - const hits = []; - if (width > 0 && height > 0) { - const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; - const point = new Cesium.Cartesian2(); - for (let i = 0; i <= grid; i++) { - for (let j = 0; j <= grid; j++) { - point.x = (width * i) / grid; - point.y = (height * j) / grid; - let cartesian = null; - try { - cartesian = camera.pickEllipsoid(point, ellipsoid); - } catch { - cartesian = null; - } - if (!cartesian) continue; - const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid); - if (carto) hits.push(carto); - } - } - } - if (hits.length >= 4) { - let west = Infinity; - let south = Infinity; - let east = -Infinity; - let north = -Infinity; - for (const carto of hits) { - const lon = Cesium.Math.toDegrees(carto.longitude); - const lat = Cesium.Math.toDegrees(carto.latitude); - west = Math.min(west, lon); - east = Math.max(east, lon); - south = Math.min(south, lat); - north = Math.max(north, lat); - } - if (east - west > 0 && north - south > 0) return [west, south, east, north]; - } - const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid); - if (!rectangle) return null; - return [ - Cesium.Math.toDegrees(rectangle.west), - Cesium.Math.toDegrees(rectangle.south), - Cesium.Math.toDegrees(rectangle.east), - Cesium.Math.toDegrees(rectangle.north), - ]; -} - /** * Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km, * then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded @@ -149,7 +65,7 @@ export function createCoverage({ state, source }) { } function filter() { - return state.coverage.filter; + return state.filter; } /** One or more ground primitives for a tile's sequences, in draw batches. */ @@ -264,7 +180,7 @@ export function createCoverage({ state, source }) { state.coverage.tiles.delete(key); } - async function loadTile(tile, generation, kind) { + async function loadTile(tile, kind) { const key = tileKey(tile); if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) return; @@ -348,13 +264,13 @@ export function createCoverage({ state, source }) { } function notify() { - state.notify?.(); + state.context.notify(); } /** Recompute the tile set for the current camera and reconcile primitives. */ function refresh() { const viewer = state.viewer; - if (!viewer || !state.enabled || state.keyRequired) return; + if (!viewer || !state.context.isActive() || state.keyRequired) return; const height = cameraHeightAboveGround(viewer); const sequenceZoom = coverageZoomForHeight(height); const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); @@ -365,7 +281,7 @@ export function createCoverage({ state, source }) { bbox = [-180, -85, 180, 85]; if (!zoom || !bbox) { state.coverage.hint = - 'Point the camera at the globe for Mapillary coverage'; + 'Point the camera at the globe for street-level coverage'; clear(); notify(); return; @@ -376,8 +292,6 @@ export function createCoverage({ state, source }) { state.coverage.zoom = zoom; state.coverage.kind = kind; } - state.coverage.generation++; - const generation = state.coverage.generation; const { tiles } = tilesForBbox(bbox, zoom, { limit: kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, @@ -390,7 +304,7 @@ export function createCoverage({ state, source }) { controller.abort(); state.coverage.pending.delete(key); } - for (const tile of tiles) loadTile(tile, generation, kind); + for (const tile of tiles) loadTile(tile, kind); notify(); } @@ -421,7 +335,6 @@ export function createCoverage({ state, source }) { } function clear() { - state.coverage.generation++; for (const controller of state.coverage.pending.values()) controller.abort(); state.coverage.pending.clear(); @@ -433,20 +346,8 @@ export function createCoverage({ state, source }) { requestRender(); } - /** Apply a new imagery filter and rebuild every loaded tile from its cache. */ - function setFilter(next) { - const current = state.coverage.filter; - const pano = ['all', 'pano', 'flat'].includes(next?.pano) - ? next.pano - : current.pano; - const sinceMs = - next && 'sinceMs' in next - ? Number.isFinite(next.sinceMs) && next.sinceMs > 0 - ? next.sinceMs - : null - : current.sinceMs; - if (pano === current.pano && sinceMs === current.sinceMs) return; - state.coverage.filter = { pano, sinceMs }; + /** Rebuild every loaded tile from its decoded cache (after a filter change). */ + function rebuild() { purgeStale(); for (const entry of state.coverage.tiles.values()) { detachPrimitive(entry); @@ -500,9 +401,9 @@ export function createCoverage({ state, source }) { detach, refresh, clear, + rebuild, findSequence, recolorSequence, sequenceCount, - setFilter, }; } diff --git a/src/layers/streetLevel/providers/mapillary/decode.test.mjs b/src/layers/streetLevel/providers/mapillary/decode.test.mjs new file mode 100644 index 000000000..464a54d53 --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/decode.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PbfWriter } from 'pbf'; +import { decodeCoverageTile } from './decode.js'; + +const zigzag = (value) => (value << 1) ^ (value >> 31); + +/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */ +function sampleTile() { + const writer = new PbfWriter(); + writer.writeMessage( + 3, + (_, layer) => { + layer.writeVarintField(15, 2); + layer.writeStringField(1, 'sequence'); + layer.writeVarintField(5, 4096); + for (const key of ['id', 'captured_at', 'is_pano']) + layer.writeStringField(3, key); + layer.writeMessage( + 4, + (__, value) => value.writeStringField(1, 'seq-42'), + null, + ); + layer.writeMessage( + 4, + (__, value) => value.writeVarintField(5, 1_700_000_000), + null, + ); + layer.writeMessage( + 4, + (__, value) => value.writeBooleanField(7, true), + null, + ); + layer.writeMessage( + 2, + (__, feature) => { + feature.writeVarintField(1, 7); + feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]); + feature.writeVarintField(3, 2); // LineString + feature.writePackedVarint(4, [ + 9, + zigzag(100), + zigzag(100), // MoveTo + 10, + zigzag(200), + zigzag(200), // LineTo ×1 + ]); + }, + null, + ); + }, + null, + ); + writer.writeMessage( + 3, + (_, layer) => { + layer.writeVarintField(15, 2); + layer.writeStringField(1, 'overview'); + layer.writeVarintField(5, 4096); + layer.writeStringField(3, 'is_pano'); + layer.writeMessage( + 4, + (__, value) => value.writeBooleanField(7, false), + null, + ); + layer.writeMessage( + 2, + (__, feature) => { + feature.writeVarintField(1, 3); + feature.writePackedVarint(2, [0, 0]); + feature.writeVarintField(3, 1); // Point + feature.writePackedVarint(4, [9, zigzag(2048), zigzag(2048)]); + }, + null, + ); + }, + null, + ); + return new Uint8Array(writer.finish()); +} + +test('sequences and overview points decode into lon/lat records', () => { + const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 }); + assert.equal(decoded.sequences.length, 1); + const [sequence] = decoded.sequences; + assert.equal(sequence.id, 'seq-42'); + assert.equal(sequence.capturedAt, 1_700_000_000); + assert.equal(sequence.isPano, true); + assert.equal(sequence.coordinates.length, 2); + const [[lon0, lat0], [lon1, lat1]] = sequence.coordinates; + assert.ok( + lon0 > -180 && lon0 < 0 && lon1 > lon0, + '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.equal(decoded.images.length, 0, 'image layer skipped by default'); +}); + +test('an empty tile decodes to empty lists', () => { + assert.deepEqual( + decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }), + { + sequences: [], + images: [], + overview: [], + }, + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js new file mode 100644 index 000000000..14093f02a --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -0,0 +1,210 @@ +import { createCoverage } from './coverage.js'; +import { createSequences } from './sequences.js'; +import { createMapillaryViewer } from './viewer.js'; +import { passesImageryFilter } from '../../filter.js'; +import { + COLORS, + MAPILLARY_CREDIT_HTML, + MAPILLARY_KEY_ID, + MAPILLARY_LABEL, + MAPILLARY_LEGEND, + MAPILLARY_NAME, + MAPILLARY_PROVIDER_ID, + NEAREST_LIMIT, + NEAREST_RADIUS_M, + PICK_PREFIX, + mapillaryImageUrl, +} from './policy.js'; + +export { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js'; + +const SOURCE_METHODS = [ + 'getStatus', + 'getTile', + 'getImage', + 'getSequenceImages', + 'nearestImages', +]; + +/** Per-provider mutable state, created once per `create()`. */ +function createProviderState(context) { + return { + context, + services: context.services, + viewer: null, + keyRequired: false, + status: null, + filter: context.getFilter(), + coverage: { + zoom: null, + /** @type {Map, count: number}>} */ + tiles: new Map(), + /** Tiles from the previous zoom, kept on screen until replacements land. */ + stale: new Map(), + staleTimer: null, + pending: new Map(), + loading: 0, + lastError: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', + kind: null, + }, + sequence: { + selectedId: null, + images: [], + /** @type {Map>} recent sequences' thinned images */ + cache: new Map(), + collection: null, + loading: false, + abort: null, + }, + }; +} + +/** + * Mapillary as a Street Level provider: coverage from cached vector tiles, + * per-sequence image cones from the Graph API and the MapillaryJS viewer. + * @param {{source: object}} options A source from ./source.js (or a stand-in with the same methods). + * @returns {import('../../registry.js').StreetLevelProvider} + */ +export function createMapillaryProvider({ source }) { + if (!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({ + key: MAPILLARY_PROVIDER_ID, + html: MAPILLARY_CREDIT_HTML, + }), + capabilities: Object.freeze({ + coverage: 'tiles', + sequences: true, + pano: true, + capturedAt: true, + creator: true, + follow: true, + }), + legend: MAPILLARY_LEGEND, + externalUrl: mapillaryImageUrl, + + 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(); + } + 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.sequences.clearSelection(); + parts.sequences.setVisible(false); + }, + + destroy(cesiumViewer) { + parts.coverage.detach(); + parts.coverage.clear(); + parts.sequences.destroy(cesiumViewer); + }, + + refreshCoverage: () => parts.coverage.refresh(), + + setFilter(filter) { + state.filter = filter; + parts.coverage.rebuild(); + parts.sequences.rerender(); + }, + + coverageStats() { + return { + count: parts.coverage.sequenceCount(), + zoom: state.coverage.zoom, + kind: state.coverage.kind, + loading: state.coverage.loading > 0, + hint: state.coverage.hint, + error: state.coverage.lastError, + keyRequired: state.keyRequired, + }; + }, + + handlePick(pickId) { + if (pickId.startsWith(PICK_PREFIX.sequence)) { + parts.sequences.select(pickId.slice(PICK_PREFIX.sequence.length)); + 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. */ + async nearestImage({ lat, lon }) { + const images = await source.nearestImages({ + lat, + lon, + radius: NEAREST_RADIUS_M, + limit: NEAREST_LIMIT, + }); + const hit = images.find((record) => + passesImageryFilter( + { + isPano: record.is_pano === true, + capturedAt: Number(record.captured_at) || 0, + }, + state.filter, + ), + ); + return hit ? String(hit.id) : null; + }, + + viewer, + }; + }, + }); +} diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index e0b488a00..a44229dcc 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -1,13 +1,17 @@ -/** Identity and tuning for the Mapillary street-level layer. */ -export const STREET_LEVEL_LAYER_ID = 'street-level'; +import { COLORS as SHARED_COLORS } 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'; export const MAPILLARY_KEY_ID = 'mapillary'; export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com'; -/** Stable id prefixes for picked primitives. */ +/** Stable id prefixes for picked primitives; every one starts with `mly:`. */ export const PICK_PREFIX = Object.freeze({ + root: 'mly:', sequence: 'mly:seq:', image: 'mly:img:', - position: 'mly:pos', }); /** Brand green for coverage; panoramas get a distinct hue; selection is GEV cyan. */ @@ -15,9 +19,8 @@ export const COLORS = Object.freeze({ coverage: '#05cb63', coverageOld: '#2e7d5b', pano: '#ff4fd8', - selected: '#00d4ff', + selected: SHARED_COLORS.selected, image: '#e8eaed', - position: '#ffb300', }); /** Camera-driven coverage refresh. */ @@ -41,12 +44,30 @@ export const NEAREST_RADIUS_M = 50; export const NEAREST_LIMIT = 8; /** - * On-globe credit shown while the layer is enabled. Mapillary imagery and + * On-globe credit shown while the provider is active. Mapillary imagery and * derived data are CC BY-SA 4.0 and require visible attribution. */ export const MAPILLARY_CREDIT_HTML = 'Street Level: imagery © Mapillary contributors, CC BY-SA 4.0'; +/** Coverage colour key, in the order the panel lists it. */ +export const MAPILLARY_LEGEND = Object.freeze([ + Object.freeze({ + key: 'recent', + label: `Recent (≤${Math.round(COVERAGE_RECENT_DAYS / 365)} yr)`, + color: COLORS.coverage, + }), + Object.freeze({ key: 'older', label: 'Older', color: COLORS.coverageOld }), + Object.freeze({ key: 'pano', label: '360°', color: COLORS.pano }), +]); + +/** Deep link to an image on mapillary.com, as the web app shares them. */ +export function mapillaryImageUrl(imageId) { + const id = String(imageId || '').trim(); + if (!id) return 'https://www.mapillary.com/app/'; + return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`; +} + /** Fields requested from the graph API. */ export const IMAGE_FIELDS = 'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score'; diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs new file mode 100644 index 000000000..a51be0e2b --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMapillaryProvider, mapillaryImageUrl } from './index.js'; +import { validateProviders } from '../../registry.js'; + +function fakeSource({ nearest = [] } = {}) { + return { + hasToken: () => true, + getStatus: async () => ({ configured: true }), + getTile: async () => new Uint8Array(0), + getImage: async () => ({}), + getSequenceImages: async () => [], + nearestImages: async () => nearest, + }; +} + +function fakeContext(filter = { pano: 'all', sinceMs: null }) { + const opened = []; + return { + opened, + services: {}, + getViewer: () => null, + getFilter: () => 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.equal(def.capabilities.coverage, 'tiles'); + assert.match(def.credit.html, /CC BY-SA 4\.0/); + assert.deepEqual( + def.legend.map((entry) => entry.key), + ['recent', 'older', 'pano'], + ); + assert.throws(() => createMapillaryProvider({ source: {} }), /source/); +}); + +test('image deep links match the mapillary.com share format', () => { + const def = createMapillaryProvider({ source: fakeSource() }); + assert.equal( + def.externalUrl(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); +}); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 74e593b50..2657c560a 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 './coverage.js'; +import { passesImageryFilter } from '../../filter.js'; import { COLORS, IMAGE_CONE_MIN_SPACING_M, @@ -8,6 +8,8 @@ import { PICK_PREFIX, } from './policy.js'; +const SPRITE_ID = 'street-level:mapillary-cones'; + /** How many recently viewed sequences keep their image list in memory. */ const SEQUENCE_CACHE_SIZE = 40; @@ -20,7 +22,7 @@ function metresBetween(a, b) { } /** Normalize a graph image record into the shape the cones use. */ -function normalizeSequenceImage(record) { +export function normalizeSequenceImage(record) { const coordinates = record?.geometry?.coordinates; if (!Array.isArray(coordinates) || coordinates.length < 2) return null; return { @@ -37,7 +39,7 @@ function normalizeSequenceImage(record) { } /** Drop images closer than the spacing to the previous kept one. */ -function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { +export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { const kept = []; let last = null; for (const image of images) { @@ -49,7 +51,7 @@ function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { return kept; } -/** Image cones for one selected sequence, plus the viewer position marker. */ +/** Image cones for one selected sequence. */ export function createSequences({ state, source, parts }) { const { render, sprites } = state.services; @@ -57,27 +59,17 @@ export function createSequences({ state, source, parts }) { render?.governorRequestRender?.('mapillary-sequence'); } + function notify() { + state.context.notify(); + } + function ensureCollections(viewer) { - if (!state.sequence.collection) { - state.sequence.collection = new Cesium.BillboardCollection({ - scene: viewer.scene, - }); - viewer.scene.primitives.add(state.sequence.collection); - sprites?.registerSpriteCollection?.( - 'street-level:mapillary-cones', - state.sequence.collection, - ); - } - if (!state.street.markerCollection) { - state.street.markerCollection = new Cesium.BillboardCollection({ - scene: viewer.scene, - }); - viewer.scene.primitives.add(state.street.markerCollection); - sprites?.registerSpriteCollection?.( - 'street-level:marker', - state.street.markerCollection, - ); - } + if (state.sequence.collection) return; + state.sequence.collection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.sequence.collection); + sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection); } function clearCones() { @@ -92,7 +84,7 @@ export function createSequences({ state, source, parts }) { const cone = imageConeGlyph({ size: 32, color: COLORS.image }); const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true }); for (const image of images) { - if (!passesImageryFilter(image, state.coverage.filter)) continue; + if (!passesImageryFilter(image, state.filter)) continue; collection.add({ id: `${PICK_PREFIX.image}${image.id}`, position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat), @@ -138,13 +130,13 @@ export function createSequences({ state, source, parts }) { state.sequence.loading = false; state.sequence.images = cached; renderCones(cached); - state.notify?.(); + notify(); return; } const controller = new AbortController(); state.sequence.abort = controller; state.sequence.loading = true; - state.notify?.(); + notify(); try { const records = await source.getSequenceImages(sequenceId, { signal: controller.signal, @@ -161,13 +153,15 @@ export function createSequences({ state, source, parts }) { renderCones(images); } catch (error) { if (!controller.signal.aborted) - state.street.error = error?.message || 'Sequence images unavailable'; + state.context.actions.reportError( + error?.message || 'Sequence images unavailable', + ); } finally { if (state.sequence.abort === controller) { state.sequence.loading = false; state.sequence.abort = null; } - state.notify?.(); + notify(); } } @@ -180,39 +174,7 @@ export function createSequences({ state, source, parts }) { state.sequence.loading = false; clearCones(); requestRender(); - state.notify?.(); - } - - /** Move (or create) the viewer position marker. */ - function setMarker(position, bearing) { - const collection = state.street.markerCollection; - if (!collection) return; - if (!position) { - collection.removeAll(); - state.street.marker = null; - requestRender(); - return; - } - const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat); - if (!state.street.marker) { - state.street.marker = collection.add({ - id: PICK_PREFIX.position, - position: cartesian, - image: (id) => - import('../../glyphs.js').then((m) => - m.positionMarkerGlyph({ color: COLORS.position }), - ), - imageId: 'mly-position', - width: 40, - height: 40, - alignedAxis: Cesium.Cartesian3.UNIT_Z, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - }); - } else state.street.marker.position = cartesian; - state.street.marker.rotation = -Cesium.Math.toRadians(bearing || 0); - requestRender(); + notify(); } /** Re-draw the current sequence's cones (after an imagery filter change). */ @@ -220,34 +182,18 @@ export function createSequences({ state, source, parts }) { if (state.sequence.images.length) renderCones(state.sequence.images); } - function hide(visible) { + function setVisible(visible) { if (state.sequence.collection) state.sequence.collection.show = visible; - if (state.street.markerCollection) - state.street.markerCollection.show = visible; requestRender(); } function destroy(viewer) { state.sequence.abort?.abort(); - for (const key of ['collection']) { - const collection = state.sequence[key]; - if (collection) { - sprites?.unregisterSpriteCollection?.( - 'street-level:mapillary-cones', - collection, - ); - viewer?.scene?.primitives?.remove(collection); - state.sequence[key] = null; - } - } - if (state.street.markerCollection) { - sprites?.unregisterSpriteCollection?.( - 'street-level:marker', - state.street.markerCollection, - ); - viewer?.scene?.primitives?.remove(state.street.markerCollection); - state.street.markerCollection = null; - state.street.marker = null; + const collection = state.sequence.collection; + if (collection) { + sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); + viewer?.scene?.primitives?.remove(collection); + state.sequence.collection = null; } state.sequence.images = []; state.sequence.selectedId = null; @@ -257,9 +203,8 @@ export function createSequences({ state, source, parts }) { ensureCollections, select, clearSelection, - setMarker, rerender, - setVisible: hide, + setVisible, destroy, }; } diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index 0092fc58f..542a08888 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -1,21 +1,23 @@ -import * as Cesium from 'cesium'; - -/** Eye height above the sampled ground when the globe camera follows the viewer. */ -const FOLLOW_EYE_HEIGHT_M = 2.4; +import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js'; /** - * Own the embedded MapillaryJS viewer: lazy-load the library, open images, - * mirror its pose onto the globe, and optionally drive the Cesium camera - * from it ("street cockpit"). + * The Mapillary viewer adapter: lazy-load MapillaryJS, mount it in the host + * the core hands over, open images, and emit a provider-neutral pose for + * every image, point-of-view or position change. + * @returns {import('../../registry.js').ViewerAdapter} */ -export function createViewerBridge({ state, source, parts }) { - const { render } = state.services; +export function createMapillaryViewer({ source, render } = {}) { let viewer = null; let Library = null; + let container = null; let pendingOpen = null; let prewarming = false; /** In-flight viewer construction, so pre-warm and open never build two. */ let creating = null; + let renderMode = 'letterbox'; + /** Metadata of the image on screen; pov/position events reuse it. */ + let current = null; + const listeners = new Set(); function requestRender() { render?.governorRequestRender?.('mapillary-viewer'); @@ -34,67 +36,30 @@ export function createViewerBridge({ state, source, parts }) { return Library; } - function groundHeightAt(lon, lat, fallback) { - const scene = state.viewer?.scene; - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - let height = null; - try { - if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); - } catch { - /* not sampleable yet */ + function emit(pose) { + for (const listener of [...listeners]) { + try { + listener(pose); + } catch { + /* listener errors are the core's to log */ + } } - if (!Number.isFinite(height)) height = scene?.globe?.getHeight?.(carto); - if (!Number.isFinite(height)) height = fallback; - return Number.isFinite(height) ? height : 0; } - /** Put the Cesium camera where the street-level camera is. */ - function followCamera() { - const { position, bearing, tilt } = state.street; - if (!state.street.follow || !position || !state.viewer) return; - const ground = groundHeightAt( - position.lon, - position.lat, - state.street.altitude, - ); - state.viewer.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees( - position.lon, - position.lat, - ground + FOLLOW_EYE_HEIGHT_M, - ), - orientation: { - heading: Cesium.Math.toRadians(bearing || 0), - pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0), - roll: 0, - }, - }); - requestRender(); - } - - /** Frame the current image from a short distance behind it. */ - function lookAtPosition() { - const { position, bearing } = state.street; - if (!position || !state.viewer) return; - const ground = groundHeightAt( - position.lon, - position.lat, - state.street.altitude, - ); - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), - 4, - ), - { - offset: new Cesium.HeadingPitchRange( - Cesium.Math.toRadians(bearing || 0), - Cesium.Math.toRadians(-32), - 140, - ), - duration: 1.6, - }, - ); + /** Read MapillaryJS image metadata into the neutral pose shape. */ + function describe(image) { + return { + imageId: String(image.id), + isPano: image.cameraType === 'spherical', + capturedAt: image.capturedAt ?? null, + sequenceId: image.sequenceId ?? null, + altitude: Number.isFinite(image.computedAltitude) + ? image.computedAltitude + : Number.isFinite(image.originalAltitude) + ? image.originalAltitude + : null, + creator: image.creatorUsername || null, + }; } async function publishPose(image) { @@ -104,74 +69,38 @@ export function createViewerBridge({ state, source, parts }) { viewer.getPosition(), viewer.getPointOfView(), ]); - state.street.position = { lon: lngLat.lng, lat: lngLat.lat }; - state.street.bearing = pov?.bearing ?? state.street.bearing; - state.street.tilt = pov?.tilt ?? 0; - if (image) { - state.street.imageId = image.id; - state.street.isPano = image.merged - ? image.cameraType === 'spherical' - : image.cameraType === 'spherical'; - state.street.capturedAt = image.capturedAt ?? null; - state.street.sequenceId = image.sequenceId ?? null; - state.street.altitude = Number.isFinite(image.computedAltitude) - ? image.computedAltitude - : Number.isFinite(image.originalAltitude) - ? image.originalAltitude - : null; - state.street.creator = image.creatorUsername || null; - // mapillary.com highlights the selected image's sequence on the map; - // mirror that once the image itself is on screen, so the sequence - // lookup never competes with the image download. - if (!state.street.loading) selectCurrentSequence(); - } - parts.sequences.setMarker(state.street.position, state.street.bearing); - followCamera(); - state.notify?.(); + 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, + tilt: Number.isFinite(pov?.tilt) ? pov.tilt : 0, + externalUrl: mapillaryImageUrl(current.imageId), + }); + requestRender(); } catch { /* viewer torn down mid-flight */ } } - function selectCurrentSequence() { - const { sequenceId } = state.street; - if (sequenceId && state.enabled && sequenceId !== state.sequence.selectedId) - parts.sequences.select(sequenceId); - } - - /** - * Load the library and stand the viewer up ahead of the first image, so - * opening one only costs the image download. Safe to call repeatedly. - */ - async function prewarm(container = state.street.host) { - if (prewarming || !state.enabled) return; - prewarming = true; - try { - await ensureLibrary(); - if (container && state.enabled && !viewer) await ensureViewer(container); - } catch { - /* the real open reports errors */ - } finally { - prewarming = false; - } - } - - function ensureViewer(container) { - if (viewer && state.street.container === container) return viewer; - if (creating?.container === container) return creating.promise; - const promise = createViewer(container).finally(() => { + 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, promise }; + creating = { container: host, promise }; return promise; } - async function createViewer(container) { + async function createViewer(host) { destroyViewer(); const { Viewer } = await ensureLibrary(); viewer = new Viewer({ accessToken: source.token, - container, + container: host, component: { cover: false, bearing: true, @@ -179,70 +108,15 @@ export function createViewerBridge({ state, source, parts }) { attribution: true, }, trackResize: true, - renderMode: libraryRenderMode(state.street.renderMode), + renderMode: libraryRenderMode(renderMode), }); - state.street.container = container; + container = host; viewer.on('image', (event) => publishPose(event.image)); viewer.on('pov', () => publishPose(null)); viewer.on('position', () => publishPose(null)); return viewer; } - /** - * Open an image in the viewer inside `container`. Returns once the first - * image has loaded; the pose then streams through `state.street`. - */ - async function open(imageId, container, { frame = true } = {}) { - if (!imageId || !container) return; - const id = String(imageId); - state.street.loading = true; - state.street.error = null; - state.street.open = true; - state.notify?.(); - pendingOpen = id; - try { - const instance = await ensureViewer(container); - if (pendingOpen !== id) return; - const image = await instance.moveTo(id); - if (pendingOpen !== id) return; - await publishPose(image); - if (frame && !state.street.follow) lookAtPosition(); - } catch (error) { - if (pendingOpen === id) - state.street.error = error?.message || 'Image could not be opened'; - } finally { - if (pendingOpen === id) state.street.loading = false; - state.notify?.(); - } - if (pendingOpen === id && !state.street.error) selectCurrentSequence(); - } - - /** Switch between showing the whole image ('letterbox') and cropping ('fill'). */ - function setRenderMode(mode) { - state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; - try { - const value = libraryRenderMode(state.street.renderMode); - if (viewer && value !== undefined) viewer.setRenderMode(value); - } catch { - /* viewer not ready */ - } - state.notify?.(); - } - - function setFollow(enabled) { - state.street.follow = enabled === true; - if (state.street.follow) followCamera(); - state.notify?.(); - } - - function resize() { - try { - viewer?.resize(); - } catch { - /* no-op */ - } - } - function destroyViewer() { if (viewer) { try { @@ -252,38 +126,77 @@ export function createViewerBridge({ state, source, parts }) { } } viewer = null; - state.street.container = null; - } - - /** Close the image. The viewer instance is kept warm for the next open. */ - function close() { - pendingOpen = null; - Object.assign(state.street, { - open: false, - follow: false, - imageId: null, - sequenceId: null, - position: null, - bearing: null, - tilt: null, - loading: false, - error: null, - }); - parts.sequences.setMarker(null); - state.notify?.(); + container = null; + current = null; } return { - open, - close, - setFollow, - setRenderMode, - resize, - prewarm, - lookAtPosition, - destroy() { - close(); + async mount(host) { + if (!host) throw new Error('Street Level viewer has no host element'); + await ensureViewer(host); + }, + + /** Resolves once the image is on screen and its first pose was emitted. */ + async open(imageId) { + const id = String(imageId); + if (!container) throw new Error('Mapillary viewer is not mounted'); + pendingOpen = id; + const instance = await ensureViewer(container); + if (pendingOpen !== id) return; + const image = await instance.moveTo(id); + if (pendingOpen !== id) return; + await publishPose(image); + }, + + close() { + pendingOpen = null; + current = null; + }, + + unmount() { + pendingOpen = null; destroyViewer(); }, + + resize() { + try { + viewer?.resize(); + } catch { + /* no-op */ + } + }, + + /** + * Load the library and stand the viewer up ahead of the first image, so + * opening one only costs the image download. Safe to call repeatedly. + */ + async prewarm(host) { + if (prewarming || !host) return; + prewarming = true; + try { + await ensureLibrary(); + if (!viewer) await ensureViewer(host); + } catch { + /* the real open reports errors */ + } finally { + prewarming = false; + } + }, + + /** Show the whole image ('letterbox') or crop it to the frame ('fill'). */ + setRenderMode(mode) { + renderMode = mode === 'fill' ? 'fill' : 'letterbox'; + try { + const value = libraryRenderMode(renderMode); + if (viewer && value !== undefined) viewer.setRenderMode(value); + } catch { + /* viewer not ready */ + } + }, + + onPose(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, }; } diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js new file mode 100644 index 000000000..af93b529f --- /dev/null +++ b/src/layers/streetLevel/registry.js @@ -0,0 +1,141 @@ +/** + * 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, coverageOld?: string, pano?: string}} colors + * @property {{key: string, html: string}} credit On-globe attribution while the provider is active. + * @property {{coverage: 'tiles'|'bbox'|'none', sequences: boolean, pano: boolean, capturedAt: boolean, creator: boolean, follow: boolean}} capabilities + * @property {Array<{key: string, label: string, color: string}>} legend Coverage colour key. + * @property {(imageId: string) => string} externalUrl Deep link to the image on the provider's site. + * @property {(context: ProviderContext) => ProviderInstance} create + * + * @typedef {object} ProviderContext Handed to `create()` once by the core. + * @property {object} services Scene services: picking, input, render, sprites, ground. + * @property {() => object|null} getViewer The Cesium viewer once the layer is initialised. + * @property {() => {pano: string, sinceMs: number|null}} getFilter The resolved imagery filter. + * @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 + * @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}) => Promise} nearestImage + * @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 + */ + +const REQUIRED = Object.freeze([ + 'id', + 'name', + 'label', + 'pickPrefix', + 'colors', + 'credit', + 'capabilities', + 'legend', + 'externalUrl', + 'create', +]); + +const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/; + +/** + * Check a provider list once at construction and freeze its order, which is + * also the chip order in the panel. + * @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.externalUrl !== 'function') + throw new TypeError( + `Street Level provider ${label}: externalUrl 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?.key || !provider.credit?.html) + throw new TypeError(`Street Level provider ${label} needs a credit`); + if (!Array.isArray(provider.legend)) + throw new TypeError( + `Street Level provider ${label}: legend is not a list`, + ); + ids.add(provider.id); + prefixes.push(provider.pickPrefix); + } + return Object.freeze([...providers]); +} + +/** + * The layer-level key requirement: the one key id every provider shares, or + * null as soon as any provider is keyless or they differ. Per-provider needs + * are then reported chip by chip instead of gating the whole layer. + * @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 new file mode 100644 index 000000000..a3cd598ae --- /dev/null +++ b/src/layers/streetLevel/registry.test.mjs @@ -0,0 +1,104 @@ +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: { key: 'mapillary', html: 'Mapillary' }, + capabilities: { coverage: 'tiles', sequences: true }, + legend: [], + externalUrl: (id) => `https://example.test/${id}`, + 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', + 'capabilities', + 'legend', + 'externalUrl', + 'create', + ]) + assert.throws( + () => validateProviders([def({ [key]: undefined })]), + new RegExp(`lacks ${key}`), + key, + ); + assert.throws(() => validateProviders([def({ create: 1 })]), /create/); + assert.throws( + () => validateProviders([def({ credit: { key: 'x' } })]), + /credit/, + ); + assert.throws(() => validateProviders([def({ legend: 'no' })]), /legend/); + 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 b65e0e80a..e1f5ff042 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -1,18 +1,12 @@ import * as Cesium from 'cesium'; -import { - STREET_LEVEL_LAYER_ID, - PICK_PREFIX, -} from './providers/mapillary/policy.js'; +import { STREET_LEVEL_LAYER_ID } from './policy.js'; -/** Click handling for coverage lines and image cones. */ +/** One click/hover handler for every provider's coverage and image cones. */ export function createSelection({ state, parts }) { const { picking, input } = state.services; function ownsPick(pickedId) { - return ( - typeof pickedId === 'string' && - Object.values(PICK_PREFIX).some((prefix) => pickedId.startsWith(prefix)) - ); + return parts.router.ownsPick(pickedId); } function onClick(click) { @@ -23,12 +17,11 @@ export function createSelection({ state, parts }) { const id = picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id; - if (!ownsPick(id)) return; - if (id.startsWith(PICK_PREFIX.sequence)) { - parts.sequences.select(id.slice(PICK_PREFIX.sequence.length)); - } else if (id.startsWith(PICK_PREFIX.image)) { - parts.street.openImage(id.slice(PICK_PREFIX.image.length)); - } + 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); } /** @@ -43,9 +36,9 @@ export function createSelection({ state, parts }) { ) ) return; - if (!state.sequence.selectedId) return; + if (!parts.hasSelectedSequence()) return; event.preventDefault(); - parts.sequences.clearSelection(); + parts.clearSequences(); } let hoverQueued = false; diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index 11111f48b..4734a0d1c 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -1,4 +1,6 @@ -/** Mutable per-layer state, created once per layer instance. */ +import { FILTER_DEFAULT } from './policy.js'; + +/** Mutable core state, created once per layer instance. */ export function createState({ services }) { return { services, @@ -6,63 +8,37 @@ export function createState({ services }) { enabled: false, initialized: false, destroyed: false, - status: null, - statusError: null, - keyRequired: false, - /** Cesium.Credit shown while enabled (CC BY-SA attribution). */ - credit: null, listeners: new Set(), - notifyScheduled: false, - - coverage: { - zoom: null, - /** @type {Map, count: number}>} */ - tiles: new Map(), - /** Tiles from the previous zoom, kept on screen until replacements land. */ - stale: new Map(), - staleTimer: null, - pending: new Map(), - generation: 0, - loading: 0, - lastError: null, - debounceTimer: null, - removeCameraListener: null, - terrainReady: null, - hint: '', - kind: null, - filter: { pano: 'all', sinceMs: null }, - }, - - sequence: { - selectedId: null, - images: [], - /** @type {Map>} recent sequences' thinned images */ - cache: new Map(), - collection: null, - loading: false, - abort: null, - }, + notify: null, + /** Shared imagery filter, in the stored (relative-days) form. */ + filter: { ...FILTER_DEFAULT }, + /** @type {Map} */ + providers: new Map(), street: { - bridge: null, - container: null, + host: null, open: false, follow: false, + providerId: null, + providerName: null, + providerLabel: null, imageId: null, position: null, bearing: null, tilt: null, + altitude: null, isPano: false, capturedAt: null, sequenceId: null, + creator: null, + externalUrl: null, loading: false, error: null, - marker: null, - markerCollection: null, /** 'letterbox' shows the whole image; 'fill' crops it to the frame. */ renderMode: 'letterbox', }, + marker: { collection: null, billboard: null }, clickHandler: null, }; } diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js new file mode 100644 index 000000000..27aab345e --- /dev/null +++ b/src/layers/streetLevel/uiState.js @@ -0,0 +1,66 @@ +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 + * @property {string|null} requiresKeyId + * @property {boolean} loading + * @property {number} count + * @property {string} hint + * @property {string|null} error + * @property {Array<{key: string, label: string, color: string}>} legend + */ + +/** + * Compose the snapshot the panel renders from the core state and one + * snapshot per registered provider. Pure, so the merge rules are testable: + * counts add up across active providers, the layer is key-gated only when + * every switched-on provider lacks its key, and the legend lists each active + * provider's colours (prefixed by name once more than one is registered) + * followed by 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 = + active.length > 0 && active.every((p) => p.keyRequired === true); + const legend = []; + for (const provider of active) + for (const entry of provider.legend) + legend.push({ + key: `${provider.id}:${entry.key}`, + label: + providers.length > 1 + ? `${provider.name} ${entry.label}` + : entry.label, + color: entry.color, + }); + if (active.length) + legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); + return { + enabled, + keyRequired, + filter: { ...filter }, + providers: providers.map((p) => ({ ...p, legend: [...p.legend] })), + coverage: { + loading: active.some((p) => p.loading), + count: active.reduce((sum, p) => sum + (p.count || 0), 0), + hint: active.find((p) => p.hint)?.hint || '', + error: active.find((p) => p.error)?.error || null, + }, + legend, + sequence: { ...sequence }, + street: { ...street }, + }; +} diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs new file mode 100644 index 000000000..d540d2b4e --- /dev/null +++ b/src/layers/streetLevel/uiState.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeUIState } from './uiState.js'; + +const provider = (overrides = {}) => ({ + id: 'mapillary', + name: 'Mapillary', + label: 'MAPILLARY', + on: true, + configured: true, + keyRequired: false, + requiresKeyId: 'mapillary', + loading: false, + count: 10, + hint: '', + error: null, + legend: [{ key: 'recent', label: 'Recent', color: '#0f0' }], + ...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 lists active providers, prefixed once more than one is registered', () => { + const single = composeUIState({ ...base, providers: [provider()] }); + assert.deepEqual( + single.legend.map((entry) => [entry.key, entry.label]), + [ + ['mapillary:recent', 'Recent'], + ['selected', 'Selected'], + ], + ); + const two = composeUIState({ + ...base, + providers: [ + provider(), + provider({ + id: 'panoramax', + name: 'Panoramax', + on: false, + legend: [{ key: 'coverage', label: 'Coverage', color: '#00f' }], + }), + ], + }); + assert.deepEqual( + two.legend.map((entry) => entry.label), + ['Mapillary Recent', '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'); +}); diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js new file mode 100644 index 000000000..56532cd94 --- /dev/null +++ b/src/layers/streetLevel/view.js @@ -0,0 +1,84 @@ +import * as Cesium from 'cesium'; + +/** + * Camera height above the surface under the camera, in metres. Falls back to + * the ellipsoidal height when the globe has no height sample yet. + */ +export function cameraHeightAboveGround(viewer) { + const carto = viewer?.camera?.positionCartographic; + if (!carto) return null; + const ground = viewer.scene?.globe?.getHeight?.(carto); + return carto.height - (Number.isFinite(ground) ? ground : 0); +} + +/** + * Visible bbox as [west, south, east, north] degrees, or null when the camera + * does not see the ground. A grid of screen rays is cast onto the ellipsoid + * and only the rays that hit count, so a view that includes the horizon (or a + * canvas whose frustum is stale) cannot inflate the box to the whole world; + * `computeViewRectangle` is the fallback when too few rays land. + */ +export function visibleBbox(viewer, { grid = 5 } = {}) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera) return null; + const width = scene.canvas?.clientWidth || scene.canvas?.width || 0; + const height = scene.canvas?.clientHeight || scene.canvas?.height || 0; + const hits = []; + if (width > 0 && height > 0) { + const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const point = new Cesium.Cartesian2(); + for (let i = 0; i <= grid; i++) { + for (let j = 0; j <= grid; j++) { + point.x = (width * i) / grid; + point.y = (height * j) / grid; + let cartesian = null; + try { + cartesian = camera.pickEllipsoid(point, ellipsoid); + } catch { + cartesian = null; + } + if (!cartesian) continue; + const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid); + if (carto) hits.push(carto); + } + } + } + if (hits.length >= 4) { + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const carto of hits) { + const lon = Cesium.Math.toDegrees(carto.longitude); + const lat = Cesium.Math.toDegrees(carto.latitude); + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + if (east - west > 0 && north - south > 0) return [west, south, east, north]; + } + const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid); + if (!rectangle) return null; + return [ + Cesium.Math.toDegrees(rectangle.west), + Cesium.Math.toDegrees(rectangle.south), + Cesium.Math.toDegrees(rectangle.east), + Cesium.Math.toDegrees(rectangle.north), + ]; +} + +/** Centre of the visible ground, or the camera's own footprint. */ +export function viewCentre(viewer) { + if (!viewer) return null; + const bbox = visibleBbox(viewer); + if (bbox) + return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 }; + const carto = viewer.camera?.positionCartographic; + if (!carto) return null; + return { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + }; +} diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js new file mode 100644 index 000000000..64c097ca8 --- /dev/null +++ b/src/layers/streetLevel/viewerHost.js @@ -0,0 +1,191 @@ +/** + * Own the one viewer element in the panel and whichever provider's viewer + * adapter is mounted in it. Poses the adapter emits become `state.street`, + * move the marker and (optionally) the globe camera. + */ +export function createViewerHost({ state, parts }) { + /** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */ + let active = null; + let openSeq = 0; + + function notify() { + state.notify?.(); + } + + function providerEntry(providerId) { + return state.providers.get(providerId) || null; + } + + function applyPose(pose) { + if (!active || pose.providerId !== active.id) 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, + tilt: Number.isFinite(pose.tilt) ? pose.tilt : 0, + altitude: Number.isFinite(pose.altitude) ? pose.altitude : null, + isPano: pose.isPano === true, + capturedAt: pose.capturedAt ?? null, + sequenceId: pose.sequenceId ?? null, + creator: pose.creator || null, + externalUrl: pose.externalUrl || null, + }); + parts.marker.set(state.street.position, state.street.bearing); + parts.follow.followCamera(); + // Providers highlight the selected image's sequence on the map; mirror + // that once the image itself is on screen, so the sequence lookup never + // competes with the image download. + if (!state.street.loading && state.street.sequenceId !== previousSequence) + selectCurrentSequence(); + notify(); + } + + 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); + } + + async function mount(entry) { + if (active?.id === entry.def.id) return active.adapter; + if (active) { + active.adapter.close(); + active.unsubscribe(); + active.adapter.unmount(); + active = null; + } + const { adapter } = { adapter: entry.instance.viewer }; + const unsubscribe = adapter.onPose(applyPose); + active = { id: entry.def.id, adapter, unsubscribe }; + await adapter.mount(state.street.host); + adapter.setRenderMode?.(state.street.renderMode); + return adapter; + } + + /** + * Open an image from one provider. Returns once the first pose is in; + * later poses stream through `state.street` as the user navigates. + */ + async function open(providerId, imageId, { frame = true } = {}) { + const entry = providerEntry(providerId); + if (!entry || !imageId) return; + if (!state.street.host) { + state.street.error = 'Open the Street Level panel to view imagery'; + notify(); + return; + } + const seq = ++openSeq; + const current = () => seq === openSeq; + Object.assign(state.street, { + loading: true, + error: null, + open: true, + providerId, + providerName: entry.def.name, + providerLabel: entry.def.label, + }); + notify(); + try { + const adapter = await mount(entry); + if (!current()) return; + await adapter.open(String(imageId)); + if (!current()) return; + if (frame && !state.street.follow) parts.follow.lookAtPosition(); + } catch (error) { + if (current()) + state.street.error = error?.message || 'Image could not be opened'; + } finally { + if (current()) state.street.loading = false; + notify(); + } + if (current() && !state.street.error) selectCurrentSequence(); + } + + /** Close the image; the mounted adapter stays warm for the next open. */ + function close() { + openSeq++; + active?.adapter.close(); + Object.assign(state.street, { + open: false, + follow: false, + providerId: null, + providerName: null, + providerLabel: null, + imageId: null, + position: null, + bearing: null, + tilt: null, + altitude: null, + isPano: false, + capturedAt: null, + sequenceId: null, + creator: null, + externalUrl: null, + loading: false, + error: null, + }); + parts.marker.clear(); + notify(); + } + + function attach(element) { + state.street.host = element || null; + if (element && state.street.open) resize(); + } + + function resize() { + try { + active?.adapter.resize(); + } catch { + /* no-op */ + } + } + + function setRenderMode(mode) { + state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; + try { + active?.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) { + try { + await entry.instance.viewer.prewarm?.(host); + } catch { + /* the real open reports errors */ + } + } + } + + /** Tear the mounted adapter down (layer disabled or destroyed). */ + function unmount() { + close(); + if (!active) return; + active.unsubscribe(); + active.adapter.unmount(); + active = null; + } + + return { + attach, + open, + close, + resize, + setRenderMode, + prewarm, + unmount, + activeProviderId: () => active?.id || null, + }; +} diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 253989f7a..b39f0a38b 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -26,7 +26,12 @@ test('Street Level is an ordinary collapsible GEV panel that starts collapsed', / - + + +
+ PROVIDERS +
@@ -106,9 +106,7 @@ - - - +
    @@ -127,12 +125,12 @@ -
    +