feat(foundry): add OSM cache survey presets
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user