Files
gods-eye-view/src/layers/streetLevel/viewerHost.js
T
Said TurkseverandClaude Opus 5.5 b1efd22f02 feat(street-level): one imagery source at a time; IC8 review fixes
- One provider is lit at a time: lighting a chip darkens the others, and a
  chip click records every registered provider's switch in one update so
  the share link keeps the swap (also off Google 3D, where Street View is
  not listed). The layer starts with the first default-on provider
  (Mapillary, free); params that light several keep the one already on,
  so reapplying them never flips (litByParams). The now-unreachable
  "several lit" chip paths are gone.
- Leaving Google 3D during a Street View lookup ends it without an error
  or a stuck loading state (openImage's early return resets it; the
  lookup loop rechecks availability).
- A source swap during a lookup no longer reports "no imagery".
- Street View lit off Google 3D says the panel needs the Google 3D map.
- Comments trimmed to the why; keySetupCore.test.mjs back to upstream's
  formatting plus the one new test (was a 551-line reformat).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:10:58 +01:00

248 lines
8.0 KiB
JavaScript

import { freshStreet } from './state.js';
/**
* Owns the panel's viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street`, the marker and the follow camera.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
let active = null;
/** @type {{id: string, promise: Promise<object>}|null} */
let mounting = null;
let openSeq = 0;
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
const warmed = new Map();
function notify() {
state.notify?.();
}
function providerEntry(providerId) {
return state.providers.get(providerId) || null;
}
function applyPose(pose) {
if (!active || !state.street.open || 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,
capturedAtPrecision:
pose.capturedAtPrecision === 'month' ? 'month' : 'day',
title: pose.title || null,
sequenceId: pose.sequenceId ?? null,
creator: pose.creator || null,
externalUrl: pose.externalUrl || null,
});
parts.marker.set(
state.street.position,
state.street.bearing,
providerEntry(pose.providerId)?.def.marker,
);
parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its 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);
}
/**
* Mount a provider's adapter. It becomes `active` only on success, so a
* failed mount is retried on the next open; concurrent opens share one.
*/
function mount(entry) {
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
if (mounting?.id === entry.def.id) return mounting.promise;
if (active) {
active.adapter.close();
active.unsubscribe();
active.adapter.unmount();
active = null;
}
// A prewarmed viewer would sit under this one, holding its WebGL context.
for (const [id, warm] of warmed) {
if (id === entry.def.id) continue;
warmed.delete(id);
warm.unmount();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
await adapter.mount(state.street.host);
if (mounting?.promise !== promise) {
// Unmounted (layer off, provider switched) while loading. A newer
// mount of this provider shares the adapter and its viewer: tearing
// it down here would leave that mount active with no viewer.
const reused =
active?.id === entry.def.id || mounting?.id === entry.def.id;
if (!reused) adapter.unmount();
throw new Error('Street-level viewer was closed');
}
// Listen only once current: an outdated mount releasing the same
// `applyPose` would otherwise unhook the mount that replaced it.
const unsubscribe = adapter.onPose(applyPose);
active = { id: entry.def.id, adapter, unsubscribe };
warmed.delete(entry.def.id);
adapter.setRenderMode?.(state.street.renderMode);
return adapter;
})();
mounting = { id: entry.def.id, promise };
promise.then(
() => {
if (mounting?.promise === promise) mounting = null;
},
() => {
if (mounting?.promise === promise) mounting = null;
},
);
return promise;
}
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
async function open(providerId, imageId, { frame = true } = {}) {
const entry = providerEntry(providerId);
if (!entry || !imageId) return false;
if (!state.street.host) {
// A nearest-image lookup may have set it; nothing is loading now.
state.street.loading = false;
state.street.error = 'Open the Street Level panel to view imagery';
notify();
return false;
}
const seq = ++openSeq;
const current = () => seq === openSeq;
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket =
frame && !state.street.follow ? parts.follow.beginFraming() : null;
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 false;
// Per open: close() resets it, and a reopen skips mount().
state.street.renderModes = typeof adapter.setRenderMode === 'function';
await adapter.open(String(imageId));
if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
} 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) return false;
selectCurrentSequence();
return true;
}
/** Close the image and stop any framing flight; the adapter stays warm. */
function close() {
openSeq++;
parts.follow.cancelFraming();
active?.adapter.close();
Object.assign(state.street, freshStreet());
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) {
const adapter = entry.instance.viewer;
if (!adapter.prewarm || !entry.on) continue;
// Another provider's viewer is showing in the host: do not build under it.
if (active && active.id !== entry.def.id) continue;
try {
await adapter.prewarm(host);
} catch {
/* the real open reports errors */
}
if (active?.id === entry.def.id || mounting?.id === entry.def.id)
continue;
// Switched off, or another viewer took the host meanwhile.
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
}
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
function unmount(providerId) {
if (
!providerId ||
active?.id === providerId ||
mounting?.id === providerId
) {
close();
mounting = null;
if (active) {
active.unsubscribe();
active.adapter.unmount();
active = null;
}
}
for (const [id, adapter] of warmed) {
if (providerId && id !== providerId) continue;
warmed.delete(id);
adapter.unmount();
}
}
return {
attach,
open,
close,
resize,
setRenderMode,
prewarm,
unmount,
};
}