mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(street-level): provider contract with Mapillary as the first provider
Street Level is now a provider-neutral layer modelled on the iD editor's photo overlay. The core owns what every imagery provider shares: the enable state and a switch per provider, the imagery filter (360°/flat and captured-since in relative days), one click handler routed by pick prefix, one viewer host that mounts whichever provider's viewer adapter owns the open image, the position marker, camera follow, one on-globe credit per active provider, and share-link parameters (one bit per provider plus the filter). Providers implement the contract in registry.js: coverage, sequences, nearest-image lookup and a viewer adapter emitting a common pose. Mapillary is the only registered provider; Google Street View, KartaView and Panoramax slot in beside it. Panel: a PROVIDERS chip row (one chip per registered provider, keyless chips read as errors), relative-day SINCE options, a provider-neutral caption whose link label follows the provider, and no vendor mark in the header. The panel inner is a rail scroller like CCTV's. Tests cover the registry, params codec, filter, pick router, UI-state composition, the provider definition and coverage-tile decoding; the QA harness switches the provider off and on and checks the filter. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
2dbc684fb1
commit
28e6a1faa7
41 files changed
+2460
-744
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user