import { encodeIdSwitches } from '../../data/idSwitches.js'; export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); export function filtersOf(provider) { return provider?.filters ?? IMAGERY_FILTERS; } /** * The contract an imagery provider registers with the Street Level layer. * * @typedef {object} StreetLevelProvider * @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key. * @property {string} name Human name for captions and legends, e.g. 'Mapillary'. * @property {string} label Chip text and link label, e.g. 'MAPILLARY'. * @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless. * @property {string} pickPrefix Every primitive id the provider creates starts with it. * @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS. * @property {{html: string}} credit On-globe attribution while the provider is active. * @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image. * @property {string} [requiresMapStack] Usable only on this map stack. * @property {boolean} [defaultOn] Default true. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. * @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, terrain, scenePick (ground clicks need it). * @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up. * @property {() => boolean} isActive Layer enabled and this provider switched on. * @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth. * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. * @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise, openNearest: (point: {lon: number, lat: number}) => Promise, reportError: (message: string|null) => void}} actions * * @typedef {object} ProviderInstance * @property {() => Promise<{configured: boolean}>} status * @property {(viewer: object) => void} init Create collections, hidden. * @property {(viewer: object) => void} activate Start drawing coverage for the camera. * @property {() => void} deactivate Detach, clear coverage and selection, hide. * @property {(viewer: object) => void} destroy * @property {() => void} refreshCoverage * @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`). * @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode. * @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats * @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground. * @property {(sequenceId: string) => Promise} [selectSequence] * @property {() => void} [clearSequence] * @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats] * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise} nearestImage An aborted lookup gives up (it may reject with an AbortError). * @property {ViewerAdapter} viewer * * @typedef {object} GroundCaster * @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise} prepare Fetch the heights around [lon, lat] points; true when all are known. * @property {(lines: Array>, options?: {signal?: AbortSignal}) => Promise} prepareLines * @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height. * @property {(coords: Array<[number, number]>) => Array|null} castLine Densified [lon, lat, height, ...] or null. * * @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 {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide. * @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 {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day'). * @property {string|null} [title] A place name for the caption, e.g. the address. * @property {string|null} creator * @property {string|null} sequenceId * @property {string} externalUrl Deep link to the image on the provider's site. */ const REQUIRED = Object.freeze([ 'id', 'name', 'label', 'pickPrefix', 'colors', 'credit', 'create', ]); const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/; /** * Validate providers and freeze their order, which is also the chip order. * @param {Array} providers * @returns {ReadonlyArray} */ export function validateProviders(providers) { if (!Array.isArray(providers)) throw new TypeError('Street Level providers must be an array'); const ids = new Set(); const prefixes = []; for (const provider of providers) { const label = provider?.id ?? '(unnamed)'; for (const key of REQUIRED) if (provider?.[key] === undefined || provider?.[key] === null) throw new TypeError(`Street Level provider ${label} lacks ${key}`); if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id)) throw new TypeError(`Street Level provider id ${label} is not a slug`); if (ids.has(provider.id)) throw new TypeError(`Duplicate Street Level provider ${provider.id}`); if (typeof provider.create !== 'function') throw new TypeError( `Street Level provider ${label}: create is not a function`, ); if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix) throw new TypeError(`Street Level provider ${label} needs a pick prefix`); for (const other of prefixes) if ( other.startsWith(provider.pickPrefix) || provider.pickPrefix.startsWith(other) ) throw new TypeError( `Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`, ); if (!provider.credit?.html) throw new TypeError(`Street Level provider ${label} needs a credit`); if ( typeof provider.colors?.coverage !== 'string' || !provider.colors.coverage ) throw new TypeError( `Street Level provider ${label} needs a coverage colour`, ); if ( provider.marker !== undefined && !( provider.marker?.kind === 'view-cone' && typeof provider.marker.color === 'string' ) ) throw new TypeError( `Street Level provider ${label}: marker must be {kind: 'view-cone', color}`, ); if ( provider.filters !== undefined && !( Array.isArray(provider.filters) && provider.filters.every((key) => IMAGERY_FILTERS.includes(key)) ) ) throw new TypeError( `Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`, ); if ( provider.sinceBasis !== undefined && provider.sinceBasis !== 'capture' && provider.sinceBasis !== 'upload' ) throw new TypeError( `Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`, ); ids.add(provider.id); prefixes.push(provider.pickPrefix); } // Validate the complete bounded share field once, before any instance exists. try { encodeIdSwitches( providers .filter(({ id }) => id !== 'mapillary') .map(({ id }) => [id, true]), ); } catch (error) { if (!(error instanceof RangeError)) throw error; throw new RangeError( 'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers', ); } return Object.freeze([...providers]); } /** * The key id every provider shares, or null if any is keyless or they differ * (then each chip reports its own need). * @param {ReadonlyArray} providers * @returns {string|null} */ export function requiresKeyIdFor(providers) { const keys = new Set(providers.map((p) => p.requiresKeyId || null)); if (keys.size !== 1) return null; return [...keys][0]; }