mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- No pointer cursor over coverage lines (no other layer has one); a click test replaces the hover tests. - No legend: one source, one colour. - The on-globe credit lives in index.js; credits.js is gone. - Billboard glyphs are SVG data URLs with the same geometry, so tests need no canvas stub. - The tile cache keeps the 120 most recently used tiles, same 24 h TTL, instead of a byte budget; listTileLayers was test-only. - providers/mapillary/ is flattened into the layer and its policy merged into one policy.js; the source's method list is a literal in the catalog. - One fakeCesiumViewer for the layer tests. - The fixture gate keeps the steps the unit tests do not cover: coverage and credit, a rejected key, line -> cone -> photo, close, a keyless page. Co-Authored-By: Claude Fable 5.1 <[email protected]>
300 lines
8.9 KiB
JavaScript
300 lines
8.9 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { createState } from './state.js';
|
|
import { createMarker } from './marker.js';
|
|
import { createCameraFraming } from './cameraFraming.js';
|
|
import { createSelection } from './selection.js';
|
|
import { createViewerHost } from './viewerHost.js';
|
|
import { normalizeFilter, sameFilter } from './filter.js';
|
|
import {
|
|
keyStatusLabel,
|
|
MAPILLARY_CREDIT_HTML,
|
|
MAPILLARY_KEY_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. */
|
|
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).
|
|
*/
|
|
export function createStreetLevelLayer({
|
|
source,
|
|
services = {},
|
|
photoViewer = null,
|
|
}) {
|
|
const state = createState({ services });
|
|
const parts = {};
|
|
const context = { state, parts };
|
|
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.openImage = openImage;
|
|
parts.selection = createSelection(context);
|
|
|
|
let notifyQueued = false;
|
|
function notify() {
|
|
if (notifyQueued) return;
|
|
notifyQueued = true;
|
|
queueMicrotask(() => {
|
|
notifyQueued = false;
|
|
const snapshot = getUIState();
|
|
for (const listener of [...state.listeners]) {
|
|
try {
|
|
listener(snapshot);
|
|
} catch (error) {
|
|
console.warn('[Data:StreetLevel] listener error:', error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
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;
|
|
});
|
|
return keyCheck;
|
|
}
|
|
|
|
/** The on-globe credit while the layer draws: CC BY-SA imagery needs it. */
|
|
let credit = null;
|
|
function showCredit(viewer) {
|
|
if (credit || !viewer?.creditDisplay) return;
|
|
credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
|
|
viewer.creditDisplay.addStaticCredit(credit);
|
|
}
|
|
function hideCredit(viewer) {
|
|
if (!credit) return;
|
|
viewer?.creditDisplay?.removeStaticCredit?.(credit);
|
|
credit = null;
|
|
}
|
|
|
|
function startDrawing() {
|
|
const viewer = state.viewer;
|
|
if (!viewer) return;
|
|
parts.sequences.setVisible(true);
|
|
parts.coverage.attach(viewer);
|
|
showCredit(viewer);
|
|
// Without a known key, a switch-on asks again: the server may be back,
|
|
// or have a key now.
|
|
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
|
|
/** The share link's `mapillary` switch: off, the layer draws nothing. */
|
|
function setProviderOn(on) {
|
|
if (state.providerOn === on) return;
|
|
state.providerOn = on;
|
|
if (state.enabled) {
|
|
if (on) startDrawing();
|
|
else stopDrawing();
|
|
}
|
|
notify();
|
|
}
|
|
|
|
function openImage(imageId) {
|
|
return parts.viewerHost.open(imageId);
|
|
}
|
|
|
|
function setCoverageFilter(next) {
|
|
const filter = normalizeFilter(next, state.filter);
|
|
if (sameFilter(filter, state.filter)) return;
|
|
state.filter = filter;
|
|
parts.coverage.rebuild();
|
|
parts.sequences.rerender();
|
|
notify();
|
|
}
|
|
|
|
function coverageStats() {
|
|
return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF;
|
|
}
|
|
|
|
function getUIState() {
|
|
const { host, ...street } = state.street;
|
|
const { keyRequired, keyRejected, ...coverage } = coverageStats();
|
|
return {
|
|
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,
|
|
},
|
|
street,
|
|
};
|
|
}
|
|
|
|
const layer = {
|
|
id: STREET_LEVEL_LAYER_ID,
|
|
name: 'Street Level',
|
|
icon: '📷',
|
|
source: 'Mapillary',
|
|
updateInterval: 0,
|
|
statsRefreshInterval: 1000,
|
|
requiresKeyId: MAPILLARY_KEY_ID,
|
|
|
|
init(viewer) {
|
|
state.viewer = viewer;
|
|
parts.marker.ensure(viewer);
|
|
parts.marker.setVisible(false);
|
|
parts.sequences.ensureCollections(viewer);
|
|
parts.sequences.setVisible(false);
|
|
checkKey();
|
|
console.log('[Data:StreetLevel] Initialized');
|
|
},
|
|
|
|
enable(viewer) {
|
|
state.enabled = true;
|
|
state.viewer = viewer;
|
|
parts.marker.setVisible(true);
|
|
parts.selection.install(viewer);
|
|
if (state.providerOn) startDrawing();
|
|
notify();
|
|
},
|
|
|
|
disable() {
|
|
state.enabled = false;
|
|
stopDrawing();
|
|
parts.selection.uninstall();
|
|
parts.marker.setVisible(false);
|
|
notify();
|
|
},
|
|
|
|
async update() {
|
|
return state.enabled;
|
|
},
|
|
|
|
destroy(viewer = state.viewer) {
|
|
layer.disable();
|
|
parts.sequences.destroy(viewer);
|
|
parts.marker.destroy(viewer);
|
|
parts.framing.attachNavigation(null);
|
|
state.listeners.clear();
|
|
state.viewer = null;
|
|
},
|
|
|
|
getStats() {
|
|
const coverage = coverageStats();
|
|
const keyLabel = keyStatusLabel(coverage);
|
|
let loadingLabel = '';
|
|
if (keyLabel) loadingLabel = keyLabel;
|
|
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
|
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
|
return {
|
|
count: coverage.count,
|
|
loading: coverage.loading,
|
|
keyRequired: coverage.keyRequired,
|
|
// A rejected key's message names the fix; a missing one says so.
|
|
error: keyLabel && !coverage.keyRejected ? keyLabel : coverage.error,
|
|
loadingLabel,
|
|
};
|
|
},
|
|
|
|
/** A photo frames itself only if it still owns the camera once loaded. */
|
|
attachNavigation(navigation) {
|
|
parts.framing.attachNavigation(navigation);
|
|
},
|
|
|
|
/**
|
|
* Share-link and stored state, in the codec's keys (src/data/layerState.js):
|
|
* the `mapillary` switch plus the filter.
|
|
*/
|
|
getParams() {
|
|
return {
|
|
mapillary: state.providerOn,
|
|
pano: state.filter.pano,
|
|
sinceDays: state.filter.sinceDays,
|
|
};
|
|
},
|
|
|
|
/** Unknown keys and malformed values are ignored, so any link applies. */
|
|
setParams(params = {}) {
|
|
const input = params && typeof params === 'object' ? params : {};
|
|
if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary);
|
|
const next = {};
|
|
if ('pano' in input) next.pano = input.pano;
|
|
if ('sinceDays' in input) next.sinceDays = input.sinceDays;
|
|
setCoverageFilter(next);
|
|
return true;
|
|
},
|
|
|
|
// ── Public surface used by the panel ────────────────────────────────
|
|
subscribe(listener) {
|
|
if (typeof listener !== 'function') return () => {};
|
|
state.listeners.add(listener);
|
|
return () => state.listeners.delete(listener);
|
|
},
|
|
getUIState,
|
|
/** The DOM element the photo viewer renders into. */
|
|
attachViewerHost(element) {
|
|
parts.viewerHost.attach(element);
|
|
},
|
|
openImage,
|
|
/** Close the image and deselect its sequence on the map. */
|
|
closeViewer() {
|
|
parts.viewerHost.close();
|
|
parts.sequences.clearSelection();
|
|
},
|
|
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
|
resizeViewer: () => parts.viewerHost.resize(),
|
|
};
|
|
return layer;
|
|
}
|