feat(foundry): add OSM cache survey presets

This commit is contained in:
Codex
2026-07-16 16:03:32 +03:00
parent e96401358b
commit 751d9963f1
7 changed files with 656 additions and 31 deletions
+140 -10
View File
@@ -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<CesiumMapRendererHandle, {
spiralSessionRef.current = null;
if (session.requestId !== null) window.cancelAnimationFrame(session.requestId);
if (!session.viewer.isDestroyed()) {
if (session.buildingsTileset && !session.buildingsTileset.isDestroyed()) {
if (session.previousBuildingsCullRequestsWhileMoving !== null) {
session.buildingsTileset.cullRequestsWhileMoving = session.previousBuildingsCullRequestsWhileMoving;
}
if (session.previousBuildingsFoveatedScreenSpaceError !== null) {
session.buildingsTileset.foveatedScreenSpaceError = session.previousBuildingsFoveatedScreenSpaceError;
}
if (session.previousBuildingsFoveatedTimeDelay !== null) {
session.buildingsTileset.foveatedTimeDelay = session.previousBuildingsFoveatedTimeDelay;
}
}
session.viewer.scene.screenSpaceCameraController.enableInputs = session.previousCameraInputsEnabled;
session.viewer.scene.requestRender();
rebuildGridRef.current?.();
@@ -683,27 +721,48 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const position = viewer?.camera.positionCartographic;
if (!viewer || viewer.isDestroyed() || !position) return false;
if (![input.heightAboveGroundMeters, input.speedMetersPerSecond, input.pitchMetersPerTurn].every(Number.isFinite)) return false;
if (input.targetRadiusMeters !== undefined && !Number.isFinite(input.targetRadiusMeters)) return false;
if (input.viewPitchRadians !== undefined && !Number.isFinite(input.viewPitchRadians)) return false;
const terrainProvider = presentationRef.current.terrainEnabled ? terrainRef.current?.world ?? null : null;
if (presentationRef.current.terrainEnabled && !terrainProvider) return false;
const heightAboveGroundMeters = clamp(input.heightAboveGroundMeters, 10, 100_000);
const config: CameraSpiralConfig = {
heightAboveGroundMeters: clamp(input.heightAboveGroundMeters, 10, 100_000),
heightAboveGroundMeters,
speedMetersPerSecond: clamp(input.speedMetersPerSecond, 1, 5_000),
pitchMetersPerTurn: clamp(input.pitchMetersPerTurn, 20, 100_000),
// Preserve overlap even in a narrow/portrait viewport. Presets express
// an upper bound; the live frustum may reduce the actual turn pitch.
pitchMetersPerTurn: clamp(
cameraSurveyPitchForViewport(
heightAboveGroundMeters,
input.pitchMetersPerTurn,
horizontalFieldOfViewRadians(viewer),
),
1,
100_000,
),
targetRadiusMeters: clamp(input.targetRadiusMeters ?? 25_000, 100, MAX_SPIRAL_RADIUS_METERS),
...(input.viewPitchRadians !== undefined
? { viewPitchRadians: clamp(input.viewPitchRadians, -Math.PI / 2 + 0.01, -0.05) }
: {}),
waitForTiles: input.waitForTiles === true,
};
stopSpiralAnimation("renderer_restarted");
viewer.camera.cancelFlight();
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
const controller = viewer.scene.screenSpaceCameraController;
const buildingsTileset = buildingsRef.current;
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),
pitch: config.viewPitchRadians
?? 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,
tilesWaitStartedAt: null,
requestId: null,
previousCameraInputsEnabled: controller.enableInputs,
terrainProvider,
@@ -714,7 +773,23 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
terrainRequestGeneration: 0,
terrainRetryAt: 0,
terrainSampleSpacingMeters: clamp(config.speedMetersPerSecond / 30, 10, 250),
targetSurfaceDistanceMeters: cameraSurveySpiralDistance(
config.targetRadiusMeters ?? 25_000,
config.pitchMetersPerTurn,
),
buildingsTileset,
previousBuildingsCullRequestsWhileMoving: buildingsTileset?.cullRequestsWhileMoving ?? null,
previousBuildingsFoveatedScreenSpaceError: buildingsTileset?.foveatedScreenSpaceError ?? null,
previousBuildingsFoveatedTimeDelay: buildingsTileset?.foveatedTimeDelay ?? null,
};
// A survey must request the whole visible OSM Buildings footprint while
// the camera moves. Cesium's interactive defaults deliberately defer and
// cull edge requests; restore them when the survey stops.
if (buildingsTileset) {
buildingsTileset.cullRequestsWhileMoving = false;
buildingsTileset.foveatedScreenSpaceError = false;
buildingsTileset.foveatedTimeDelay = 0;
}
controller.enableInputs = false;
spiralSessionRef.current = session;
@@ -755,6 +830,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const requestTerrainSamples = () => {
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<CesiumMapRendererHandle, {
const distances: number[] = [];
const positions: Cartographic[] = [];
let reachedExtent = false;
for (let index = 0; index < 120; index += 1) {
const distanceMeters = startDistance + index * session.terrainSampleSpacingMeters;
let reachedTargetDistance = false;
const plannedDistances = cameraSurveySampleDistances(
startDistance,
session.terrainSampleSpacingMeters,
session.targetSurfaceDistanceMeters,
120,
);
for (const distanceMeters of plannedDistances) {
try {
const frame = spiralSurfaceFrame(
session.origin,
@@ -775,10 +857,13 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
);
distances.push(distanceMeters);
positions.push(new Cartographic(frame.longitude, frame.latitude, 0));
if (distanceMeters >= 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<CesiumMapRendererHandle, {
});
session.terrainSamples = [...session.terrainSamples, ...nextSamples]
.filter((sample) => 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<CesiumMapRendererHandle, {
if (document.hidden) {
session.lastTimestamp = null;
session.pendingElapsedSeconds = 0;
session.tilesWaitStartedAt = null;
} else if (session.lastTimestamp === null) {
session.lastTimestamp = timestamp;
} else {
const elapsedSeconds = Math.max(0, (timestamp - session.lastTimestamp) / 1000);
const currentTilesReady = !session.config.waitForTiles
|| (viewer.scene.globe.tilesLoaded && (!session.buildingsTileset || session.buildingsTileset.tilesLoaded));
if (!currentTilesReady) {
session.tilesWaitStartedAt ??= timestamp;
if (timestamp - session.tilesWaitStartedAt >= 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<CesiumMapRendererHandle, {
candidateDistance,
session.config.pitchMetersPerTurn,
);
if (candidateFrame.radiusMeters >= (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<CesiumMapRendererHandle, {
if (!session || !document.hidden) return;
session.lastTimestamp = null;
session.pendingElapsedSeconds = 0;
session.tilesWaitStartedAt = null;
};
window.addEventListener("pagehide", stopForPageLeave);
document.addEventListener("visibilitychange", resetHiddenClock);
@@ -960,6 +1074,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
let removeGridCameraListener: (() => 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<CesiumMapRendererHandle, {
buildings: "Platform Map Gateway недоступен",
},
};
const providerTileFailureLatched = { imagery: false, terrain: false, buildings: false };
const reportProvider = (provider: "imagery" | "terrain" | "buildings", state: MapProviderState, error?: unknown) => {
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<CesiumMapRendererHandle, {
}
if (!cancelled) onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
};
const latchProviderTileFailure = (provider: "imagery" | "terrain" | "buildings") => {
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<CesiumMapRendererHandle, {
tileProtocol: "https",
});
if (cancelled || !viewer || viewer.isDestroyed()) return;
removeProviderFailureListeners.push(imageryProvider.errorEvent.addEventListener(() => 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<CesiumMapRendererHandle, {
const terrainResource = buildResource(terrainEndpoint.url);
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
if (cancelled || !viewer || viewer.isDestroyed()) return;
removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => 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<CesiumMapRendererHandle, {
reportProvider("terrain", "ready");
}).catch((error) => 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<CesiumMapRendererHandle, {
removeGridCameraListener?.();
removeRefreshRenderListener?.();
removeRenderErrorListener?.();
for (const removeListener of removeProviderFailureListeners) removeListener();
handler?.destroy();
if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null;
+201 -18
View File
@@ -1,5 +1,6 @@
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, Select, Window } from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react";
import type {
CameraSpiralState,
CesiumMapRendererHandle,
@@ -9,16 +10,29 @@ import type {
MapProviderStatus,
} from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import {
CAMERA_SURVEY_PRESETS,
DEFAULT_CAMERA_SURVEY_PRESET,
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
cameraSurveySpiralDistance,
findCameraSurveyPreset,
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
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<MapFixturePreviewHandle, {
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
const [mapRendererReady, setMapRendererReady] = useState(false);
const [animationModeEnabled, setAnimationModeEnabled] = useState(false);
const animationSettingsSnapshotRef = useRef<SurveySettingsSnapshot | null>(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<CameraSurveySelection>(DEFAULT_CAMERA_SURVEY_PRESET.id);
const [spiralHeightMeters, setSpiralHeightMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.heightAboveGroundMeters);
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.speedMetersPerSecond);
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.pitchMetersPerTurn);
const [spiralTargetRadiusMeters, setSpiralTargetRadiusMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.targetRadiusMeters);
const [spiralMessage, setSpiralMessage] = useState<string | null>(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<MapFixturePreviewHandle, {
const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null);
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
const [cacheRefresh, setCacheRefresh] = useState(false);
const spiralPresetOptions = useMemo<Array<SelectOption<CameraSurveySelection>>>(() => [
...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<MapPresentation>(
() => ({ ...mapSettings, cacheRefresh }),
@@ -303,24 +348,124 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
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 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<MapFixturePreviewHandle, {
getLayout: () => ({
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<MapFixturePreviewHandle, {
}, [beginGatewayHealthVerification, isLatestGatewayHealthRequest, rememberGatewayHealth]);
useEffect(() => {
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<MapFixturePreviewHandle, {
pending.controller.abort();
}
};
}, [inspectorOpen, layersOpen, verifyGateway]);
}, [animationModeEnabled, inspectorOpen, layersOpen, verifyGateway]);
const liveCacheStatus = gatewayHealth?.cache;
const liveCacheSummary = liveCacheStatus
@@ -566,7 +714,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
content: <>
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
{animationModeEnabled ? <>
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта.</small>
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
<ControlRow label="Профиль покрытия">
<Select
value={spiralPresetId}
options={spiralPresetOptions}
label="Профиль покрытия камеры"
disabled={spiralRunning}
onChange={selectSpiralPreset}
/>
</ControlRow>
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
<RangeControl
label="Высота над землёй"
value={logarithmicControlValue(spiralHeightMeters)}
@@ -575,7 +734,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralHeightMeters(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Скорость камеры"
@@ -585,7 +747,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricSpeed(10 ** value)}
onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Шаг спирали"
@@ -595,9 +760,27 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value));
}}
/>
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!mapRendererReady}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
<RangeControl
label="Радиус прохода"
value={logarithmicControlValue(spiralTargetRadiusMeters)}
min={logarithmicControlValue(1_000)}
max={logarithmicControlValue(250_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value));
}}
/>
<small className="catalog-map-inspector__note">Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время.</small>
{!spiralCanStart && !spiralRunning ? <small className="catalog-map-inspector__note" role="status">Подготовка: imagery {providerStateLabel[providerStatus.imagery]}, terrain {providerStateLabel[providerStatus.terrain]}, OSM Buildings {providerStateLabel[providerStatus.buildings]}, TileCache {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}.</small> : null}
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!spiralRunning && !spiralCanStart}>{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}
+123
View File
@@ -0,0 +1,123 @@
/**
* Geometric-error bands observed in the current Cesium OSM Buildings 96188
* hierarchy. They are ordered from the global root towards maximum detail.
* The provider may publish a newer hierarchy later, so these values are a
* measured runtime profile rather than a permanent Cesium API contract.
*/
export const OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS = [
77_067.33978,
38_533.66989,
19_266.834945,
9_633.417472,
4_816.708736,
2_408.354368,
1_204.177184,
602.088592,
301.044296,
150.522148,
75.261074,
37.630537,
18.815269,
9.407634,
4.703817,
2.351909,
] as const;
export const CAMERA_SURVEY_CROSS_TRACK_FRACTION = 0.69;
export type CameraSurveyPreset = {
id: string;
profile: number;
label: string;
heightAboveGroundMeters: number;
pitchMetersPerTurn: number;
speedMetersPerSecond: number;
targetRadiusMeters: number;
};
/**
* Ten operational scale profiles cover a regional survey from street to
* macro scale. They deliberately do not claim a one-to-one provider LOD:
* Cesium selects imagery, terrain and 3D Tiles through different SSE rules,
* viewport geometry, pixel ratio, availability and tile bounding volumes.
*
* Height and turn pitch double together. With a 60 degree horizontal field
* of view and a near-nadir camera, pitch = 0.8 * height keeps about 31%
* cross-track overlap. Runtime additionally clamps pitch to the live viewport,
* so a narrow inspector cannot open gaps between turns.
*
* Speed is a ceiling, not a promise: the renderer pauses whenever current
* Imagery, Terrain or OSM Buildings requests are unsettled. A 25 km target
* radius makes every profile a finite city-scale pass instead of silently
* continuing to the 250 km geodesic safety boundary.
*/
export const CAMERA_SURVEY_PRESETS = [
{ id: "osm-detail-1", profile: 1, label: "Профиль 1 · 80 м", heightAboveGroundMeters: 80, pitchMetersPerTurn: 64, speedMetersPerSecond: 256, targetRadiusMeters: 25_000 },
{ id: "osm-detail-2", profile: 2, label: "Профиль 2 · 160 м", heightAboveGroundMeters: 160, pitchMetersPerTurn: 128, speedMetersPerSecond: 512, targetRadiusMeters: 25_000 },
{ id: "osm-detail-3", profile: 3, label: "Профиль 3 · 320 м", heightAboveGroundMeters: 320, pitchMetersPerTurn: 256, speedMetersPerSecond: 1_024, targetRadiusMeters: 25_000 },
{ id: "osm-detail-4", profile: 4, label: "Профиль 4 · 640 м", heightAboveGroundMeters: 640, pitchMetersPerTurn: 512, speedMetersPerSecond: 2_048, targetRadiusMeters: 25_000 },
{ id: "osm-detail-5", profile: 5, label: "Профиль 5 · 1,28 км", heightAboveGroundMeters: 1_280, pitchMetersPerTurn: 1_024, speedMetersPerSecond: 4_096, targetRadiusMeters: 25_000 },
{ id: "osm-detail-6", profile: 6, label: "Профиль 6 · 2,56 км", heightAboveGroundMeters: 2_560, pitchMetersPerTurn: 2_048, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-7", profile: 7, label: "Профиль 7 · 5,12 км", heightAboveGroundMeters: 5_120, pitchMetersPerTurn: 4_096, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-8", profile: 8, label: "Профиль 8 · 10,24 км", heightAboveGroundMeters: 10_240, pitchMetersPerTurn: 8_192, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-9", profile: 9, label: "Профиль 9 · 20,48 км", heightAboveGroundMeters: 20_480, pitchMetersPerTurn: 16_384, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-10", profile: 10, label: "Профиль 10 · 40,96 км", heightAboveGroundMeters: 40_960, pitchMetersPerTurn: 32_768, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
] as const satisfies readonly CameraSurveyPreset[];
export type CameraSurveyPresetId = (typeof CAMERA_SURVEY_PRESETS)[number]["id"];
export type CameraSurveySelection = CameraSurveyPresetId | "custom";
export const DEFAULT_CAMERA_SURVEY_PRESET = CAMERA_SURVEY_PRESETS[0];
export const OSM_BUILDINGS_OBSERVED_BAND_COUNT = OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS.length;
export function findCameraSurveyPreset(id: CameraSurveyPresetId) {
return CAMERA_SURVEY_PRESETS.find((preset) => preset.id === id);
}
export function cameraSurveyCrossTrackWidth(heightAboveGroundMeters: number, horizontalFieldOfViewDegrees = 60) {
return 2 * heightAboveGroundMeters * Math.tan(horizontalFieldOfViewDegrees * Math.PI / 360);
}
export function cameraSurveyTurnOverlap(preset: CameraSurveyPreset, horizontalFieldOfViewDegrees = 60) {
const width = cameraSurveyCrossTrackWidth(preset.heightAboveGroundMeters, horizontalFieldOfViewDegrees);
return 1 - preset.pitchMetersPerTurn / width;
}
export function cameraSurveyPitchForViewport(
heightAboveGroundMeters: number,
requestedPitchMeters: number,
horizontalFieldOfViewRadians: number,
) {
const crossTrackWidthMeters = 2 * heightAboveGroundMeters * Math.tan(horizontalFieldOfViewRadians / 2);
return Math.min(requestedPitchMeters, crossTrackWidthMeters * CAMERA_SURVEY_CROSS_TRACK_FRACTION);
}
export function cameraSurveySpiralDistance(targetRadiusMeters: number, pitchMetersPerTurn: number) {
const angle = (Math.PI * 2 * targetRadiusMeters) / pitchMetersPerTurn;
const radialGrowth = pitchMetersPerTurn / (Math.PI * 2);
return (radialGrowth / 2) * (
angle * Math.sqrt(1 + angle * angle)
+ Math.asinh(angle)
);
}
export function cameraSurveySampleDistances(
startDistanceMeters: number,
spacingMeters: number,
targetDistanceMeters: number,
maximumSamples: number,
) {
if (startDistanceMeters > targetDistanceMeters || maximumSamples <= 0) return [];
const distances: number[] = [];
for (let index = 0; index < maximumSamples; index += 1) {
const distanceMeters = Math.min(startDistanceMeters + index * spacingMeters, targetDistanceMeters);
distances.push(distanceMeters);
if (distanceMeters >= targetDistanceMeters) break;
}
return distances;
}
export function cameraSurveyTravelSeconds(preset: CameraSurveyPreset) {
return cameraSurveySpiralDistance(preset.targetRadiusMeters, preset.pitchMetersPerTurn)
/ preset.speedMetersPerSecond;
}