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

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { import {
Cartesian2, Cartesian2,
Cartesian3, Cartesian3,
@ -10,6 +10,7 @@ import {
Cesium3DTileStyle, Cesium3DTileStyle,
CesiumTerrainProvider, CesiumTerrainProvider,
CallbackProperty, CallbackProperty,
Cartographic,
ConstantPositionProperty, ConstantPositionProperty,
CustomDataSource, CustomDataSource,
DefaultProxy, DefaultProxy,
@ -27,6 +28,7 @@ import {
PolygonHierarchy, PolygonHierarchy,
PointGraphics, PointGraphics,
Resource, Resource,
sampleTerrainMostDetailed,
ScreenSpaceEventHandler, ScreenSpaceEventHandler,
ScreenSpaceEventType, ScreenSpaceEventType,
SunLight, SunLight,
@ -35,9 +37,12 @@ import {
} from "cesium"; } from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css"; import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json"; 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"; import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
type Position = [number, number, number?]; type Position = [number, number, number?];
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
type PinPresentation = { type PinPresentation = {
variant: "elevated-spike"; variant: "elevated-spike";
stemHeightMeters: number; stemHeightMeters: number;
@ -177,6 +182,50 @@ export type MapCameraView = {
roll: number; 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 toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height);
const toCartesianArray = (positions: Position[]) => positions.map(toCartesian); const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
const accent = Color.fromCssColorString("#ff2f92"); 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) { function addFixtureEntities(viewer: Viewer) {
const styleProfiles = new Map<string, MapStyleProfile>( const styleProfiles = new Map<string, MapStyleProfile>(
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]), (sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
@ -540,25 +604,29 @@ function applyPresentation(
viewer.scene.requestRender(); viewer.scene.requestRender();
} }
export function CesiumMapRenderer({ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
onSelect,
onGatewayHealth,
onProviderStatus,
onCameraChange,
onCacheRefreshConsumed,
initialCamera,
presentation,
runtimeBindings = [],
}: {
onSelect?: (entityId: string) => void; onSelect?: (entityId: string) => void;
onGatewayHealth?: (health: MapGatewayHealth | null) => void; onGatewayHealth?: (health: MapGatewayHealth | null) => void;
onProviderStatus?: (status: MapProviderStatus) => void; onProviderStatus?: (status: MapProviderStatus) => void;
onCameraChange?: (camera: MapCameraView) => void; onCameraChange?: (camera: MapCameraView) => void;
onCacheRefreshConsumed?: () => void; onCacheRefreshConsumed?: () => void;
onReadyChange?: (ready: boolean) => void;
onSpiralStateChange?: (state: CameraSpiralState) => void;
initialCamera?: MapCameraView; initialCamera?: MapCameraView;
presentation: MapPresentation; presentation: MapPresentation;
runtimeBindings?: MapRuntimeBinding[]; runtimeBindings?: MapRuntimeBinding[];
}) { }>(function CesiumMapRenderer({
onSelect,
onGatewayHealth,
onProviderStatus,
onCameraChange,
onCacheRefreshConsumed,
onReadyChange,
onSpiralStateChange,
initialCamera,
presentation,
runtimeBindings = [],
}, ref) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null); const creditContainerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer | null>(null); const viewerRef = useRef<Viewer | null>(null);
@ -572,6 +640,9 @@ export function CesiumMapRenderer({
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const onCameraChangeRef = useRef(onCameraChange); const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed); const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
const onReadyChangeRef = useRef(onReadyChange);
const onSpiralStateChangeRef = useRef(onSpiralStateChange);
const spiralSessionRef = useRef<SpiralSession | null>(null);
useEffect(() => { useEffect(() => {
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
@ -585,11 +656,295 @@ export function CesiumMapRenderer({
onCacheRefreshConsumedRef.current = onCacheRefreshConsumed; onCacheRefreshConsumedRef.current = onCacheRefreshConsumed;
}, [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(() => { useEffect(() => {
presentationRef.current = presentation; 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); if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
rebuildGridRef.current?.(); 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(() => { useEffect(() => {
runtimeBindingsRef.current = runtimeBindings; runtimeBindingsRef.current = runtimeBindings;
@ -705,6 +1060,7 @@ export function CesiumMapRenderer({
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } }); onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
let renderRecoveryScheduled = false; let renderRecoveryScheduled = false;
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => { removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
stopSpiralAnimation("render_error");
const raw = error instanceof Error ? error.message : "cesium_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"; const safe = raw.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "cesium_render_error";
reportProvider("imagery", "error", safe); reportProvider("imagery", "error", safe);
@ -748,6 +1104,7 @@ export function CesiumMapRenderer({
terrain.world = world; terrain.world = world;
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); 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); applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
reportProvider("terrain", "ready"); reportProvider("terrain", "ready");
}).catch((error) => reportProvider("terrain", "error", error)); }).catch((error) => reportProvider("terrain", "error", error));
void loadEndpoint("96188").then(async (buildingsEndpoint) => { void loadEndpoint("96188").then(async (buildingsEndpoint) => {
@ -794,6 +1151,7 @@ export function CesiumMapRenderer({
} }
rebuildGrid(); rebuildGrid();
onCameraChangeRef.current?.(getCameraView(viewer)); onCameraChangeRef.current?.(getCameraView(viewer));
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) { if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) {
// All root provider resources for the current view were created with // All root provider resources for the current view were created with
@ -838,6 +1196,8 @@ export function CesiumMapRenderer({
void start(); void start();
return () => { return () => {
cancelled = true; cancelled = true;
stopSpiralAnimation("renderer_restarted");
onReadyChangeRef.current?.(false);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
removeGridCameraListener?.(); removeGridCameraListener?.();
removeRefreshRenderListener?.(); removeRefreshRenderListener?.();
@ -851,7 +1211,7 @@ export function CesiumMapRenderer({
rebuildGridRef.current = null; rebuildGridRef.current = null;
runtimeDataSourcesRef.current.clear(); runtimeDataSourcesRef.current.clear();
}; };
}, [onGatewayHealth, onProviderStatus]); }, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
return ( return (
<div className="catalog-cesium-map"> <div className="catalog-cesium-map">
@ -859,4 +1219,4 @@ export function CesiumMapRenderer({
<div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" /> <div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" />
</div> </div>
); );
} });

View File

@ -1,6 +1,13 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react"; import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react"; import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
import type { MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus } from "./CesiumMapRenderer.js"; import type {
CameraSpiralState,
CesiumMapRendererHandle,
MapCameraView,
MapGatewayHealth,
MapPresentation,
MapProviderStatus,
} from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json"; import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
@ -161,6 +168,23 @@ const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
"not-configured": "не настроен", "not-configured": "не настроен",
}; };
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
const formatMetricDistance = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км`
: `${Math.round(value)} м`;
const formatMetricSpeed = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
: `${Math.round(value)} м/с`;
function spiralStopMessage(reason: CameraSpiralState["reason"]) {
if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута.";
if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка.";
if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры.";
if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer.";
return null;
}
export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, { export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
features?: PreviewFeatures; features?: PreviewFeatures;
expanded?: boolean; expanded?: boolean;
@ -186,6 +210,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
})); }));
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470)); const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera); const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
const [mapRendererReady, setMapRendererReady] = useState(false);
const [animationModeEnabled, setAnimationModeEnabled] = useState(false);
const [spiralRunning, setSpiralRunning] = useState(false);
const [spiralHeightMeters, setSpiralHeightMeters] = useState(1500);
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(250);
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(10_000);
const [spiralMessage, setSpiralMessage] = useState<string | null>(null);
// Map pin bindings belong to the application page instance. They are kept // Map pin bindings belong to the application page instance. They are kept
// intact when a human changes camera or visual settings and presses Save. // intact when a human changes camera or visual settings and presses Save.
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []); const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
@ -237,7 +269,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus); const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
const [cacheRefresh, setCacheRefresh] = useState(false); const [cacheRefresh, setCacheRefresh] = useState(false);
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
const presentation: MapPresentation = { ...mapSettings, cacheRefresh }; const presentation = useMemo<MapPresentation>(
() => ({ ...mapSettings, cacheRefresh }),
[cacheRefresh, mapSettings],
);
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch })); const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
const setCacheEnabled = (cacheEnabled: boolean) => { const setCacheEnabled = (cacheEnabled: boolean) => {
updateMapSettings({ cacheEnabled }); updateMapSettings({ cacheEnabled });
@ -263,13 +298,40 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
setMapCamera(camera); setMapCamera(camera);
}, []); }, []);
const handleSpiralStateChange = useCallback((state: CameraSpiralState) => {
setSpiralRunning(state.running);
setSpiralMessage(state.running ? null : spiralStopMessage(state.reason));
}, []);
const setAnimationMode = (enabled: boolean) => {
setAnimationModeEnabled(enabled);
setSpiralMessage(null);
if (!enabled) {
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
setSpiralRunning(false);
}
};
const toggleSpiralAnimation = () => {
if (spiralRunning) {
mapRendererRef.current?.stopSpiralAnimation("stopped");
return;
}
const started = mapRendererRef.current?.startSpiralAnimation({
heightAboveGroundMeters: spiralHeightMeters,
speedMetersPerSecond: spiralSpeedMetersPerSecond,
pitchMetersPerTurn: spiralPitchMetersPerTurn,
}) ?? false;
if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации.");
};
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getLayout: () => ({ getLayout: () => ({
schemaVersion: 1, schemaVersion: 1,
pageId: "map", pageId: "map",
settings: mapSettings, settings: mapSettings,
mapHeight: Math.round(mapHeight), mapHeight: Math.round(mapHeight),
camera: mapCameraRef.current ?? mapCamera, camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
pinBindings, pinBindings,
dataProductBindings, dataProductBindings,
}), }),
@ -426,7 +488,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small> <small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow> <ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)} {Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" description="Рельеф — отдельный слой под imagery." onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} /> <small className="catalog-map-inspector__note">Рельеф отдельный слой под imagery.</small>
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} /> <RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} /> <Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow> <ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
@ -476,7 +539,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
description: "first adapter control", description: "first adapter control",
group: "Слои", group: "Слои",
content: <> content: <>
<Checker checked={mapSettings.gridVisible} label="3D-сетка" description="Сетка размещается над поверхностью и меняет шаг по высоте камеры." onChange={(gridVisible) => updateMapSettings({ gridVisible })} /> <small className="catalog-map-inspector__note">Сетка размещается над поверхностью и меняет шаг по высоте камеры.</small>
<Checker checked={mapSettings.gridVisible} label="3D-сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} /> <Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} /> <RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} />
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} /> <RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} />
@ -494,6 +558,51 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label="Прозрачность точек" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> <RangeControl label="Прозрачность точек" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} />
</>, </>,
}, },
{
id: "map-camera-animation",
label: "Анимация камеры",
description: "geodesic spiral survey",
group: "Камера",
content: <>
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
{animationModeEnabled ? <>
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта.</small>
<RangeControl
label="Высота над землёй"
value={logarithmicControlValue(spiralHeightMeters)}
min={logarithmicControlValue(10)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))}
/>
<RangeControl
label="Скорость камеры"
value={logarithmicControlValue(spiralSpeedMetersPerSecond)}
min={logarithmicControlValue(1)}
max={logarithmicControlValue(5_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricSpeed(10 ** value)}
onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))}
/>
<RangeControl
label="Шаг спирали"
value={logarithmicControlValue(spiralPitchMetersPerTurn)}
min={logarithmicControlValue(20)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))}
/>
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!mapRendererReady}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
</> : null}
</>,
},
{ {
id: "map-cache", id: "map-cache",
label: "TileCache", label: "TileCache",
@ -501,13 +610,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
group: "Хранение", group: "Хранение",
content: <> content: <>
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small> <small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
<Checker className="catalog-map-inspector__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} /> <Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученный cache" description="Cache hit отдаётся как есть; новый tile записывается только при miss." onChange={setCacheNoOverwrite} /> <small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
<ControlRow label="Режим"><strong>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</strong></ControlRow> <Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
<ControlRow label="Хранилище"><strong>Platform Map Gateway</strong></ControlRow> <ControlRow className="catalog-map-inspector__cache-fact" label="Режим"><span>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</span></ControlRow>
<ControlRow label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow> <ControlRow className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
<ControlRow label="Записано"><strong>{liveCacheSummary}</strong></ControlRow> <ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
<ControlRow label="Политика"><strong>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</strong></ControlRow> <ControlRow className="catalog-map-inspector__cache-fact" label="Записано"><span>{liveCacheSummary}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Политика"><span>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</span></ControlRow>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button> <Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button> <Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small> <small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
@ -537,7 +647,20 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
aria-label="Map Page Cesium adapter" aria-label="Map Page Cesium adapter"
> >
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты</div>}> <Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты</div>}>
<CesiumMapRenderer key={rendererRevision} onSelect={handleSelect} onGatewayHealth={handleRendererGatewayHealth} onProviderStatus={setProviderStatus} onCameraChange={handleCameraChange} onCacheRefreshConsumed={handleCacheRefreshConsumed} initialCamera={mapCamera ?? undefined} presentation={presentation} runtimeBindings={runtimeBindings} /> <CesiumMapRenderer
key={rendererRevision}
ref={mapRendererRef}
onSelect={handleSelect}
onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus}
onCameraChange={handleCameraChange}
onCacheRefreshConsumed={handleCacheRefreshConsumed}
onReadyChange={setMapRendererReady}
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={runtimeBindings}
/>
</Suspense> </Suspense>
<div className="catalog-map-fixture__actions"> <div className="catalog-map-fixture__actions">

View File

@ -0,0 +1,180 @@
const TWO_PI = Math.PI * 2;
const WGS84_SEMI_MAJOR_METERS = 6_378_137;
const WGS84_FLATTENING = 1 / 298.257_223_563;
const WGS84_SEMI_MINOR_METERS = WGS84_SEMI_MAJOR_METERS * (1 - WGS84_FLATTENING);
// This survey mode is deliberately regional. Radially projecting an
// Archimedean spiral onto WGS84 stays within a negligible speed/tangent error
// at this radius; a future continental mode needs an ellipsoid-arc
// reparameterization instead of silently pretending the flat formula is exact.
export const MAX_SPIRAL_RADIUS_METERS = 250_000;
export type GeodeticRadians = {
longitude: number;
latitude: number;
};
export type SpiralSurfaceFrame = GeodeticRadians & {
angle: number;
radiusMeters: number;
radialBearing: number;
tangentHeading: number;
};
export function normalizeRadians(value: number) {
const normalized = ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
return normalized === -Math.PI ? Math.PI : normalized;
}
export function spiralRadiusAtAngle(angle: number, pitchMetersPerTurn: number) {
const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid"));
const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid");
return (pitch / TWO_PI) * safeAngle;
}
export function spiralArcLengthAtAngle(angle: number, pitchMetersPerTurn: number) {
const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid"));
const b = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid") / TWO_PI;
return (b / 2) * (
safeAngle * Math.sqrt(1 + safeAngle * safeAngle)
+ Math.asinh(safeAngle)
);
}
/**
* Inverts the Archimedean spiral arc-length function. This keeps the camera's
* configured surface speed constant instead of accelerating as the radius
* grows. Newton is monotone here; the bounded fallback protects the first
* samples near the origin from numerical overshoot.
*/
export function spiralAngleAtArcLength(surfaceDistanceMeters: number, pitchMetersPerTurn: number) {
const distance = Math.max(0, finiteNumber(surfaceDistanceMeters, "spiral_distance_invalid"));
const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid");
if (distance === 0) return 0;
const b = pitch / TWO_PI;
let angle = distance <= b ? distance / b : Math.sqrt((2 * distance) / b);
for (let iteration = 0; iteration < 10; iteration += 1) {
const error = spiralArcLengthAtAngle(angle, pitch) - distance;
if (Math.abs(error) <= Math.max(1e-7, distance * 1e-12)) break;
const derivative = b * Math.sqrt(1 + angle * angle);
const next = angle - error / derivative;
angle = Number.isFinite(next) && next >= 0 ? next : angle / 2;
}
return angle;
}
/**
* Vincenty's WGS84 direct solution: move from a geodetic point along an
* initial bearing by an exact ellipsoid surface distance. Longitude/latitude
* are radians. The animation therefore remains stable at datelines and high
* latitudes and never treats degrees as a flat Cartesian plane.
*/
export function directGeodesicDestination(
origin: GeodeticRadians,
initialBearing: number,
distanceMeters: number,
): GeodeticRadians {
const latitude1 = finiteNumber(origin.latitude, "spiral_origin_invalid");
const longitude1 = finiteNumber(origin.longitude, "spiral_origin_invalid");
const bearing = finiteNumber(initialBearing, "spiral_bearing_invalid");
const distance = Math.max(0, finiteNumber(distanceMeters, "spiral_distance_invalid"));
if (Math.abs(latitude1) > Math.PI / 2 + 1e-12) throw new Error("spiral_origin_invalid");
if (distance === 0) return { longitude: normalizeRadians(longitude1), latitude: latitude1 };
const sinBearing = Math.sin(bearing);
const cosBearing = Math.cos(bearing);
const tanReducedLatitude1 = (1 - WGS84_FLATTENING) * Math.tan(latitude1);
const cosReducedLatitude1 = 1 / Math.sqrt(1 + tanReducedLatitude1 * tanReducedLatitude1);
const sinReducedLatitude1 = tanReducedLatitude1 * cosReducedLatitude1;
const sigma1 = Math.atan2(tanReducedLatitude1, cosBearing);
const sinAlpha = cosReducedLatitude1 * sinBearing;
const cosSquaredAlpha = 1 - sinAlpha * sinAlpha;
const uSquared = cosSquaredAlpha
* (WGS84_SEMI_MAJOR_METERS ** 2 - WGS84_SEMI_MINOR_METERS ** 2)
/ (WGS84_SEMI_MINOR_METERS ** 2);
const coefficientA = 1 + (uSquared / 16_384)
* (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared)));
const coefficientB = (uSquared / 1024)
* (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared)));
let sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA);
let previousSigma = Number.POSITIVE_INFINITY;
let sinSigma = 0;
let cosSigma = 1;
let cosTwoSigmaMiddle = 0;
for (let iteration = 0; iteration < 24 && Math.abs(sigma - previousSigma) > 1e-12; iteration += 1) {
cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma);
sinSigma = Math.sin(sigma);
cosSigma = Math.cos(sigma);
const deltaSigma = coefficientB * sinSigma * (
cosTwoSigmaMiddle
+ (coefficientB / 4) * (
cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2)
- (coefficientB / 6) * cosTwoSigmaMiddle
* (-3 + 4 * sinSigma ** 2)
* (-3 + 4 * cosTwoSigmaMiddle ** 2)
)
);
previousSigma = sigma;
sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA) + deltaSigma;
}
sinSigma = Math.sin(sigma);
cosSigma = Math.cos(sigma);
cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma);
const temporary = sinReducedLatitude1 * sinSigma
- cosReducedLatitude1 * cosSigma * cosBearing;
const latitude2 = Math.atan2(
sinReducedLatitude1 * cosSigma + cosReducedLatitude1 * sinSigma * cosBearing,
(1 - WGS84_FLATTENING) * Math.sqrt(sinAlpha ** 2 + temporary ** 2),
);
const lambda = Math.atan2(
sinSigma * sinBearing,
cosReducedLatitude1 * cosSigma - sinReducedLatitude1 * sinSigma * cosBearing,
);
const coefficientC = (WGS84_FLATTENING / 16) * cosSquaredAlpha
* (4 + WGS84_FLATTENING * (4 - 3 * cosSquaredAlpha));
const longitudeDelta = lambda - (1 - coefficientC) * WGS84_FLATTENING * sinAlpha * (
sigma + coefficientC * sinSigma * (
cosTwoSigmaMiddle + coefficientC * cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2)
)
);
return {
longitude: normalizeRadians(longitude1 + longitudeDelta),
latitude: latitude2,
};
}
export function spiralSurfaceFrame(
origin: GeodeticRadians,
initialHeading: number,
surfaceDistanceMeters: number,
pitchMetersPerTurn: number,
): SpiralSurfaceFrame {
const angle = spiralAngleAtArcLength(surfaceDistanceMeters, pitchMetersPerTurn);
const radiusMeters = spiralRadiusAtAngle(angle, pitchMetersPerTurn);
if (radiusMeters > MAX_SPIRAL_RADIUS_METERS) throw new Error("spiral_extent_limit");
const radialBearing = normalizeRadians(initialHeading + angle);
const destination = directGeodesicDestination(origin, radialBearing, radiusMeters);
// In polar coordinates dr/dθ=b and r=bθ. The path tangent is therefore
// rotated atan2(r, dr/dθ)=atan(θ) from the outward radial direction.
const tangentHeading = normalizeRadians(radialBearing + Math.atan(angle));
return {
...destination,
angle,
radiusMeters,
radialBearing,
tangentHeading,
};
}
function finiteNumber(value: number, errorCode: string) {
if (!Number.isFinite(value)) throw new Error(errorCode);
return value;
}
function positiveNumber(value: number, errorCode: string) {
const finite = finiteNumber(value, errorCode);
if (finite <= 0) throw new Error(errorCode);
return finite;
}

View File

@ -787,8 +787,7 @@ textarea {
font-size: var(--nodedc-font-size-xs); font-size: var(--nodedc-font-size-xs);
} }
.catalog-map-fixture__cache-toggle, .catalog-map-fixture__cache-toggle {
.catalog-map-inspector__cache-toggle {
min-height: 3.4rem; min-height: 3.4rem;
} }
@ -818,6 +817,13 @@ textarea {
font-weight: var(--nodedc-font-weight-strong); font-weight: var(--nodedc-font-weight-strong);
} }
.catalog-map-inspector__cache-fact .nodedc-control-row__control {
overflow-wrap: anywhere;
font-size: var(--nodedc-font-size-md);
font-weight: var(--nodedc-font-weight-strong);
line-height: 1.25;
}
.catalog-map-fixture__assistant { .catalog-map-fixture__assistant {
position: absolute; position: absolute;
z-index: 9; z-index: 9;

View File

@ -35,6 +35,8 @@ FieldFrame объединяет label, control, hint и description. TextField/T
Круглый бинарный контрол из нового Engine settings/agent inspector. Он использует checkbox semantics, но не копирует квадратный системный checkbox. Круглый бинарный контрол из нового Engine settings/agent inspector. Он использует checkbox semantics, но не копирует квадратный системный checkbox.
Внутри Checker допускается только короткий однострочный заголовок. Пояснение, hint или описание размещается отдельным текстовым блоком перед Checker; сам бинарный контрол не является контейнером для вторичной копии.
## RangeControl ## RangeControl
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа.

View File

@ -16,7 +16,8 @@
"serve": "node server/catalog-server.mjs", "serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs", "validate:registry": "node scripts/validate-registry.mjs",
"test:platform-settings": "node scripts/smoke-platform-settings.mjs", "test:platform-settings": "node scripts/smoke-platform-settings.mjs",
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs" "test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@ -627,13 +627,6 @@ textarea.nodedc-field__control {
white-space: nowrap; white-space: nowrap;
} }
.nodedc-checker__description {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
font-weight: var(--nodedc-font-weight-regular);
line-height: 1.25;
}
.nodedc-checker__indicator { .nodedc-checker__indicator {
width: 1.625rem; width: 1.625rem;
height: 1.625rem; height: 1.625rem;

View File

@ -4,14 +4,19 @@ import { cn } from "./cn.js";
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> { export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean; checked: boolean;
label: string; label: string;
description?: string; /** Helper copy belongs outside the control, immediately before it. */
description?: never;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
} }
type IsNever<Value> = [Value] extends [never] ? true : false;
type Assert<Value extends true> = Value;
type _CheckerDescriptionMustStayForbidden = Assert<IsNever<Exclude<CheckerProps["description"], undefined>>>;
export function Checker({ export function Checker({
checked, checked,
label, label,
description, description: _unsupportedDescription,
onChange, onChange,
disabled, disabled,
className, className,
@ -32,10 +37,8 @@ export function Checker({
> >
<span className="nodedc-checker__copy"> <span className="nodedc-checker__copy">
<span className="nodedc-checker__label">{label}</span> <span className="nodedc-checker__label">{label}</span>
{description ? <span className="nodedc-checker__description">{description}</span> : null}
</span> </span>
<span className="nodedc-checker__indicator" aria-hidden="true" /> <span className="nodedc-checker__indicator" aria-hidden="true" />
</button> </button>
); );
} }

View File

@ -34,6 +34,7 @@ export function RangeControl({
max={safeMax} max={safeMax}
step={step} step={step}
aria-label={label} aria-label={label}
aria-valuetext={formatValue(value)}
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
{...props} {...props}
/> />

View File

@ -53,7 +53,8 @@
"summary": "Binary setting control derived from the redesigned Engine environment settings.", "summary": "Binary setting control derived from the redesigned Engine environment settings.",
"rules": [ "rules": [
"The visual anchor is circular, not a square native checkbox.", "The visual anchor is circular, not a square native checkbox.",
"The control exposes checkbox semantics through role and aria-checked." "The control exposes checkbox semantics through role and aria-checked.",
"Checker contains one short label only; helper copy is a separate sibling block before the control."
] ]
}, },
{ {

View File

@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import ts from "typescript";
const source = await readFile(new URL("../apps/catalog/src/mapSpiralMath.ts", import.meta.url), "utf8");
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
target: ts.ScriptTarget.ES2022,
},
fileName: "mapSpiralMath.ts",
});
const spiralMath = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
const {
MAX_SPIRAL_RADIUS_METERS,
directGeodesicDestination,
normalizeRadians,
spiralAngleAtArcLength,
spiralArcLengthAtAngle,
spiralRadiusAtAngle,
spiralSurfaceFrame,
} = spiralMath;
const twoPi = Math.PI * 2;
test("one complete spiral turn increases radius by exactly one pitch", () => {
assert.equal(spiralRadiusAtAngle(twoPi, 10_000), 10_000);
});
test("arc-length inversion keeps configured surface distance across the route", () => {
for (const distance of [0, 1, 20, 1_000, 25_000, 1_000_000]) {
const angle = spiralAngleAtArcLength(distance, 10_000);
const restored = spiralArcLengthAtAngle(angle, 10_000);
assert.ok(Math.abs(restored - distance) <= Math.max(1e-6, distance * 1e-10), `${distance}${restored}`);
}
});
test("equal elapsed time produces equal traveled surface distance", () => {
const speed = 250;
const pitch = 10_000;
const atOneSecond = spiralAngleAtArcLength(speed, pitch);
const atTenSeconds = spiralAngleAtArcLength(speed * 10, pitch);
assert.ok(atTenSeconds > atOneSecond);
assert.ok(spiralRadiusAtAngle(atTenSeconds, pitch) > spiralRadiusAtAngle(atOneSecond, pitch));
assert.ok(Math.abs(spiralArcLengthAtAngle(atTenSeconds, pitch) - speed * 10) < 1e-6);
});
test("WGS84 direct destination remains finite across dateline and high latitude", () => {
const destination = directGeodesicDestination({
longitude: 179.9 * Math.PI / 180,
latitude: 84 * Math.PI / 180,
}, Math.PI / 3, 500_000);
assert.ok(Number.isFinite(destination.longitude));
assert.ok(Number.isFinite(destination.latitude));
assert.ok(destination.longitude >= -Math.PI && destination.longitude <= Math.PI);
assert.ok(destination.latitude >= -Math.PI / 2 && destination.latitude <= Math.PI / 2);
});
test("eastbound WGS84 destination at equator matches the ellipsoid circumference", () => {
const destination = directGeodesicDestination({ longitude: 0, latitude: 0 }, Math.PI / 2, 100_000);
assert.ok(Math.abs(destination.latitude) < 1e-12);
assert.ok(Math.abs(destination.longitude - 100_000 / 6_378_137) < 1e-10);
});
test("spiral frame starts at the current point and follows the current heading", () => {
const origin = { longitude: 37.6173 * Math.PI / 180, latitude: 55.7558 * Math.PI / 180 };
const heading = 0.7;
const frame = spiralSurfaceFrame(origin, heading, 0, 10_000);
assert.ok(Math.abs(normalizeRadians(frame.longitude - origin.longitude)) < 1e-12);
assert.ok(Math.abs(frame.latitude - origin.latitude) < 1e-12);
assert.equal(frame.radiusMeters, 0);
assert.ok(Math.abs(normalizeRadians(frame.tangentHeading - heading)) < 1e-12);
});
test("route stops at the documented regional WGS84 safety boundary", () => {
const pitch = 100_000;
const angle = (MAX_SPIRAL_RADIUS_METERS + pitch) * twoPi / pitch;
const distance = spiralArcLengthAtAngle(angle, pitch);
assert.throws(
() => spiralSurfaceFrame({ longitude: 0, latitude: 0 }, 0, distance, pitch),
/spiral_extent_limit/,
);
});
test("invalid animation inputs fail closed", () => {
assert.throws(() => spiralAngleAtArcLength(100, 0), /spiral_pitch_invalid/);
assert.throws(() => directGeodesicDestination({ longitude: 0, latitude: Number.NaN }, 0, 10), /spiral_origin_invalid/);
});