diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 250e9fc..3385482 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -37,12 +37,14 @@ 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 { MAX_SPIRAL_RADIUS_METERS, spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js"; +import { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.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; +const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000; type PinPresentation = { variant: "elevated-spike"; stemHeightMeters: number; @@ -186,11 +188,14 @@ export type CameraSpiralConfig = { heightAboveGroundMeters: number; speedMetersPerSecond: number; pitchMetersPerTurn: number; + targetRadiusMeters?: number; + viewPitchRadians?: number; + waitForTiles?: boolean; }; export type CameraSpiralState = { running: boolean; - reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error"; + reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached"; }; export type CesiumMapRendererHandle = { @@ -214,6 +219,7 @@ type SpiralSession = { surfaceDistanceMeters: number; lastTimestamp: number | null; pendingElapsedSeconds: number; + tilesWaitStartedAt: number | null; requestId: number | null; previousCameraInputsEnabled: boolean; terrainProvider: CesiumTerrainProvider | null; @@ -224,6 +230,11 @@ type SpiralSession = { terrainRequestGeneration: number; terrainRetryAt: number; terrainSampleSpacingMeters: number; + targetSurfaceDistanceMeters: number; + buildingsTileset: Cesium3DTileset | null; + previousBuildingsCullRequestsWhileMoving: boolean | null; + previousBuildingsFoveatedScreenSpaceError: boolean | null; + previousBuildingsFoveatedTimeDelay: number | null; }; const toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height); @@ -260,6 +271,22 @@ function getCameraView(viewer: Viewer): MapCameraView { }; } +function horizontalFieldOfViewRadians(viewer: Viewer) { + const frustum = viewer.camera.frustum as unknown as { + fovy?: number; + fov?: number; + aspectRatio?: number; + }; + const aspectRatio = Number.isFinite(frustum.aspectRatio) && Number(frustum.aspectRatio) > 0 + ? Number(frustum.aspectRatio) + : Math.max(0.1, viewer.canvas.clientWidth / Math.max(1, viewer.canvas.clientHeight)); + if (Number.isFinite(frustum.fovy) && Number(frustum.fovy) > 0) { + return 2 * Math.atan(Math.tan(Number(frustum.fovy) / 2) * aspectRatio); + } + if (Number.isFinite(frustum.fov) && Number(frustum.fov) > 0) return Number(frustum.fov); + return Math.PI / 3; +} + function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMeters: number) { if (samples.length === 0) return null; if (distanceMeters === samples[0].distanceMeters) return samples[0].heightMeters; @@ -670,6 +697,17 @@ export const CesiumMapRenderer = forwardRef { if (!session.terrainProvider || session.terrainSamplePending || spiralSessionRef.current !== session) return; + if (session.terrainSampleExhausted) return; if (Date.now() < session.terrainRetryAt) return; const lastSample = session.terrainSamples.at(-1); // Prefetch before the camera reaches the buffer tail. At normal and @@ -764,8 +840,14 @@ export const CesiumMapRenderer = forwardRef= session.targetSurfaceDistanceMeters) { + reachedTargetDistance = true; + break; + } } catch (error) { if (error instanceof Error && error.message === "spiral_extent_limit") { reachedExtent = true; - session.terrainSampleExhausted = true; } else { stopSpiralAnimation("spiral_runtime_error"); } @@ -814,6 +899,7 @@ export const CesiumMapRenderer = forwardRef sample.distanceMeters >= session.surfaceDistanceMeters - session.terrainSampleSpacingMeters * 2); + if (reachedTargetDistance || reachedExtent) session.terrainSampleExhausted = true; session.terrainSamplePending = false; session.terrainSampleFailures = 0; session.terrainRetryAt = 0; @@ -838,10 +924,33 @@ export const CesiumMapRenderer = forwardRef= SPIRAL_TILE_WAIT_TIMEOUT_MS) { + stopSpiralAnimation("tile_loading_timeout"); + return; + } + // Do not accumulate route time while the current Terrain, Imagery or + // OSM Buildings footprint is still resolving through TileCache. + session.lastTimestamp = timestamp; + session.pendingElapsedSeconds = 0; + requestTerrainSamples(); + viewer.scene.requestRender(); + session.requestId = window.requestAnimationFrame(tick); + return; + } + session.tilesWaitStartedAt = null; + // Never catch up a stalled UI thread with one large camera jump. A + // cache survey values continuous visible coverage over wall-clock + // speed; limiting one rendered advance to 1/30 s keeps intermediate + // viewports observable by Cesium before the next tile-settle gate. + const elapsedSeconds = Math.min(1 / 30, Math.max(0, (timestamp - session.lastTimestamp) / 1000)); session.lastTimestamp = timestamp; session.pendingElapsedSeconds += elapsedSeconds; try { @@ -857,6 +966,10 @@ export const CesiumMapRenderer = forwardRef= (session.config.targetRadiusMeters ?? MAX_SPIRAL_RADIUS_METERS)) { + stopSpiralAnimation("target_radius_reached"); + return; + } const candidateGround = groundHeightAtDistance(candidateDistance); if (candidateGround === null) { const lastTerrainSample = session.terrainSamples.at(-1); @@ -919,6 +1032,7 @@ export const CesiumMapRenderer = forwardRef void) | undefined; let removeRefreshRenderListener: (() => void) | undefined; let removeRenderErrorListener: (() => void) | undefined; + const removeProviderFailureListeners: Array<() => void> = []; let cancelled = false; const start = async () => { @@ -1048,7 +1163,9 @@ export const CesiumMapRenderer = forwardRef { + if (state === "ready" && providerTileFailureLatched[provider]) return; providerStatus[provider] = state; if (state === "error") { providerStatus.errors[provider] = error instanceof Error && error.message ? error.message : "provider_unavailable"; @@ -1057,6 +1174,15 @@ export const CesiumMapRenderer = forwardRef { + if (cancelled) return; + // Keep the provider failed until the renderer is recreated. Merely + // emptying Cesium's request queues cannot turn a viewport with a + // failed tile back into a successful cache survey. + providerTileFailureLatched[provider] = true; + stopSpiralAnimation("tile_loading_error"); + reportProvider(provider, "error", new Error(`${provider}_tile_failed`)); + }; onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } }); let renderRecoveryScheduled = false; removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => { @@ -1091,6 +1217,7 @@ export const CesiumMapRenderer = forwardRef latchProviderTileFailure("imagery"))); for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider); applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current); @@ -1101,6 +1228,7 @@ export const CesiumMapRenderer = forwardRef latchProviderTileFailure("terrain"))); 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); @@ -1108,10 +1236,11 @@ export const CesiumMapRenderer = forwardRef reportProvider("terrain", "error", error)); void loadEndpoint("96188").then(async (buildingsEndpoint) => { - if (!buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid"); + if (buildingsEndpoint.type !== "3DTILES" || !buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid"); const buildingsResource = buildResource(buildingsEndpoint.url); const buildings = await Cesium3DTileset.fromUrl(buildingsResource); if (cancelled || !viewer || viewer.isDestroyed()) return; + removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings"))); viewer.scene.primitives.add(buildings); buildingsRef.current = buildings; for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); @@ -1202,6 +1331,7 @@ export const CesiumMapRenderer = forwardRef import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; export type MapPageSettings = Omit; +type SurveySettingsSnapshot = Pick< + MapPageSettings, + "imageryVisible" | "monochrome" | "cacheEnabled" | "cacheNoOverwrite" | "terrainEnabled" | "buildingsVisible" | "buildingsDetail" +>; type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null }; type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error"; type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number }; const RENDERER_GATEWAY_HEALTH_EPOCH = 1; +const SURVEY_GATEWAY_HEALTH_MAX_AGE_MS = 30_000; function beginGatewayHealthEpoch(order: GatewayHealthOrder) { order.nextEpoch += 1; @@ -46,6 +60,13 @@ function gatewayCheckMessage(code: string, stale: boolean) { return `${prefix} (${code}). ${suffix}`; } +function isWritableAppendOnlyTileCache(health: MapGatewayHealth | null) { + return health?.cache?.persistent === true + && health.cache.mode === "readwrite" + && health.cache.writePolicy === "append-only-no-eviction" + && health.cache.atCapacity === false; +} + /** * Provider-neutral visual binding. Foundry stores this on an Application page * instance; a future data binding resolves the live source behind `source`. @@ -176,10 +197,19 @@ const formatMetricDistance = (value: number) => value >= 1000 const formatMetricSpeed = (value: number) => value >= 1000 ? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с` : `${Math.round(value)} м/с`; +const formatDuration = (seconds: number) => { + if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`; + if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`; + if (seconds >= 60) return `${Math.round(seconds / 60)} мин`; + return `${Math.max(1, Math.round(seconds))} с`; +}; function spiralStopMessage(reason: CameraSpiralState["reason"]) { + if (reason === "target_radius_reached") return "Проход завершён: камера достигла выбранного радиуса."; if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута."; if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка."; + if (reason === "tile_loading_timeout") return "Режим остановлен: текущий набор Imagery, Terrain или OSM Buildings не загрузился за отведённое время."; + if (reason === "tile_loading_error") return "Режим остановлен: Cesium сообщил об ошибке tile в Imagery, Terrain или OSM Buildings. Неполный участок не засчитан."; if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры."; if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer."; return null; @@ -213,10 +243,13 @@ export const MapFixturePreview = forwardRef(null); const [mapRendererReady, setMapRendererReady] = useState(false); const [animationModeEnabled, setAnimationModeEnabled] = useState(false); + const animationSettingsSnapshotRef = useRef(null); const [spiralRunning, setSpiralRunning] = useState(false); - const [spiralHeightMeters, setSpiralHeightMeters] = useState(1500); - const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(250); - const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(10_000); + const [spiralPresetId, setSpiralPresetId] = useState(DEFAULT_CAMERA_SURVEY_PRESET.id); + const [spiralHeightMeters, setSpiralHeightMeters] = useState(DEFAULT_CAMERA_SURVEY_PRESET.heightAboveGroundMeters); + const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(DEFAULT_CAMERA_SURVEY_PRESET.speedMetersPerSecond); + const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(DEFAULT_CAMERA_SURVEY_PRESET.pitchMetersPerTurn); + const [spiralTargetRadiusMeters, setSpiralTargetRadiusMeters] = useState(DEFAULT_CAMERA_SURVEY_PRESET.targetRadiusMeters); 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. @@ -268,6 +301,18 @@ export const MapFixturePreview = forwardRef(null); const [providerStatus, setProviderStatus] = useState(initialProviderStatus); const [cacheRefresh, setCacheRefresh] = useState(false); + const spiralPresetOptions = useMemo>>(() => [ + ...CAMERA_SURVEY_PRESETS.map((preset) => ({ + value: preset.id, + label: preset.label, + description: `шаг ${formatMetricDistance(preset.pitchMetersPerTurn)} · до ${formatMetricDistance(preset.targetRadiusMeters)}`, + })), + ...(spiralPresetId === "custom" ? [{ + value: "custom" as const, + label: "Пользовательский", + description: "Значения изменены вручную", + }] : []), + ], [spiralPresetId]); const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; const presentation = useMemo( () => ({ ...mapSettings, cacheRefresh }), @@ -303,24 +348,124 @@ export const MapFixturePreview = forwardRef { - setAnimationModeEnabled(enabled); - setSpiralMessage(null); - if (!enabled) { - mapRendererRef.current?.stopSpiralAnimation("mode_disabled"); - setSpiralRunning(false); + const prepareAnimationSurvey = () => { + const failedProviderNeedsRetry = [providerStatus.imagery, providerStatus.terrain, providerStatus.buildings] + .some((state) => state === "error" || state === "not-configured"); + const rendererRestartRequired = !mapSettings.cacheEnabled + || !mapSettings.cacheNoOverwrite + || cacheRefresh + || failedProviderNeedsRetry; + updateMapSettings({ + imageryVisible: true, + // Monochrome deliberately hides the imagery layer. A cache survey must + // render it so Cesium actually requests every visible imagery tile. + monochrome: false, + cacheEnabled: true, + cacheNoOverwrite: true, + terrainEnabled: true, + buildingsVisible: true, + // The measured OSM hierarchy and presets use the canonical SSE value. + buildingsDetail: 16, + }); + setCacheRefresh(false); + if (rendererRestartRequired) { + setMapRendererReady(false); + setProviderStatus(initialProviderStatus); + setRendererRevision((value) => value + 1); } }; + const setAnimationMode = (enabled: boolean) => { + if (enabled === animationModeEnabled) return; + setAnimationModeEnabled(enabled); + setSpiralMessage(null); + if (enabled) { + // Survey preparation is transient. Save the page presentation before + // forcing all cacheable providers on, and restore it when the mode ends. + animationSettingsSnapshotRef.current = { + imageryVisible: mapSettings.imageryVisible, + monochrome: mapSettings.monochrome, + cacheEnabled: mapSettings.cacheEnabled, + cacheNoOverwrite: mapSettings.cacheNoOverwrite, + terrainEnabled: mapSettings.terrainEnabled, + buildingsVisible: mapSettings.buildingsVisible, + buildingsDetail: mapSettings.buildingsDetail, + }; + prepareAnimationSurvey(); + void verifyGateway(); + } else { + mapRendererRef.current?.stopSpiralAnimation("mode_disabled"); + setSpiralRunning(false); + const snapshot = animationSettingsSnapshotRef.current; + animationSettingsSnapshotRef.current = null; + if (snapshot) { + const rendererRestartRequired = snapshot.cacheEnabled !== mapSettings.cacheEnabled + || snapshot.cacheNoOverwrite !== mapSettings.cacheNoOverwrite; + // Restore only fields owned by the survey overlay. Any unrelated + // Inspector edits made while the mode was open remain intact. + setMapSettings((current) => ({ ...current, ...snapshot })); + setCacheRefresh(false); + if (rendererRestartRequired) { + setMapRendererReady(false); + setProviderStatus(initialProviderStatus); + setRendererRevision((value) => value + 1); + } + } + } + }; + + const selectSpiralPreset = (presetId: CameraSurveySelection) => { + if (presetId === "custom") return; + const preset = findCameraSurveyPreset(presetId); + if (!preset) return; + setSpiralPresetId(preset.id); + setSpiralHeightMeters(preset.heightAboveGroundMeters); + setSpiralSpeedMetersPerSecond(preset.speedMetersPerSecond); + setSpiralPitchMetersPerTurn(preset.pitchMetersPerTurn); + setSpiralTargetRadiusMeters(preset.targetRadiusMeters); + setSpiralMessage(null); + }; + + const spiralSurveyConfigured = mapSettings.cacheEnabled + && mapSettings.cacheNoOverwrite + && mapSettings.imageryVisible + && !mapSettings.monochrome + && mapSettings.terrainEnabled + && mapSettings.buildingsVisible + && mapSettings.buildingsDetail === 16; + const spiralProvidersReady = providerStatus.imagery === "ready" + && providerStatus.terrain === "ready" + && providerStatus.buildings === "ready"; + const spiralGatewayHealthFresh = Boolean( + gatewayLastVerifiedAt + && Date.now() - gatewayLastVerifiedAt.getTime() <= SURVEY_GATEWAY_HEALTH_MAX_AGE_MS + && gatewayCheckState !== "error" + && gatewayCheckState !== "stale", + ); + const spiralTileCacheReady = spiralGatewayHealthFresh && isWritableAppendOnlyTileCache(gatewayHealth); + const spiralCanStart = mapRendererReady && spiralSurveyConfigured && spiralProvidersReady && spiralTileCacheReady; + + useEffect(() => { + if (!spiralRunning || (spiralSurveyConfigured && spiralTileCacheReady)) return; + mapRendererRef.current?.stopSpiralAnimation("mode_disabled"); + }, [spiralRunning, spiralSurveyConfigured, spiralTileCacheReady]); + const toggleSpiralAnimation = () => { if (spiralRunning) { mapRendererRef.current?.stopSpiralAnimation("stopped"); return; } + if (!spiralCanStart) { + setSpiralMessage("Подождите готовности Imagery, Terrain, OSM Buildings и свежей проверки writable append-only TileCache."); + return; + } const started = mapRendererRef.current?.startSpiralAnimation({ heightAboveGroundMeters: spiralHeightMeters, speedMetersPerSecond: spiralSpeedMetersPerSecond, pitchMetersPerTurn: spiralPitchMetersPerTurn, + targetRadiusMeters: spiralTargetRadiusMeters, + viewPitchRadians: -Math.PI / 2 + 0.01, + waitForTiles: true, }) ?? false; if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации."); }; @@ -329,7 +474,10 @@ export const MapFixturePreview = forwardRef ({ schemaVersion: 1, pageId: "map", - settings: mapSettings, + // Survey-only layer overrides never leak into a saved page layout. + settings: animationSettingsSnapshotRef.current + ? { ...mapSettings, ...animationSettingsSnapshotRef.current } + : mapSettings, mapHeight: Math.round(mapHeight), camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera, pinBindings, @@ -435,7 +583,7 @@ export const MapFixturePreview = forwardRef { - if (!inspectorOpen && !layersOpen) return; + if (!inspectorOpen && !layersOpen && !animationModeEnabled) return; void verifyGateway(); const interval = window.setInterval(() => void verifyGateway(), 15_000); return () => { @@ -446,7 +594,7 @@ export const MapFixturePreview = forwardRef {animationModeEnabled ? <> - Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта. + Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса. + +