Merge origin/main: keep the provider registry, slim it

main grew the Street Level provider registry while this branch removed it
(#982–#985: injected providers, an empty composition, the `r` switch field).
The registry, params, uiState and pick router come back as main has them and
the slimmed layer sits behind the provider contract:

- The core (index.js) drives providers as on main, without surface modes,
  ground casting, FOLLOW, prewarm and nearest-image lookups. A provider's key
  status is asked with a 10 s timeout and asked again on a switch-on while no
  key is known.
- Mapillary is mapillaryProvider.js over the slim coverage, sequences and
  viewer modules, which read the filter and the active flag from the provider
  context.
- The viewer host mounts one provider adapter at a time, no prewarm.
- The panel has a chip per provider again (the pill switches the layer);
  no legend, EXPAND stays the Fullscreen API.
- The contract drops what no provider implements: groundCaster, meshSampler,
  getSurface/setSurface, nearestImage, prewarm, reportError, the legend and
  surface fields of the UI snapshot.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-13 13:24:25 +04:00
commit c29ccd55d6
103 files changed
+5469 -522

No files matched your search

+186 -134
View File
@@ -1,61 +1,55 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { normalizeFilter, sameFilter } from './filter.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import {
keyStatusLabel,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
import { createCoverage } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Longest wait for the server's key status; past it, the bundle's token decides. */
/** Longest wait for a provider's key status; past it, the provider decides. */
const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Coverage as the panel sees it while the `mapillary` switch is off. */
const COVERAGE_OFF = Object.freeze({
count: 0,
loading: false,
hint: '',
error: null,
keyRequired: false,
keyRejected: false,
});
/**
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
* sequence's image cones, and the photo viewer in the panel.
* @param {{source: object, services?: object, photoViewer?: object}} options
* `source` is source.js or a stand-in; `photoViewer` stands in for the
* MapillaryJS adapter (viewer.js).
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share: the filter and switches, the photo viewer
* host, the position marker and the click handler. Providers own coverage,
* sequences and their viewer.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/
export function createStreetLevelLayer({
source,
providers: definitions,
services = {},
photoViewer = null,
}) {
const defs = validateProviders(definitions);
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.credits = createCredits();
parts.marker = createMarker(context);
parts.framing = createCameraFraming(context);
parts.coverage = createCoverage({ state, source });
parts.sequences = createSequences({ state, source, parts });
parts.viewerHost = createViewerHost({
state,
parts,
adapter:
photoViewer || createMapillaryViewer({ source, render: services.render }),
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
});
parts.openImage = openImage;
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);
let notifyQueued = false;
@@ -76,127 +70,184 @@ export function createStreetLevelLayer({
}
state.notify = notify;
/** The key-status request in flight; overlapping checks share it. */
let keyCheck = null;
/** Coverage waits for the key status: no tile requests without a key. */
function checkKey() {
keyCheck ||= (async () => {
let configured;
try {
// A hung server must not hold LOADING, or the next check, forever.
const signal = AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS);
configured = (await source.getStatus({ signal }))?.configured === true;
} catch {
configured = source.hasToken?.() === true;
}
parts.coverage.setKeyStatus(configured);
})().finally(() => {
keyCheck = null;
function providerContext(entry) {
return Object.freeze({
services: state.services,
/** Resolved against now, so "since N days" keeps up with the clock. */
getFilter: () => resolveFilter(state.filter),
/** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
},
});
return keyCheck;
}
/** The on-globe credit while the layer draws: CC BY-SA imagery needs it. */
let credit = null;
function showCredit(viewer) {
if (credit || !viewer?.creditDisplay) return;
credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
viewer.creditDisplay.addStaticCredit(credit);
}
function hideCredit(viewer) {
if (!credit) return;
viewer?.creditDisplay?.removeStaticCredit?.(credit);
credit = null;
for (const def of defs) {
const entry = {
def,
instance: null,
on: true,
status: null,
/** The status request in flight; overlapping checks share it. */
statusCheck: null,
};
entry.instance = def.create(providerContext(entry));
state.providers.set(def.id, entry);
}
function startDrawing() {
const viewer = state.viewer;
if (!viewer) return;
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
showCredit(viewer);
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
/** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */
function checkStatus(entry) {
entry.statusCheck ||= (async () => {
try {
entry.status = await entry.instance.status({
signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS),
});
} catch {
entry.status = null;
}
notify();
})().finally(() => {
entry.statusCheck = null;
});
return entry.statusCheck;
}
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
// Without a known key, a switch-on asks again: the server may be back,
// or have a key now.
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
if (entry.status?.configured !== true) checkStatus(entry);
}
/** Close the photo, clear lines and cones, forget refusals. */
function stopDrawing() {
parts.viewerHost.unmount();
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.unblock();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
hideCredit(state.viewer);
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
}
/** The share link's `mapillary` switch: off, the layer draws nothing. */
function setProviderOn(on) {
if (state.providerOn === on) return;
state.providerOn = on;
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 (on) startDrawing();
else stopDrawing();
if (next) activate(entry);
else deactivate(entry);
}
notify();
}
function openImage(imageId) {
return parts.viewerHost.open(imageId);
return true;
}
function setCoverageFilter(next) {
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
state.filter = filter;
parts.coverage.rebuild();
parts.sequences.rerender();
const resolved = resolveFilter(filter);
for (const entry of state.providers.values())
entry.instance.setFilter(resolved);
notify();
}
function coverageStats() {
return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF;
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
name: entry.def.name,
label: entry.def.label,
on: entry.on,
configured: entry.status ? entry.status.configured === true : null,
// A rejected key gates the provider exactly like a missing one.
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
keyRejected: stats.keyRejected === true,
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
error: stats.error || null,
color: entry.def.colors.coverage,
};
});
}
/** The selected sequence; the provider showing the open image answers first. */
function sequenceSnapshot() {
const owner = state.providers.get(state.street.providerId);
const candidates = owner
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
: [...state.providers.values()];
for (const entry of candidates) {
const stats = entry.instance.sequenceStats?.();
if (stats?.selectedId || stats?.loading || stats?.error)
return {
providerId: entry.def.id,
selectedId: stats.selectedId || null,
images: stats.images || 0,
loading: stats.loading === true,
error: stats.error || null,
};
}
return {
providerId: null,
selectedId: null,
images: 0,
loading: false,
error: null,
};
}
function getUIState() {
const { host, ...street } = state.street;
const { keyRequired, keyRejected, ...coverage } = coverageStats();
return {
return composeUIState({
enabled: state.enabled,
providerOn: state.providerOn,
// A rejected key gates the layer exactly like a missing one.
keyRequired,
keyRejected,
filter: { ...state.filter },
coverage,
sequence: {
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
error: state.sequence.error,
},
filter: state.filter,
providers: providerSnapshots(),
street,
};
sequence: sequenceSnapshot(),
});
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: 'Mapillary',
source: defs.map((def) => def.name).join(' · '),
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: MAPILLARY_KEY_ID,
requiresKeyId: requiresKeyIdFor(defs),
/** Registered providers, in chip order. */
providerIds: defs.map((def) => def.id),
getSourceAvailability() {
const available = defs.length > 0;
return {
available,
source: STREET_LEVEL_LAYER_ID,
reason: available
? null
: 'No street-level imagery providers configured',
};
},
init(viewer) {
state.viewer = viewer;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
parts.sequences.ensureCollections(viewer);
parts.sequences.setVisible(false);
checkKey();
for (const entry of state.providers.values()) {
entry.instance.init(viewer);
checkStatus(entry);
}
console.log('[Data:StreetLevel] Initialized');
},
@@ -205,13 +256,15 @@ export function createStreetLevelLayer({
state.viewer = viewer;
parts.marker.setVisible(true);
parts.selection.install(viewer);
if (state.providerOn) startDrawing();
for (const entry of activeEntries()) activate(entry);
notify();
},
disable() {
state.enabled = false;
stopDrawing();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
parts.credits.hideAll(state.viewer);
parts.selection.uninstall();
parts.marker.setVisible(false);
notify();
@@ -223,7 +276,8 @@ export function createStreetLevelLayer({
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);
parts.framing.attachNavigation(null);
state.listeners.clear();
@@ -231,7 +285,8 @@ export function createStreetLevelLayer({
},
getStats() {
const coverage = coverageStats();
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
const keyLabel = keyStatusLabel(coverage);
let loadingLabel = '';
if (keyLabel) loadingLabel = keyLabel;
@@ -252,26 +307,22 @@ export function createStreetLevelLayer({
parts.framing.attachNavigation(navigation);
},
/**
* Share-link and stored state, in the codec's keys (src/data/layerState.js):
* the `mapillary` switch plus the filter.
*/
/** Share-link and stored state: provider switches plus the filter. */
getParams() {
return {
mapillary: state.providerOn,
pano: state.filter.pano,
sinceDays: state.filter.sinceDays,
};
return encodeParams({
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
filter: state.filter,
});
},
/** Unknown keys and malformed values are ignored, so any link applies. */
/** Unknown providers and malformed values are ignored, so any link applies. */
setParams(params = {}) {
const input = params && typeof params === 'object' ? params : {};
if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary);
const next = {};
if ('pano' in input) next.pano = input.pano;
if ('sinceDays' in input) next.sinceDays = input.sinceDays;
setCoverageFilter(next);
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;
},
@@ -282,15 +333,16 @@ export function createStreetLevelLayer({
return () => state.listeners.delete(listener);
},
getUIState,
/** The DOM element the photo viewer renders into. */
/** The DOM element provider viewers render into. */
attachViewerHost(element) {
parts.viewerHost.attach(element);
},
setProviderEnabled,
openImage,
/** Close the image and deselect its sequence on the map. */
closeViewer() {
parts.viewerHost.close();
parts.sequences.clearSelection();
parts.clearSequences();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(),