feat(foundry): add map camera spiral controls

This commit is contained in:
Codex
2026-07-16 12:47:48 +03:00
parent 5e8c1cc3fc
commit e96401358b
11 changed files with 802 additions and 43 deletions
+375 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import {
Cartesian2,
Cartesian3,
@@ -10,6 +10,7 @@ import {
Cesium3DTileStyle,
CesiumTerrainProvider,
CallbackProperty,
Cartographic,
ConstantPositionProperty,
CustomDataSource,
DefaultProxy,
@@ -27,6 +28,7 @@ import {
PolygonHierarchy,
PointGraphics,
Resource,
sampleTerrainMostDetailed,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
SunLight,
@@ -35,9 +37,12 @@ import {
} from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
import { spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
type Position = [number, number, number?];
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
type PinPresentation = {
variant: "elevated-spike";
stemHeightMeters: number;
@@ -177,6 +182,50 @@ export type MapCameraView = {
roll: number;
};
export type CameraSpiralConfig = {
heightAboveGroundMeters: number;
speedMetersPerSecond: number;
pitchMetersPerTurn: number;
};
export type CameraSpiralState = {
running: boolean;
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error";
};
export type CesiumMapRendererHandle = {
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
getCameraView: () => MapCameraView | null;
};
type TerrainRouteSample = {
distanceMeters: number;
heightMeters: number;
};
type SpiralSession = {
viewer: Viewer;
origin: GeodeticRadians;
initialHeading: number;
pitch: number;
roll: number;
config: CameraSpiralConfig;
surfaceDistanceMeters: number;
lastTimestamp: number | null;
pendingElapsedSeconds: number;
requestId: number | null;
previousCameraInputsEnabled: boolean;
terrainProvider: CesiumTerrainProvider | null;
terrainSamples: TerrainRouteSample[];
terrainSamplePending: boolean;
terrainSampleExhausted: boolean;
terrainSampleFailures: number;
terrainRequestGeneration: number;
terrainRetryAt: number;
terrainSampleSpacingMeters: number;
};
const toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height);
const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
const accent = Color.fromCssColorString("#ff2f92");
@@ -211,6 +260,21 @@ function getCameraView(viewer: Viewer): MapCameraView {
};
}
function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMeters: number) {
if (samples.length === 0) return null;
if (distanceMeters === samples[0].distanceMeters) return samples[0].heightMeters;
for (let index = 1; index < samples.length; index += 1) {
const upper = samples[index];
if (distanceMeters > upper.distanceMeters) continue;
const lower = samples[index - 1];
const span = upper.distanceMeters - lower.distanceMeters;
if (span <= 0) return upper.heightMeters;
const progress = clamp((distanceMeters - lower.distanceMeters) / span, 0, 1);
return lower.heightMeters + (upper.heightMeters - lower.heightMeters) * progress;
}
return null;
}
function addFixtureEntities(viewer: Viewer) {
const styleProfiles = new Map<string, MapStyleProfile>(
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
@@ -540,25 +604,29 @@ function applyPresentation(
viewer.scene.requestRender();
}
export function CesiumMapRenderer({
onSelect,
onGatewayHealth,
onProviderStatus,
onCameraChange,
onCacheRefreshConsumed,
initialCamera,
presentation,
runtimeBindings = [],
}: {
export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
onSelect?: (entityId: string) => void;
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
onProviderStatus?: (status: MapProviderStatus) => void;
onCameraChange?: (camera: MapCameraView) => void;
onCacheRefreshConsumed?: () => void;
onReadyChange?: (ready: boolean) => void;
onSpiralStateChange?: (state: CameraSpiralState) => void;
initialCamera?: MapCameraView;
presentation: MapPresentation;
runtimeBindings?: MapRuntimeBinding[];
}) {
}>(function CesiumMapRenderer({
onSelect,
onGatewayHealth,
onProviderStatus,
onCameraChange,
onCacheRefreshConsumed,
onReadyChange,
onSpiralStateChange,
initialCamera,
presentation,
runtimeBindings = [],
}, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer | null>(null);
@@ -572,6 +640,9 @@ export function CesiumMapRenderer({
const onSelectRef = useRef(onSelect);
const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
const onReadyChangeRef = useRef(onReadyChange);
const onSpiralStateChangeRef = useRef(onSpiralStateChange);
const spiralSessionRef = useRef<SpiralSession | null>(null);
useEffect(() => {
onSelectRef.current = onSelect;
@@ -585,11 +656,295 @@ export function CesiumMapRenderer({
onCacheRefreshConsumedRef.current = onCacheRefreshConsumed;
}, [onCacheRefreshConsumed]);
useEffect(() => {
onReadyChangeRef.current = onReadyChange;
}, [onReadyChange]);
useEffect(() => {
onSpiralStateChangeRef.current = onSpiralStateChange;
}, [onSpiralStateChange]);
const stopSpiralAnimation = useCallback((reason: CameraSpiralState["reason"] = "stopped") => {
const session = spiralSessionRef.current;
if (!session) return;
spiralSessionRef.current = null;
if (session.requestId !== null) window.cancelAnimationFrame(session.requestId);
if (!session.viewer.isDestroyed()) {
session.viewer.scene.screenSpaceCameraController.enableInputs = session.previousCameraInputsEnabled;
session.viewer.scene.requestRender();
rebuildGridRef.current?.();
onCameraChangeRef.current?.(getCameraView(session.viewer));
}
onSpiralStateChangeRef.current?.({ running: false, reason });
}, []);
const startSpiralAnimation = useCallback((input: CameraSpiralConfig) => {
const viewer = viewerRef.current;
const position = viewer?.camera.positionCartographic;
if (!viewer || viewer.isDestroyed() || !position) return false;
if (![input.heightAboveGroundMeters, input.speedMetersPerSecond, input.pitchMetersPerTurn].every(Number.isFinite)) return false;
const terrainProvider = presentationRef.current.terrainEnabled ? terrainRef.current?.world ?? null : null;
if (presentationRef.current.terrainEnabled && !terrainProvider) return false;
const config: CameraSpiralConfig = {
heightAboveGroundMeters: clamp(input.heightAboveGroundMeters, 10, 100_000),
speedMetersPerSecond: clamp(input.speedMetersPerSecond, 1, 5_000),
pitchMetersPerTurn: clamp(input.pitchMetersPerTurn, 20, 100_000),
};
stopSpiralAnimation("renderer_restarted");
viewer.camera.cancelFlight();
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
const controller = viewer.scene.screenSpaceCameraController;
const session: SpiralSession = {
viewer,
origin: { longitude: position.longitude, latitude: position.latitude },
initialHeading: Number.isFinite(viewer.camera.heading) ? viewer.camera.heading : 0,
pitch: clamp(Number.isFinite(viewer.camera.pitch) ? viewer.camera.pitch : -Math.PI / 4, -Math.PI / 2 + 0.01, -0.05),
roll: 0,
config,
surfaceDistanceMeters: 0,
lastTimestamp: null,
pendingElapsedSeconds: 0,
requestId: null,
previousCameraInputsEnabled: controller.enableInputs,
terrainProvider,
terrainSamples: [],
terrainSamplePending: false,
terrainSampleExhausted: false,
terrainSampleFailures: 0,
terrainRequestGeneration: 0,
terrainRetryAt: 0,
terrainSampleSpacingMeters: clamp(config.speedMetersPerSecond / 30, 10, 250),
};
controller.enableInputs = false;
spiralSessionRef.current = session;
const groundHeightAtDistance = (distanceMeters: number) => {
if (!session.terrainProvider) return 0;
// Only the most-detailed sampled route is stable enough for AGL.
// globe.getHeight() follows the currently rendered LOD and can jump as
// tiles refine, so it must not drive the camera altitude.
return interpolateTerrainRouteHeight(session.terrainSamples, distanceMeters);
};
const applyFrame = (frame: ReturnType<typeof spiralSurfaceFrame>, rawGround: number) => {
if (spiralSessionRef.current !== session || viewer.isDestroyed()) return;
const scene = viewer.scene as unknown as {
verticalExaggeration?: number;
verticalExaggerationRelativeHeight?: number;
};
const terrainActive = Boolean(session.terrainProvider);
const exaggeration = terrainActive && Number.isFinite(scene.verticalExaggeration) ? Number(scene.verticalExaggeration) : 1;
const relativeHeight = terrainActive && Number.isFinite(scene.verticalExaggerationRelativeHeight)
? Number(scene.verticalExaggerationRelativeHeight)
: 0;
const renderedGround = relativeHeight + (rawGround - relativeHeight) * exaggeration;
viewer.camera.setView({
destination: Cartesian3.fromRadians(
frame.longitude,
frame.latitude,
renderedGround + session.config.heightAboveGroundMeters,
),
orientation: {
heading: frame.tangentHeading,
pitch: session.pitch,
roll: session.roll,
},
});
viewer.scene.requestRender();
};
const requestTerrainSamples = () => {
if (!session.terrainProvider || session.terrainSamplePending || spiralSessionRef.current !== session) return;
if (Date.now() < session.terrainRetryAt) return;
const lastSample = session.terrainSamples.at(-1);
// Prefetch before the camera reaches the buffer tail. At normal and
// high speeds this leaves roughly three seconds for Gateway/VPN latency.
if (lastSample && lastSample.distanceMeters - session.surfaceDistanceMeters > session.terrainSampleSpacingMeters * 90) return;
const startDistance = lastSample ? lastSample.distanceMeters + session.terrainSampleSpacingMeters : 0;
const distances: number[] = [];
const positions: Cartographic[] = [];
let reachedExtent = false;
for (let index = 0; index < 120; index += 1) {
const distanceMeters = startDistance + index * session.terrainSampleSpacingMeters;
try {
const frame = spiralSurfaceFrame(
session.origin,
session.initialHeading,
distanceMeters,
session.config.pitchMetersPerTurn,
);
distances.push(distanceMeters);
positions.push(new Cartographic(frame.longitude, frame.latitude, 0));
} catch (error) {
if (error instanceof Error && error.message === "spiral_extent_limit") {
reachedExtent = true;
session.terrainSampleExhausted = true;
} else {
stopSpiralAnimation("spiral_runtime_error");
}
break;
}
}
if (positions.length === 0 || spiralSessionRef.current !== session) {
if (reachedExtent && spiralSessionRef.current === session) stopSpiralAnimation("spiral_extent_limit");
return;
}
session.terrainSamplePending = true;
const requestGeneration = ++session.terrainRequestGeneration;
let timeoutId = 0;
const timeout = new Promise<never>((_resolve, reject) => {
timeoutId = window.setTimeout(() => reject(new Error("terrain_sample_timeout")), TERRAIN_SAMPLE_TIMEOUT_MS);
});
void Promise.race([
sampleTerrainMostDetailed(session.terrainProvider, positions),
timeout,
]).then((sampledPositions) => {
window.clearTimeout(timeoutId);
if (
spiralSessionRef.current !== session
|| session.terrainRequestGeneration !== requestGeneration
|| viewer.isDestroyed()
) return;
const nextSamples = sampledPositions.map((sampledPosition, index) => {
if (!Number.isFinite(sampledPosition.height)) throw new Error("terrain_height_unavailable");
return {
distanceMeters: distances[index],
heightMeters: Number(sampledPosition.height),
};
});
session.terrainSamples = [...session.terrainSamples, ...nextSamples]
.filter((sample) => sample.distanceMeters >= session.surfaceDistanceMeters - session.terrainSampleSpacingMeters * 2);
session.terrainSamplePending = false;
session.terrainSampleFailures = 0;
session.terrainRetryAt = 0;
viewer.scene.requestRender();
}).catch(() => {
window.clearTimeout(timeoutId);
if (spiralSessionRef.current !== session || session.terrainRequestGeneration !== requestGeneration) return;
session.terrainSamplePending = false;
session.terrainSampleFailures += 1;
if (session.terrainSampleFailures >= 4) {
stopSpiralAnimation("terrain_sampling_error");
return;
}
const retryDelay = [500, 1_500, 4_000][session.terrainSampleFailures - 1] ?? 4_000;
session.terrainRetryAt = Date.now() + retryDelay;
viewer.scene.requestRender();
});
};
const tick = (timestamp: number) => {
if (spiralSessionRef.current !== session || viewer.isDestroyed()) return;
if (document.hidden) {
session.lastTimestamp = null;
session.pendingElapsedSeconds = 0;
} else if (session.lastTimestamp === null) {
session.lastTimestamp = timestamp;
} else {
const elapsedSeconds = Math.max(0, (timestamp - session.lastTimestamp) / 1000);
session.lastTimestamp = timestamp;
session.pendingElapsedSeconds += elapsedSeconds;
try {
let nextFrame: ReturnType<typeof spiralSurfaceFrame> | null = null;
let nextGround: number | null = null;
let substeps = 0;
while (session.pendingElapsedSeconds > 0.000_001 && substeps < MAX_SPIRAL_SUBSTEPS_PER_FRAME) {
const stepSeconds = Math.min(session.pendingElapsedSeconds, 1 / 30);
const candidateDistance = session.surfaceDistanceMeters + session.config.speedMetersPerSecond * stepSeconds;
const candidateFrame = spiralSurfaceFrame(
session.origin,
session.initialHeading,
candidateDistance,
session.config.pitchMetersPerTurn,
);
const candidateGround = groundHeightAtDistance(candidateDistance);
if (candidateGround === null) {
const lastTerrainSample = session.terrainSamples.at(-1);
if (session.terrainSampleExhausted && (!lastTerrainSample || candidateDistance > lastTerrainSample.distanceMeters)) {
throw new Error("spiral_extent_limit");
}
// Missing terrain is a loading state, never an ellipsoid-height
// fallback. Pause the route clock until a sampled buffer exists.
session.pendingElapsedSeconds = 0;
session.lastTimestamp = timestamp;
requestTerrainSamples();
break;
}
session.surfaceDistanceMeters = candidateDistance;
session.pendingElapsedSeconds -= stepSeconds;
nextFrame = candidateFrame;
nextGround = candidateGround;
substeps += 1;
}
if (nextFrame && nextGround !== null) applyFrame(nextFrame, nextGround);
requestTerrainSamples();
} catch (error) {
stopSpiralAnimation(error instanceof Error && error.message === "spiral_extent_limit"
? "spiral_extent_limit"
: "spiral_runtime_error");
return;
}
}
if (spiralSessionRef.current === session) session.requestId = window.requestAnimationFrame(tick);
};
try {
requestTerrainSamples();
const initialFrame = spiralSurfaceFrame(session.origin, session.initialHeading, 0, session.config.pitchMetersPerTurn);
const initialGround = groundHeightAtDistance(0);
if (initialGround !== null) applyFrame(initialFrame, initialGround);
} catch {
stopSpiralAnimation("spiral_runtime_error");
return false;
}
session.requestId = window.requestAnimationFrame(tick);
rebuildGridRef.current?.();
onSpiralStateChangeRef.current?.({ running: true });
return true;
}, [stopSpiralAnimation]);
useImperativeHandle(ref, () => ({
startSpiralAnimation,
stopSpiralAnimation,
getCameraView: () => {
const viewer = viewerRef.current;
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
},
}), [startSpiralAnimation, stopSpiralAnimation]);
useEffect(() => {
const stopForPageLeave = () => stopSpiralAnimation("stopped");
const resetHiddenClock = () => {
const session = spiralSessionRef.current;
if (!session || !document.hidden) return;
session.lastTimestamp = null;
session.pendingElapsedSeconds = 0;
};
window.addEventListener("pagehide", stopForPageLeave);
document.addEventListener("visibilitychange", resetHiddenClock);
return () => {
window.removeEventListener("pagehide", stopForPageLeave);
document.removeEventListener("visibilitychange", resetHiddenClock);
};
}, [stopSpiralAnimation]);
useEffect(() => {
presentationRef.current = presentation;
const activeSpiral = spiralSessionRef.current;
if (activeSpiral && Boolean(activeSpiral.terrainProvider) !== presentation.terrainEnabled) {
stopSpiralAnimation("renderer_restarted");
}
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
rebuildGridRef.current?.();
}, [presentation]);
const viewer = viewerRef.current;
const terrain = terrainRef.current;
onReadyChangeRef.current?.(Boolean(
viewer
&& !viewer.isDestroyed()
&& terrain
&& (!presentation.terrainEnabled || terrain.world),
));
}, [presentation, stopSpiralAnimation]);
useEffect(() => {
runtimeBindingsRef.current = runtimeBindings;
@@ -705,6 +1060,7 @@ export function CesiumMapRenderer({
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
let renderRecoveryScheduled = false;
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
stopSpiralAnimation("render_error");
const raw = error instanceof Error ? error.message : "cesium_render_error";
const safe = raw.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "cesium_render_error";
reportProvider("imagery", "error", safe);
@@ -748,6 +1104,7 @@ export function CesiumMapRenderer({
terrain.world = world;
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
reportProvider("terrain", "ready");
}).catch((error) => reportProvider("terrain", "error", error));
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
@@ -794,6 +1151,7 @@ export function CesiumMapRenderer({
}
rebuildGrid();
onCameraChangeRef.current?.(getCameraView(viewer));
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) {
// All root provider resources for the current view were created with
@@ -838,6 +1196,8 @@ export function CesiumMapRenderer({
void start();
return () => {
cancelled = true;
stopSpiralAnimation("renderer_restarted");
onReadyChangeRef.current?.(false);
resizeObserver?.disconnect();
removeGridCameraListener?.();
removeRefreshRenderListener?.();
@@ -851,7 +1211,7 @@ export function CesiumMapRenderer({
rebuildGridRef.current = null;
runtimeDataSourcesRef.current.clear();
};
}, [onGatewayHealth, onProviderStatus]);
}, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
return (
<div className="catalog-cesium-map">
@@ -859,4 +1219,4 @@ export function CesiumMapRenderer({
<div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" />
</div>
);
}
});