mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
The browser gate is one pass over the remaining flows: rail strip and expand, coverage with fixtures, the ON/OFF pill, 360° and SINCE filters, KEY REJECTED, nearest photo with caption, × close, phone fit, keyless KEY REQUIRED on a second page and no page errors. Panel controls are pressed through the DOM; pick-point search, line/cone clicks, the framing-cancel, reload/share and status-route probes, the second viewport and pointer-settling retries are gone (unit tests cover them). Tests that pinned constants, copy, CSS or source text, or repeated another test, are removed or merged. CSS loses an unused variable and duplicate rules. Docs describe the slimmed feature; unused exports and the layer's dead setCoverageFilter/refreshCoverage surface are removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
244 lines
7.5 KiB
JavaScript
244 lines
7.5 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { imageConeGlyph } from '../../glyphs.js';
|
|
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
|
import { createHorizonCull, metresBetween } from '../../view.js';
|
|
import {
|
|
COLORS,
|
|
IMAGE_CONE_MIN_SPACING_M,
|
|
IMAGE_CONE_SIZE_PX,
|
|
PICK_PREFIX,
|
|
} from './policy.js';
|
|
|
|
const SPRITE_ID = 'street-level:mapillary-cones';
|
|
|
|
/** How many recently viewed sequences keep their image list in memory. */
|
|
const SEQUENCE_CACHE_SIZE = 40;
|
|
|
|
/** Normalize a graph image record into the shape the cones use. */
|
|
function normalizeSequenceImage(record) {
|
|
const coordinates = record?.geometry?.coordinates;
|
|
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
|
return {
|
|
id: String(record.id),
|
|
lon: Number(coordinates[0]),
|
|
lat: Number(coordinates[1]),
|
|
compassAngle: Number(record.compass_angle) || 0,
|
|
capturedAt: Number(record.captured_at) || 0,
|
|
isPano: record.is_pano === true,
|
|
altitude: Number.isFinite(record.computed_altitude)
|
|
? record.computed_altitude
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/** Drop images closer than the spacing to the previous kept one. */
|
|
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
|
const kept = [];
|
|
let last = null;
|
|
for (const image of images) {
|
|
if (!last || metresBetween(last, image) >= spacingM) {
|
|
kept.push(image);
|
|
last = image;
|
|
}
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
/** Image cones for one selected sequence. */
|
|
export function createSequences({ state, source, parts }) {
|
|
const { render, sprites } = state.services;
|
|
|
|
function requestRender() {
|
|
render?.governorRequestRender?.('mapillary-sequence');
|
|
}
|
|
|
|
function notify() {
|
|
state.notify?.();
|
|
}
|
|
|
|
/** Show a sequence error, or withdraw it (null); see `reportSequenceError`. */
|
|
function reportError(message) {
|
|
parts.reportSequenceError?.(message);
|
|
}
|
|
|
|
/** Drawn cone billboards. */
|
|
let cones = [];
|
|
|
|
// The cones skip the depth test: hide the ones behind the globe.
|
|
const horizon = createHorizonCull({
|
|
getViewer: () => state.viewer,
|
|
items: () => cones,
|
|
onChange: requestRender,
|
|
});
|
|
|
|
function ensureCollections(viewer) {
|
|
if (state.sequence.collection) return;
|
|
state.sequence.collection = new Cesium.BillboardCollection({
|
|
scene: viewer.scene,
|
|
});
|
|
viewer.scene.primitives.add(state.sequence.collection);
|
|
sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection);
|
|
}
|
|
|
|
function clearCones() {
|
|
state.sequence.collection?.removeAll();
|
|
state.sequence.images = [];
|
|
cones = [];
|
|
horizon.stop();
|
|
}
|
|
|
|
function renderCones(images) {
|
|
const collection = state.sequence.collection;
|
|
if (!collection) return;
|
|
collection.removeAll();
|
|
cones = [];
|
|
// One colour per source; 360° images keep the ring shape.
|
|
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
|
|
const ring = imageConeGlyph({
|
|
size: 32,
|
|
color: COLORS.coverage,
|
|
pano: true,
|
|
});
|
|
// Resolved now, so a "since N days" window keeps up with the clock.
|
|
const filter = resolveFilter(state.filter);
|
|
for (const image of images) {
|
|
if (!passesImageryFilter(image, filter)) continue;
|
|
const billboard = collection.add({
|
|
id: `${PICK_PREFIX.image}${image.id}`,
|
|
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
|
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
image: image.isPano ? ring : cone,
|
|
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
|
|
width: IMAGE_CONE_SIZE_PX,
|
|
height: IMAGE_CONE_SIZE_PX,
|
|
rotation: -Cesium.Math.toRadians(image.compassAngle),
|
|
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
|
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
|
});
|
|
cones.push(billboard);
|
|
}
|
|
horizon.update();
|
|
requestRender();
|
|
}
|
|
|
|
function remember(sequenceId, images) {
|
|
const { cache } = state.sequence;
|
|
cache.delete(sequenceId);
|
|
cache.set(sequenceId, images);
|
|
while (cache.size > SEQUENCE_CACHE_SIZE)
|
|
cache.delete(cache.keys().next().value);
|
|
}
|
|
|
|
/** Select a sequence: highlight its line and load its image cones. */
|
|
async function select(sequenceId) {
|
|
if (!sequenceId || !state.viewer) return;
|
|
// Already on screen, or already on its way.
|
|
if (
|
|
state.sequence.selectedId === sequenceId &&
|
|
(state.sequence.images.length || state.sequence.abort)
|
|
)
|
|
return;
|
|
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
|
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
|
state.sequence.abort?.abort();
|
|
state.sequence.selectedId = sequenceId;
|
|
parts.coverage.recolorSequence(sequenceId, true);
|
|
const cached = state.sequence.cache.get(sequenceId);
|
|
if (cached) {
|
|
state.sequence.abort = null;
|
|
state.sequence.loading = false;
|
|
state.sequence.images = cached;
|
|
renderCones(cached);
|
|
reportError(null);
|
|
notify();
|
|
return;
|
|
}
|
|
// The previous sequence's cones must not stay clickable under the new
|
|
// highlight while this one loads, nor after its load fails.
|
|
clearCones();
|
|
requestRender();
|
|
const controller = new AbortController();
|
|
state.sequence.abort = controller;
|
|
state.sequence.loading = true;
|
|
notify();
|
|
try {
|
|
const records = await source.getSequenceImages(sequenceId, {
|
|
signal: controller.signal,
|
|
});
|
|
if (controller.signal.aborted) return;
|
|
const images = thinImages(
|
|
records
|
|
.map(normalizeSequenceImage)
|
|
.filter(Boolean)
|
|
.sort((a, b) => a.capturedAt - b.capturedAt),
|
|
);
|
|
remember(sequenceId, images);
|
|
state.sequence.images = images;
|
|
renderCones(images);
|
|
reportError(null);
|
|
} catch (error) {
|
|
if (!controller.signal.aborted) {
|
|
// Nothing to show: drop the highlight, so a click asks again.
|
|
parts.coverage.recolorSequence(sequenceId, false);
|
|
state.sequence.selectedId = null;
|
|
reportError(error?.message || 'Sequence images unavailable');
|
|
}
|
|
} finally {
|
|
if (state.sequence.abort === controller) {
|
|
state.sequence.loading = false;
|
|
state.sequence.abort = null;
|
|
}
|
|
notify();
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
state.sequence.abort?.abort();
|
|
state.sequence.abort = null;
|
|
if (state.sequence.selectedId)
|
|
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
|
state.sequence.selectedId = null;
|
|
state.sequence.loading = false;
|
|
clearCones();
|
|
// An error about the sequence goes with it.
|
|
reportError(null);
|
|
requestRender();
|
|
notify();
|
|
}
|
|
|
|
/** Re-draw the current sequence's cones (after an imagery filter change). */
|
|
function rerender() {
|
|
if (state.sequence.images.length) renderCones(state.sequence.images);
|
|
}
|
|
|
|
function setVisible(visible) {
|
|
if (state.sequence.collection) state.sequence.collection.show = visible;
|
|
requestRender();
|
|
}
|
|
|
|
function destroy(viewer) {
|
|
state.sequence.abort?.abort();
|
|
horizon.stop();
|
|
cones = [];
|
|
const collection = state.sequence.collection;
|
|
if (collection) {
|
|
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
|
viewer?.scene?.primitives?.remove(collection);
|
|
state.sequence.collection = null;
|
|
}
|
|
state.sequence.images = [];
|
|
state.sequence.selectedId = null;
|
|
}
|
|
|
|
return {
|
|
ensureCollections,
|
|
select,
|
|
clearSelection,
|
|
rerender,
|
|
setVisible,
|
|
destroy,
|
|
};
|
|
}
|