From e96401358b85de46780167f4339bf165da55d604 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 12:47:48 +0300 Subject: [PATCH] feat(foundry): add map camera spiral controls --- apps/catalog/src/CesiumMapRenderer.tsx | 390 ++++++++++++++++++++++++- apps/catalog/src/MapFixturePreview.tsx | 149 +++++++++- apps/catalog/src/mapSpiralMath.ts | 180 ++++++++++++ apps/catalog/src/styles.css | 10 +- docs/COMPONENTS.md | 2 + package.json | 3 +- packages/ui-core/styles.css | 7 - packages/ui-react/src/Checker.tsx | 11 +- packages/ui-react/src/RangeControl.tsx | 1 + registry/components.json | 3 +- scripts/map-spiral.test.mjs | 89 ++++++ 11 files changed, 802 insertions(+), 43 deletions(-) create mode 100644 apps/catalog/src/mapSpiralMath.ts create mode 100644 scripts/map-spiral.test.mjs diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 1869b5b..250e9fc 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -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( (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 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(null); const creditContainerRef = useRef(null); const viewerRef = useRef(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(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, 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((_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 | 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 (
@@ -859,4 +1219,4 @@ export function CesiumMapRenderer({ ); -} +}); diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 4d7f9c6..510dc9a 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -1,6 +1,13 @@ 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 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 sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json"; @@ -161,6 +168,23 @@ const providerStateLabel: Record = { "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 initialLayout?.mapHeight ?? (expanded ? 620 : 470)); const [mapCamera, setMapCamera] = useState(initialLayout?.camera ?? fallbackMapCamera); + const mapRendererRef = useRef(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(null); // Map pin bindings belong to the application page instance. They are kept // intact when a human changes camera or visual settings and presses Save. const [pinBindings] = useState(() => initialLayout?.pinBindings ?? []); @@ -237,7 +269,10 @@ export const MapFixturePreview = forwardRef(initialProviderStatus); const [cacheRefresh, setCacheRefresh] = useState(false); const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; - const presentation: MapPresentation = { ...mapSettings, cacheRefresh }; + const presentation = useMemo( + () => ({ ...mapSettings, cacheRefresh }), + [cacheRefresh, mapSettings], + ); const updateMapSettings = (patch: Partial) => setMapSettings((current) => ({ ...current, ...patch })); const setCacheEnabled = (cacheEnabled: boolean) => { updateMapSettings({ cacheEnabled }); @@ -263,13 +298,40 @@ export const MapFixturePreview = forwardRef { + 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, () => ({ getLayout: () => ({ schemaVersion: 1, pageId: "map", settings: mapSettings, mapHeight: Math.round(mapHeight), - camera: mapCameraRef.current ?? mapCamera, + camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera, pinBindings, dataProductBindings, }), @@ -426,7 +488,8 @@ export const MapFixturePreview = forwardRefТекущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform. Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]} {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} - updateMapSettings({ terrainEnabled })} /> + Рельеф — отдельный слой под imagery. + updateMapSettings({ terrainEnabled })} /> `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} /> updateMapSettings({ monochrome })} /> updateMapSettings({ monochromeColor })} /> @@ -476,7 +539,8 @@ export const MapFixturePreview = forwardRef - updateMapSettings({ gridVisible })} /> + Сетка размещается над поверхностью и меняет шаг по высоте камеры. + updateMapSettings({ gridVisible })} /> updateMapSettings({ gridLodEnabled })} /> `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} /> `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} /> @@ -494,6 +558,51 @@ export const MapFixturePreview = forwardRef `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> , }, + { + id: "map-camera-animation", + label: "Анимация камеры", + description: "geodesic spiral survey", + group: "Камера", + content: <> + + {animationModeEnabled ? <> + Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта. + formatMetricDistance(10 ** value)} + onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))} + /> + formatMetricSpeed(10 ** value)} + onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))} + /> + formatMetricDistance(10 ** value)} + onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))} + /> + + {spiralRunning ? Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию. : null} + {spiralMessage ? {spiralMessage} : null} + : null} + , + }, { id: "map-cache", label: "TileCache", @@ -501,13 +610,14 @@ export const MapFixturePreview = forwardRef Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю. - - - {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"} - Platform Map Gateway - {gatewayEndpoint ?? "runtime profile · не проверено"} - {liveCacheSummary} - {gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"} + + Cache hit отдаётся как есть; новый tile записывается только при miss. + + {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"} + Platform Map Gateway + {gatewayEndpoint ?? "runtime profile · не проверено"} + {liveCacheSummary} + {gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"} Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"} @@ -537,7 +647,20 @@ export const MapFixturePreview = forwardRef Загрузка карты…
}> - +
diff --git a/apps/catalog/src/mapSpiralMath.ts b/apps/catalog/src/mapSpiralMath.ts new file mode 100644 index 0000000..c2b8dec --- /dev/null +++ b/apps/catalog/src/mapSpiralMath.ts @@ -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; +} diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 48106fd..dfad280 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -787,8 +787,7 @@ textarea { font-size: var(--nodedc-font-size-xs); } -.catalog-map-fixture__cache-toggle, -.catalog-map-inspector__cache-toggle { +.catalog-map-fixture__cache-toggle { min-height: 3.4rem; } @@ -818,6 +817,13 @@ textarea { 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 { position: absolute; z-index: 9; diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index ab12747..eac5091 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -35,6 +35,8 @@ FieldFrame объединяет label, control, hint и description. TextField/T Круглый бинарный контрол из нового Engine settings/agent inspector. Он использует checkbox semantics, но не копирует квадратный системный checkbox. +Внутри Checker допускается только короткий однострочный заголовок. Пояснение, hint или описание размещается отдельным текстовым блоком перед Checker; сам бинарный контрол не является контейнером для вторичной копии. + ## RangeControl Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. diff --git a/package.json b/package.json index 2ae166f..8703216 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.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": { "node": ">=20" diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 7da060a..5dc3c96 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -627,13 +627,6 @@ textarea.nodedc-field__control { 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 { width: 1.625rem; height: 1.625rem; diff --git a/packages/ui-react/src/Checker.tsx b/packages/ui-react/src/Checker.tsx index dcfa1d8..f9a58d9 100644 --- a/packages/ui-react/src/Checker.tsx +++ b/packages/ui-react/src/Checker.tsx @@ -4,14 +4,19 @@ import { cn } from "./cn.js"; export interface CheckerProps extends Omit, "onChange"> { checked: boolean; label: string; - description?: string; + /** Helper copy belongs outside the control, immediately before it. */ + description?: never; onChange: (checked: boolean) => void; } +type IsNever = [Value] extends [never] ? true : false; +type Assert = Value; +type _CheckerDescriptionMustStayForbidden = Assert>>; + export function Checker({ checked, label, - description, + description: _unsupportedDescription, onChange, disabled, className, @@ -32,10 +37,8 @@ export function Checker({ > {label} - {description ? {description} : null}