feat(foundry): add map camera spiral controls
This commit is contained in:
parent
5e8c1cc3fc
commit
e96401358b
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Cartesian2,
|
Cartesian2,
|
||||||
Cartesian3,
|
Cartesian3,
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Cesium3DTileStyle,
|
Cesium3DTileStyle,
|
||||||
CesiumTerrainProvider,
|
CesiumTerrainProvider,
|
||||||
CallbackProperty,
|
CallbackProperty,
|
||||||
|
Cartographic,
|
||||||
ConstantPositionProperty,
|
ConstantPositionProperty,
|
||||||
CustomDataSource,
|
CustomDataSource,
|
||||||
DefaultProxy,
|
DefaultProxy,
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
PolygonHierarchy,
|
PolygonHierarchy,
|
||||||
PointGraphics,
|
PointGraphics,
|
||||||
Resource,
|
Resource,
|
||||||
|
sampleTerrainMostDetailed,
|
||||||
ScreenSpaceEventHandler,
|
ScreenSpaceEventHandler,
|
||||||
ScreenSpaceEventType,
|
ScreenSpaceEventType,
|
||||||
SunLight,
|
SunLight,
|
||||||
|
|
@ -35,9 +37,12 @@ import {
|
||||||
} from "cesium";
|
} from "cesium";
|
||||||
import "cesium/Build/Cesium/Widgets/widgets.css";
|
import "cesium/Build/Cesium/Widgets/widgets.css";
|
||||||
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
||||||
|
import { spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js";
|
||||||
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
type Position = [number, number, number?];
|
type Position = [number, number, number?];
|
||||||
|
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
||||||
|
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
||||||
type PinPresentation = {
|
type PinPresentation = {
|
||||||
variant: "elevated-spike";
|
variant: "elevated-spike";
|
||||||
stemHeightMeters: number;
|
stemHeightMeters: number;
|
||||||
|
|
@ -177,6 +182,50 @@ export type MapCameraView = {
|
||||||
roll: number;
|
roll: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CameraSpiralConfig = {
|
||||||
|
heightAboveGroundMeters: number;
|
||||||
|
speedMetersPerSecond: number;
|
||||||
|
pitchMetersPerTurn: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CameraSpiralState = {
|
||||||
|
running: boolean;
|
||||||
|
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CesiumMapRendererHandle = {
|
||||||
|
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
|
||||||
|
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
|
||||||
|
getCameraView: () => MapCameraView | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TerrainRouteSample = {
|
||||||
|
distanceMeters: number;
|
||||||
|
heightMeters: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpiralSession = {
|
||||||
|
viewer: Viewer;
|
||||||
|
origin: GeodeticRadians;
|
||||||
|
initialHeading: number;
|
||||||
|
pitch: number;
|
||||||
|
roll: number;
|
||||||
|
config: CameraSpiralConfig;
|
||||||
|
surfaceDistanceMeters: number;
|
||||||
|
lastTimestamp: number | null;
|
||||||
|
pendingElapsedSeconds: number;
|
||||||
|
requestId: number | null;
|
||||||
|
previousCameraInputsEnabled: boolean;
|
||||||
|
terrainProvider: CesiumTerrainProvider | null;
|
||||||
|
terrainSamples: TerrainRouteSample[];
|
||||||
|
terrainSamplePending: boolean;
|
||||||
|
terrainSampleExhausted: boolean;
|
||||||
|
terrainSampleFailures: number;
|
||||||
|
terrainRequestGeneration: number;
|
||||||
|
terrainRetryAt: number;
|
||||||
|
terrainSampleSpacingMeters: number;
|
||||||
|
};
|
||||||
|
|
||||||
const toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height);
|
const toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height);
|
||||||
const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
|
const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
|
||||||
const accent = Color.fromCssColorString("#ff2f92");
|
const accent = Color.fromCssColorString("#ff2f92");
|
||||||
|
|
@ -211,6 +260,21 @@ function getCameraView(viewer: Viewer): MapCameraView {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMeters: number) {
|
||||||
|
if (samples.length === 0) return null;
|
||||||
|
if (distanceMeters === samples[0].distanceMeters) return samples[0].heightMeters;
|
||||||
|
for (let index = 1; index < samples.length; index += 1) {
|
||||||
|
const upper = samples[index];
|
||||||
|
if (distanceMeters > upper.distanceMeters) continue;
|
||||||
|
const lower = samples[index - 1];
|
||||||
|
const span = upper.distanceMeters - lower.distanceMeters;
|
||||||
|
if (span <= 0) return upper.heightMeters;
|
||||||
|
const progress = clamp((distanceMeters - lower.distanceMeters) / span, 0, 1);
|
||||||
|
return lower.heightMeters + (upper.heightMeters - lower.heightMeters) * progress;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function addFixtureEntities(viewer: Viewer) {
|
function addFixtureEntities(viewer: Viewer) {
|
||||||
const styleProfiles = new Map<string, MapStyleProfile>(
|
const styleProfiles = new Map<string, MapStyleProfile>(
|
||||||
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
|
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
|
||||||
|
|
@ -540,25 +604,29 @@ function applyPresentation(
|
||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CesiumMapRenderer({
|
export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||||
onSelect,
|
|
||||||
onGatewayHealth,
|
|
||||||
onProviderStatus,
|
|
||||||
onCameraChange,
|
|
||||||
onCacheRefreshConsumed,
|
|
||||||
initialCamera,
|
|
||||||
presentation,
|
|
||||||
runtimeBindings = [],
|
|
||||||
}: {
|
|
||||||
onSelect?: (entityId: string) => void;
|
onSelect?: (entityId: string) => void;
|
||||||
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
|
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
|
||||||
onProviderStatus?: (status: MapProviderStatus) => void;
|
onProviderStatus?: (status: MapProviderStatus) => void;
|
||||||
onCameraChange?: (camera: MapCameraView) => void;
|
onCameraChange?: (camera: MapCameraView) => void;
|
||||||
onCacheRefreshConsumed?: () => void;
|
onCacheRefreshConsumed?: () => void;
|
||||||
|
onReadyChange?: (ready: boolean) => void;
|
||||||
|
onSpiralStateChange?: (state: CameraSpiralState) => void;
|
||||||
initialCamera?: MapCameraView;
|
initialCamera?: MapCameraView;
|
||||||
presentation: MapPresentation;
|
presentation: MapPresentation;
|
||||||
runtimeBindings?: MapRuntimeBinding[];
|
runtimeBindings?: MapRuntimeBinding[];
|
||||||
}) {
|
}>(function CesiumMapRenderer({
|
||||||
|
onSelect,
|
||||||
|
onGatewayHealth,
|
||||||
|
onProviderStatus,
|
||||||
|
onCameraChange,
|
||||||
|
onCacheRefreshConsumed,
|
||||||
|
onReadyChange,
|
||||||
|
onSpiralStateChange,
|
||||||
|
initialCamera,
|
||||||
|
presentation,
|
||||||
|
runtimeBindings = [],
|
||||||
|
}, ref) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const creditContainerRef = useRef<HTMLDivElement>(null);
|
const creditContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewerRef = useRef<Viewer | null>(null);
|
const viewerRef = useRef<Viewer | null>(null);
|
||||||
|
|
@ -572,6 +640,9 @@ export function CesiumMapRenderer({
|
||||||
const onSelectRef = useRef(onSelect);
|
const onSelectRef = useRef(onSelect);
|
||||||
const onCameraChangeRef = useRef(onCameraChange);
|
const onCameraChangeRef = useRef(onCameraChange);
|
||||||
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
|
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
|
||||||
|
const onReadyChangeRef = useRef(onReadyChange);
|
||||||
|
const onSpiralStateChangeRef = useRef(onSpiralStateChange);
|
||||||
|
const spiralSessionRef = useRef<SpiralSession | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSelectRef.current = onSelect;
|
onSelectRef.current = onSelect;
|
||||||
|
|
@ -585,11 +656,295 @@ export function CesiumMapRenderer({
|
||||||
onCacheRefreshConsumedRef.current = onCacheRefreshConsumed;
|
onCacheRefreshConsumedRef.current = onCacheRefreshConsumed;
|
||||||
}, [onCacheRefreshConsumed]);
|
}, [onCacheRefreshConsumed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onReadyChangeRef.current = onReadyChange;
|
||||||
|
}, [onReadyChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSpiralStateChangeRef.current = onSpiralStateChange;
|
||||||
|
}, [onSpiralStateChange]);
|
||||||
|
|
||||||
|
const stopSpiralAnimation = useCallback((reason: CameraSpiralState["reason"] = "stopped") => {
|
||||||
|
const session = spiralSessionRef.current;
|
||||||
|
if (!session) return;
|
||||||
|
spiralSessionRef.current = null;
|
||||||
|
if (session.requestId !== null) window.cancelAnimationFrame(session.requestId);
|
||||||
|
if (!session.viewer.isDestroyed()) {
|
||||||
|
session.viewer.scene.screenSpaceCameraController.enableInputs = session.previousCameraInputsEnabled;
|
||||||
|
session.viewer.scene.requestRender();
|
||||||
|
rebuildGridRef.current?.();
|
||||||
|
onCameraChangeRef.current?.(getCameraView(session.viewer));
|
||||||
|
}
|
||||||
|
onSpiralStateChangeRef.current?.({ running: false, reason });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startSpiralAnimation = useCallback((input: CameraSpiralConfig) => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
const position = viewer?.camera.positionCartographic;
|
||||||
|
if (!viewer || viewer.isDestroyed() || !position) return false;
|
||||||
|
if (![input.heightAboveGroundMeters, input.speedMetersPerSecond, input.pitchMetersPerTurn].every(Number.isFinite)) return false;
|
||||||
|
const terrainProvider = presentationRef.current.terrainEnabled ? terrainRef.current?.world ?? null : null;
|
||||||
|
if (presentationRef.current.terrainEnabled && !terrainProvider) return false;
|
||||||
|
const config: CameraSpiralConfig = {
|
||||||
|
heightAboveGroundMeters: clamp(input.heightAboveGroundMeters, 10, 100_000),
|
||||||
|
speedMetersPerSecond: clamp(input.speedMetersPerSecond, 1, 5_000),
|
||||||
|
pitchMetersPerTurn: clamp(input.pitchMetersPerTurn, 20, 100_000),
|
||||||
|
};
|
||||||
|
stopSpiralAnimation("renderer_restarted");
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
|
||||||
|
const controller = viewer.scene.screenSpaceCameraController;
|
||||||
|
const session: SpiralSession = {
|
||||||
|
viewer,
|
||||||
|
origin: { longitude: position.longitude, latitude: position.latitude },
|
||||||
|
initialHeading: Number.isFinite(viewer.camera.heading) ? viewer.camera.heading : 0,
|
||||||
|
pitch: clamp(Number.isFinite(viewer.camera.pitch) ? viewer.camera.pitch : -Math.PI / 4, -Math.PI / 2 + 0.01, -0.05),
|
||||||
|
roll: 0,
|
||||||
|
config,
|
||||||
|
surfaceDistanceMeters: 0,
|
||||||
|
lastTimestamp: null,
|
||||||
|
pendingElapsedSeconds: 0,
|
||||||
|
requestId: null,
|
||||||
|
previousCameraInputsEnabled: controller.enableInputs,
|
||||||
|
terrainProvider,
|
||||||
|
terrainSamples: [],
|
||||||
|
terrainSamplePending: false,
|
||||||
|
terrainSampleExhausted: false,
|
||||||
|
terrainSampleFailures: 0,
|
||||||
|
terrainRequestGeneration: 0,
|
||||||
|
terrainRetryAt: 0,
|
||||||
|
terrainSampleSpacingMeters: clamp(config.speedMetersPerSecond / 30, 10, 250),
|
||||||
|
};
|
||||||
|
controller.enableInputs = false;
|
||||||
|
spiralSessionRef.current = session;
|
||||||
|
|
||||||
|
const groundHeightAtDistance = (distanceMeters: number) => {
|
||||||
|
if (!session.terrainProvider) return 0;
|
||||||
|
// Only the most-detailed sampled route is stable enough for AGL.
|
||||||
|
// globe.getHeight() follows the currently rendered LOD and can jump as
|
||||||
|
// tiles refine, so it must not drive the camera altitude.
|
||||||
|
return interpolateTerrainRouteHeight(session.terrainSamples, distanceMeters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFrame = (frame: ReturnType<typeof spiralSurfaceFrame>, rawGround: number) => {
|
||||||
|
if (spiralSessionRef.current !== session || viewer.isDestroyed()) return;
|
||||||
|
const scene = viewer.scene as unknown as {
|
||||||
|
verticalExaggeration?: number;
|
||||||
|
verticalExaggerationRelativeHeight?: number;
|
||||||
|
};
|
||||||
|
const terrainActive = Boolean(session.terrainProvider);
|
||||||
|
const exaggeration = terrainActive && Number.isFinite(scene.verticalExaggeration) ? Number(scene.verticalExaggeration) : 1;
|
||||||
|
const relativeHeight = terrainActive && Number.isFinite(scene.verticalExaggerationRelativeHeight)
|
||||||
|
? Number(scene.verticalExaggerationRelativeHeight)
|
||||||
|
: 0;
|
||||||
|
const renderedGround = relativeHeight + (rawGround - relativeHeight) * exaggeration;
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: Cartesian3.fromRadians(
|
||||||
|
frame.longitude,
|
||||||
|
frame.latitude,
|
||||||
|
renderedGround + session.config.heightAboveGroundMeters,
|
||||||
|
),
|
||||||
|
orientation: {
|
||||||
|
heading: frame.tangentHeading,
|
||||||
|
pitch: session.pitch,
|
||||||
|
roll: session.roll,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestTerrainSamples = () => {
|
||||||
|
if (!session.terrainProvider || session.terrainSamplePending || spiralSessionRef.current !== session) return;
|
||||||
|
if (Date.now() < session.terrainRetryAt) return;
|
||||||
|
const lastSample = session.terrainSamples.at(-1);
|
||||||
|
// Prefetch before the camera reaches the buffer tail. At normal and
|
||||||
|
// high speeds this leaves roughly three seconds for Gateway/VPN latency.
|
||||||
|
if (lastSample && lastSample.distanceMeters - session.surfaceDistanceMeters > session.terrainSampleSpacingMeters * 90) return;
|
||||||
|
const startDistance = lastSample ? lastSample.distanceMeters + session.terrainSampleSpacingMeters : 0;
|
||||||
|
const distances: number[] = [];
|
||||||
|
const positions: Cartographic[] = [];
|
||||||
|
let reachedExtent = false;
|
||||||
|
for (let index = 0; index < 120; index += 1) {
|
||||||
|
const distanceMeters = startDistance + index * session.terrainSampleSpacingMeters;
|
||||||
|
try {
|
||||||
|
const frame = spiralSurfaceFrame(
|
||||||
|
session.origin,
|
||||||
|
session.initialHeading,
|
||||||
|
distanceMeters,
|
||||||
|
session.config.pitchMetersPerTurn,
|
||||||
|
);
|
||||||
|
distances.push(distanceMeters);
|
||||||
|
positions.push(new Cartographic(frame.longitude, frame.latitude, 0));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "spiral_extent_limit") {
|
||||||
|
reachedExtent = true;
|
||||||
|
session.terrainSampleExhausted = true;
|
||||||
|
} else {
|
||||||
|
stopSpiralAnimation("spiral_runtime_error");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (positions.length === 0 || spiralSessionRef.current !== session) {
|
||||||
|
if (reachedExtent && spiralSessionRef.current === session) stopSpiralAnimation("spiral_extent_limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.terrainSamplePending = true;
|
||||||
|
const requestGeneration = ++session.terrainRequestGeneration;
|
||||||
|
let timeoutId = 0;
|
||||||
|
const timeout = new Promise<never>((_resolve, reject) => {
|
||||||
|
timeoutId = window.setTimeout(() => reject(new Error("terrain_sample_timeout")), TERRAIN_SAMPLE_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
void Promise.race([
|
||||||
|
sampleTerrainMostDetailed(session.terrainProvider, positions),
|
||||||
|
timeout,
|
||||||
|
]).then((sampledPositions) => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
if (
|
||||||
|
spiralSessionRef.current !== session
|
||||||
|
|| session.terrainRequestGeneration !== requestGeneration
|
||||||
|
|| viewer.isDestroyed()
|
||||||
|
) return;
|
||||||
|
const nextSamples = sampledPositions.map((sampledPosition, index) => {
|
||||||
|
if (!Number.isFinite(sampledPosition.height)) throw new Error("terrain_height_unavailable");
|
||||||
|
return {
|
||||||
|
distanceMeters: distances[index],
|
||||||
|
heightMeters: Number(sampledPosition.height),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
session.terrainSamples = [...session.terrainSamples, ...nextSamples]
|
||||||
|
.filter((sample) => sample.distanceMeters >= session.surfaceDistanceMeters - session.terrainSampleSpacingMeters * 2);
|
||||||
|
session.terrainSamplePending = false;
|
||||||
|
session.terrainSampleFailures = 0;
|
||||||
|
session.terrainRetryAt = 0;
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
}).catch(() => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
if (spiralSessionRef.current !== session || session.terrainRequestGeneration !== requestGeneration) return;
|
||||||
|
session.terrainSamplePending = false;
|
||||||
|
session.terrainSampleFailures += 1;
|
||||||
|
if (session.terrainSampleFailures >= 4) {
|
||||||
|
stopSpiralAnimation("terrain_sampling_error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const retryDelay = [500, 1_500, 4_000][session.terrainSampleFailures - 1] ?? 4_000;
|
||||||
|
session.terrainRetryAt = Date.now() + retryDelay;
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = (timestamp: number) => {
|
||||||
|
if (spiralSessionRef.current !== session || viewer.isDestroyed()) return;
|
||||||
|
if (document.hidden) {
|
||||||
|
session.lastTimestamp = null;
|
||||||
|
session.pendingElapsedSeconds = 0;
|
||||||
|
} else if (session.lastTimestamp === null) {
|
||||||
|
session.lastTimestamp = timestamp;
|
||||||
|
} else {
|
||||||
|
const elapsedSeconds = Math.max(0, (timestamp - session.lastTimestamp) / 1000);
|
||||||
|
session.lastTimestamp = timestamp;
|
||||||
|
session.pendingElapsedSeconds += elapsedSeconds;
|
||||||
|
try {
|
||||||
|
let nextFrame: ReturnType<typeof spiralSurfaceFrame> | null = null;
|
||||||
|
let nextGround: number | null = null;
|
||||||
|
let substeps = 0;
|
||||||
|
while (session.pendingElapsedSeconds > 0.000_001 && substeps < MAX_SPIRAL_SUBSTEPS_PER_FRAME) {
|
||||||
|
const stepSeconds = Math.min(session.pendingElapsedSeconds, 1 / 30);
|
||||||
|
const candidateDistance = session.surfaceDistanceMeters + session.config.speedMetersPerSecond * stepSeconds;
|
||||||
|
const candidateFrame = spiralSurfaceFrame(
|
||||||
|
session.origin,
|
||||||
|
session.initialHeading,
|
||||||
|
candidateDistance,
|
||||||
|
session.config.pitchMetersPerTurn,
|
||||||
|
);
|
||||||
|
const candidateGround = groundHeightAtDistance(candidateDistance);
|
||||||
|
if (candidateGround === null) {
|
||||||
|
const lastTerrainSample = session.terrainSamples.at(-1);
|
||||||
|
if (session.terrainSampleExhausted && (!lastTerrainSample || candidateDistance > lastTerrainSample.distanceMeters)) {
|
||||||
|
throw new Error("spiral_extent_limit");
|
||||||
|
}
|
||||||
|
// Missing terrain is a loading state, never an ellipsoid-height
|
||||||
|
// fallback. Pause the route clock until a sampled buffer exists.
|
||||||
|
session.pendingElapsedSeconds = 0;
|
||||||
|
session.lastTimestamp = timestamp;
|
||||||
|
requestTerrainSamples();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
session.surfaceDistanceMeters = candidateDistance;
|
||||||
|
session.pendingElapsedSeconds -= stepSeconds;
|
||||||
|
nextFrame = candidateFrame;
|
||||||
|
nextGround = candidateGround;
|
||||||
|
substeps += 1;
|
||||||
|
}
|
||||||
|
if (nextFrame && nextGround !== null) applyFrame(nextFrame, nextGround);
|
||||||
|
requestTerrainSamples();
|
||||||
|
} catch (error) {
|
||||||
|
stopSpiralAnimation(error instanceof Error && error.message === "spiral_extent_limit"
|
||||||
|
? "spiral_extent_limit"
|
||||||
|
: "spiral_runtime_error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (spiralSessionRef.current === session) session.requestId = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
requestTerrainSamples();
|
||||||
|
const initialFrame = spiralSurfaceFrame(session.origin, session.initialHeading, 0, session.config.pitchMetersPerTurn);
|
||||||
|
const initialGround = groundHeightAtDistance(0);
|
||||||
|
if (initialGround !== null) applyFrame(initialFrame, initialGround);
|
||||||
|
} catch {
|
||||||
|
stopSpiralAnimation("spiral_runtime_error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
session.requestId = window.requestAnimationFrame(tick);
|
||||||
|
rebuildGridRef.current?.();
|
||||||
|
onSpiralStateChangeRef.current?.({ running: true });
|
||||||
|
return true;
|
||||||
|
}, [stopSpiralAnimation]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
startSpiralAnimation,
|
||||||
|
stopSpiralAnimation,
|
||||||
|
getCameraView: () => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
|
||||||
|
},
|
||||||
|
}), [startSpiralAnimation, stopSpiralAnimation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stopForPageLeave = () => stopSpiralAnimation("stopped");
|
||||||
|
const resetHiddenClock = () => {
|
||||||
|
const session = spiralSessionRef.current;
|
||||||
|
if (!session || !document.hidden) return;
|
||||||
|
session.lastTimestamp = null;
|
||||||
|
session.pendingElapsedSeconds = 0;
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", stopForPageLeave);
|
||||||
|
document.addEventListener("visibilitychange", resetHiddenClock);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", stopForPageLeave);
|
||||||
|
document.removeEventListener("visibilitychange", resetHiddenClock);
|
||||||
|
};
|
||||||
|
}, [stopSpiralAnimation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
presentationRef.current = presentation;
|
presentationRef.current = presentation;
|
||||||
|
const activeSpiral = spiralSessionRef.current;
|
||||||
|
if (activeSpiral && Boolean(activeSpiral.terrainProvider) !== presentation.terrainEnabled) {
|
||||||
|
stopSpiralAnimation("renderer_restarted");
|
||||||
|
}
|
||||||
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
|
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
|
||||||
rebuildGridRef.current?.();
|
rebuildGridRef.current?.();
|
||||||
}, [presentation]);
|
const viewer = viewerRef.current;
|
||||||
|
const terrain = terrainRef.current;
|
||||||
|
onReadyChangeRef.current?.(Boolean(
|
||||||
|
viewer
|
||||||
|
&& !viewer.isDestroyed()
|
||||||
|
&& terrain
|
||||||
|
&& (!presentation.terrainEnabled || terrain.world),
|
||||||
|
));
|
||||||
|
}, [presentation, stopSpiralAnimation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runtimeBindingsRef.current = runtimeBindings;
|
runtimeBindingsRef.current = runtimeBindings;
|
||||||
|
|
@ -705,6 +1060,7 @@ export function CesiumMapRenderer({
|
||||||
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
|
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
|
||||||
let renderRecoveryScheduled = false;
|
let renderRecoveryScheduled = false;
|
||||||
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
|
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
|
||||||
|
stopSpiralAnimation("render_error");
|
||||||
const raw = error instanceof Error ? error.message : "cesium_render_error";
|
const raw = error instanceof Error ? error.message : "cesium_render_error";
|
||||||
const safe = raw.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "cesium_render_error";
|
const safe = raw.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "cesium_render_error";
|
||||||
reportProvider("imagery", "error", safe);
|
reportProvider("imagery", "error", safe);
|
||||||
|
|
@ -748,6 +1104,7 @@ export function CesiumMapRenderer({
|
||||||
terrain.world = world;
|
terrain.world = world;
|
||||||
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||||
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
|
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
|
||||||
|
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
|
||||||
reportProvider("terrain", "ready");
|
reportProvider("terrain", "ready");
|
||||||
}).catch((error) => reportProvider("terrain", "error", error));
|
}).catch((error) => reportProvider("terrain", "error", error));
|
||||||
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
|
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
|
||||||
|
|
@ -794,6 +1151,7 @@ export function CesiumMapRenderer({
|
||||||
}
|
}
|
||||||
rebuildGrid();
|
rebuildGrid();
|
||||||
onCameraChangeRef.current?.(getCameraView(viewer));
|
onCameraChangeRef.current?.(getCameraView(viewer));
|
||||||
|
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
|
||||||
|
|
||||||
if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) {
|
if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) {
|
||||||
// All root provider resources for the current view were created with
|
// All root provider resources for the current view were created with
|
||||||
|
|
@ -838,6 +1196,8 @@ export function CesiumMapRenderer({
|
||||||
void start();
|
void start();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
stopSpiralAnimation("renderer_restarted");
|
||||||
|
onReadyChangeRef.current?.(false);
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
removeGridCameraListener?.();
|
removeGridCameraListener?.();
|
||||||
removeRefreshRenderListener?.();
|
removeRefreshRenderListener?.();
|
||||||
|
|
@ -851,7 +1211,7 @@ export function CesiumMapRenderer({
|
||||||
rebuildGridRef.current = null;
|
rebuildGridRef.current = null;
|
||||||
runtimeDataSourcesRef.current.clear();
|
runtimeDataSourcesRef.current.clear();
|
||||||
};
|
};
|
||||||
}, [onGatewayHealth, onProviderStatus]);
|
}, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="catalog-cesium-map">
|
<div className="catalog-cesium-map">
|
||||||
|
|
@ -859,4 +1219,4 @@ export function CesiumMapRenderer({
|
||||||
<div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" />
|
<div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||||||
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
|
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
|
||||||
import type { MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus } from "./CesiumMapRenderer.js";
|
import type {
|
||||||
|
CameraSpiralState,
|
||||||
|
CesiumMapRendererHandle,
|
||||||
|
MapCameraView,
|
||||||
|
MapGatewayHealth,
|
||||||
|
MapPresentation,
|
||||||
|
MapProviderStatus,
|
||||||
|
} from "./CesiumMapRenderer.js";
|
||||||
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
|
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
|
||||||
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
||||||
|
|
||||||
|
|
@ -161,6 +168,23 @@ const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
|
||||||
"not-configured": "не настроен",
|
"not-configured": "не настроен",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
|
||||||
|
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
|
||||||
|
const formatMetricDistance = (value: number) => value >= 1000
|
||||||
|
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км`
|
||||||
|
: `${Math.round(value)} м`;
|
||||||
|
const formatMetricSpeed = (value: number) => value >= 1000
|
||||||
|
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
|
||||||
|
: `${Math.round(value)} м/с`;
|
||||||
|
|
||||||
|
function spiralStopMessage(reason: CameraSpiralState["reason"]) {
|
||||||
|
if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута.";
|
||||||
|
if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка.";
|
||||||
|
if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры.";
|
||||||
|
if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
features?: PreviewFeatures;
|
features?: PreviewFeatures;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
|
@ -186,6 +210,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
}));
|
}));
|
||||||
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
|
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
|
||||||
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
|
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
|
||||||
|
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
|
||||||
|
const [mapRendererReady, setMapRendererReady] = useState(false);
|
||||||
|
const [animationModeEnabled, setAnimationModeEnabled] = useState(false);
|
||||||
|
const [spiralRunning, setSpiralRunning] = useState(false);
|
||||||
|
const [spiralHeightMeters, setSpiralHeightMeters] = useState(1500);
|
||||||
|
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(250);
|
||||||
|
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(10_000);
|
||||||
|
const [spiralMessage, setSpiralMessage] = useState<string | null>(null);
|
||||||
// Map pin bindings belong to the application page instance. They are kept
|
// Map pin bindings belong to the application page instance. They are kept
|
||||||
// intact when a human changes camera or visual settings and presses Save.
|
// intact when a human changes camera or visual settings and presses Save.
|
||||||
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
|
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
|
||||||
|
|
@ -237,7 +269,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
|
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
|
||||||
const [cacheRefresh, setCacheRefresh] = useState(false);
|
const [cacheRefresh, setCacheRefresh] = useState(false);
|
||||||
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
|
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
|
||||||
const presentation: MapPresentation = { ...mapSettings, cacheRefresh };
|
const presentation = useMemo<MapPresentation>(
|
||||||
|
() => ({ ...mapSettings, cacheRefresh }),
|
||||||
|
[cacheRefresh, mapSettings],
|
||||||
|
);
|
||||||
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
|
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
|
||||||
const setCacheEnabled = (cacheEnabled: boolean) => {
|
const setCacheEnabled = (cacheEnabled: boolean) => {
|
||||||
updateMapSettings({ cacheEnabled });
|
updateMapSettings({ cacheEnabled });
|
||||||
|
|
@ -263,13 +298,40 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
setMapCamera(camera);
|
setMapCamera(camera);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSpiralStateChange = useCallback((state: CameraSpiralState) => {
|
||||||
|
setSpiralRunning(state.running);
|
||||||
|
setSpiralMessage(state.running ? null : spiralStopMessage(state.reason));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAnimationMode = (enabled: boolean) => {
|
||||||
|
setAnimationModeEnabled(enabled);
|
||||||
|
setSpiralMessage(null);
|
||||||
|
if (!enabled) {
|
||||||
|
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
|
||||||
|
setSpiralRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSpiralAnimation = () => {
|
||||||
|
if (spiralRunning) {
|
||||||
|
mapRendererRef.current?.stopSpiralAnimation("stopped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const started = mapRendererRef.current?.startSpiralAnimation({
|
||||||
|
heightAboveGroundMeters: spiralHeightMeters,
|
||||||
|
speedMetersPerSecond: spiralSpeedMetersPerSecond,
|
||||||
|
pitchMetersPerTurn: spiralPitchMetersPerTurn,
|
||||||
|
}) ?? false;
|
||||||
|
if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации.");
|
||||||
|
};
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
getLayout: () => ({
|
getLayout: () => ({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
pageId: "map",
|
pageId: "map",
|
||||||
settings: mapSettings,
|
settings: mapSettings,
|
||||||
mapHeight: Math.round(mapHeight),
|
mapHeight: Math.round(mapHeight),
|
||||||
camera: mapCameraRef.current ?? mapCamera,
|
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
|
||||||
pinBindings,
|
pinBindings,
|
||||||
dataProductBindings,
|
dataProductBindings,
|
||||||
}),
|
}),
|
||||||
|
|
@ -426,7 +488,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
|
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
|
||||||
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
|
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
|
||||||
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
|
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
|
||||||
<Checker checked={mapSettings.terrainEnabled} label="Terrain" description="Рельеф — отдельный слой под imagery." onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
|
<small className="catalog-map-inspector__note">Рельеф — отдельный слой под imagery.</small>
|
||||||
|
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
|
||||||
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
|
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
|
||||||
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
|
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
|
||||||
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
|
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
|
||||||
|
|
@ -476,7 +539,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
description: "first adapter control",
|
description: "first adapter control",
|
||||||
group: "Слои",
|
group: "Слои",
|
||||||
content: <>
|
content: <>
|
||||||
<Checker checked={mapSettings.gridVisible} label="3D-сетка" description="Сетка размещается над поверхностью и меняет шаг по высоте камеры." onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
<small className="catalog-map-inspector__note">Сетка размещается над поверхностью и меняет шаг по высоте камеры.</small>
|
||||||
|
<Checker checked={mapSettings.gridVisible} label="3D-сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
||||||
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
|
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
|
||||||
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} />
|
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} />
|
||||||
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} />
|
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} />
|
||||||
|
|
@ -494,6 +558,51 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
<RangeControl label="Прозрачность точек" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} />
|
<RangeControl label="Прозрачность точек" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} />
|
||||||
</>,
|
</>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "map-camera-animation",
|
||||||
|
label: "Анимация камеры",
|
||||||
|
description: "geodesic spiral survey",
|
||||||
|
group: "Камера",
|
||||||
|
content: <>
|
||||||
|
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
|
||||||
|
{animationModeEnabled ? <>
|
||||||
|
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта.</small>
|
||||||
|
<RangeControl
|
||||||
|
label="Высота над землёй"
|
||||||
|
value={logarithmicControlValue(spiralHeightMeters)}
|
||||||
|
min={logarithmicControlValue(10)}
|
||||||
|
max={logarithmicControlValue(100_000)}
|
||||||
|
step={0.01}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||||
|
onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Скорость камеры"
|
||||||
|
value={logarithmicControlValue(spiralSpeedMetersPerSecond)}
|
||||||
|
min={logarithmicControlValue(1)}
|
||||||
|
max={logarithmicControlValue(5_000)}
|
||||||
|
step={0.01}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
formatValue={(value) => formatMetricSpeed(10 ** value)}
|
||||||
|
onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Шаг спирали"
|
||||||
|
value={logarithmicControlValue(spiralPitchMetersPerTurn)}
|
||||||
|
min={logarithmicControlValue(20)}
|
||||||
|
max={logarithmicControlValue(100_000)}
|
||||||
|
step={0.01}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||||
|
onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!mapRendererReady}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
|
||||||
|
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
|
||||||
|
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
|
||||||
|
</> : null}
|
||||||
|
</>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "map-cache",
|
id: "map-cache",
|
||||||
label: "TileCache",
|
label: "TileCache",
|
||||||
|
|
@ -501,13 +610,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
group: "Хранение",
|
group: "Хранение",
|
||||||
content: <>
|
content: <>
|
||||||
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
|
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
|
||||||
<Checker className="catalog-map-inspector__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
|
<Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
|
||||||
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученный cache" description="Cache hit отдаётся как есть; новый tile записывается только при miss." onChange={setCacheNoOverwrite} />
|
<small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
|
||||||
<ControlRow label="Режим"><strong>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</strong></ControlRow>
|
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
|
||||||
<ControlRow label="Хранилище"><strong>Platform Map Gateway</strong></ControlRow>
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Режим"><span>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</span></ControlRow>
|
||||||
<ControlRow label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
|
||||||
<ControlRow label="Записано"><strong>{liveCacheSummary}</strong></ControlRow>
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
|
||||||
<ControlRow label="Политика"><strong>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</strong></ControlRow>
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Записано"><span>{liveCacheSummary}</span></ControlRow>
|
||||||
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Политика"><span>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</span></ControlRow>
|
||||||
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
|
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
|
||||||
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
|
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
|
||||||
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
|
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
|
||||||
|
|
@ -537,7 +647,20 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
aria-label="Map Page Cesium adapter"
|
aria-label="Map Page Cesium adapter"
|
||||||
>
|
>
|
||||||
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты…</div>}>
|
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты…</div>}>
|
||||||
<CesiumMapRenderer key={rendererRevision} onSelect={handleSelect} onGatewayHealth={handleRendererGatewayHealth} onProviderStatus={setProviderStatus} onCameraChange={handleCameraChange} onCacheRefreshConsumed={handleCacheRefreshConsumed} initialCamera={mapCamera ?? undefined} presentation={presentation} runtimeBindings={runtimeBindings} />
|
<CesiumMapRenderer
|
||||||
|
key={rendererRevision}
|
||||||
|
ref={mapRendererRef}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onGatewayHealth={handleRendererGatewayHealth}
|
||||||
|
onProviderStatus={setProviderStatus}
|
||||||
|
onCameraChange={handleCameraChange}
|
||||||
|
onCacheRefreshConsumed={handleCacheRefreshConsumed}
|
||||||
|
onReadyChange={setMapRendererReady}
|
||||||
|
onSpiralStateChange={handleSpiralStateChange}
|
||||||
|
initialCamera={mapCamera ?? undefined}
|
||||||
|
presentation={presentation}
|
||||||
|
runtimeBindings={runtimeBindings}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="catalog-map-fixture__actions">
|
<div className="catalog-map-fixture__actions">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
const TWO_PI = Math.PI * 2;
|
||||||
|
const WGS84_SEMI_MAJOR_METERS = 6_378_137;
|
||||||
|
const WGS84_FLATTENING = 1 / 298.257_223_563;
|
||||||
|
const WGS84_SEMI_MINOR_METERS = WGS84_SEMI_MAJOR_METERS * (1 - WGS84_FLATTENING);
|
||||||
|
|
||||||
|
// This survey mode is deliberately regional. Radially projecting an
|
||||||
|
// Archimedean spiral onto WGS84 stays within a negligible speed/tangent error
|
||||||
|
// at this radius; a future continental mode needs an ellipsoid-arc
|
||||||
|
// reparameterization instead of silently pretending the flat formula is exact.
|
||||||
|
export const MAX_SPIRAL_RADIUS_METERS = 250_000;
|
||||||
|
|
||||||
|
export type GeodeticRadians = {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpiralSurfaceFrame = GeodeticRadians & {
|
||||||
|
angle: number;
|
||||||
|
radiusMeters: number;
|
||||||
|
radialBearing: number;
|
||||||
|
tangentHeading: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeRadians(value: number) {
|
||||||
|
const normalized = ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
|
||||||
|
return normalized === -Math.PI ? Math.PI : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spiralRadiusAtAngle(angle: number, pitchMetersPerTurn: number) {
|
||||||
|
const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid"));
|
||||||
|
const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid");
|
||||||
|
return (pitch / TWO_PI) * safeAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spiralArcLengthAtAngle(angle: number, pitchMetersPerTurn: number) {
|
||||||
|
const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid"));
|
||||||
|
const b = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid") / TWO_PI;
|
||||||
|
return (b / 2) * (
|
||||||
|
safeAngle * Math.sqrt(1 + safeAngle * safeAngle)
|
||||||
|
+ Math.asinh(safeAngle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverts the Archimedean spiral arc-length function. This keeps the camera's
|
||||||
|
* configured surface speed constant instead of accelerating as the radius
|
||||||
|
* grows. Newton is monotone here; the bounded fallback protects the first
|
||||||
|
* samples near the origin from numerical overshoot.
|
||||||
|
*/
|
||||||
|
export function spiralAngleAtArcLength(surfaceDistanceMeters: number, pitchMetersPerTurn: number) {
|
||||||
|
const distance = Math.max(0, finiteNumber(surfaceDistanceMeters, "spiral_distance_invalid"));
|
||||||
|
const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid");
|
||||||
|
if (distance === 0) return 0;
|
||||||
|
const b = pitch / TWO_PI;
|
||||||
|
let angle = distance <= b ? distance / b : Math.sqrt((2 * distance) / b);
|
||||||
|
for (let iteration = 0; iteration < 10; iteration += 1) {
|
||||||
|
const error = spiralArcLengthAtAngle(angle, pitch) - distance;
|
||||||
|
if (Math.abs(error) <= Math.max(1e-7, distance * 1e-12)) break;
|
||||||
|
const derivative = b * Math.sqrt(1 + angle * angle);
|
||||||
|
const next = angle - error / derivative;
|
||||||
|
angle = Number.isFinite(next) && next >= 0 ? next : angle / 2;
|
||||||
|
}
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincenty's WGS84 direct solution: move from a geodetic point along an
|
||||||
|
* initial bearing by an exact ellipsoid surface distance. Longitude/latitude
|
||||||
|
* are radians. The animation therefore remains stable at datelines and high
|
||||||
|
* latitudes and never treats degrees as a flat Cartesian plane.
|
||||||
|
*/
|
||||||
|
export function directGeodesicDestination(
|
||||||
|
origin: GeodeticRadians,
|
||||||
|
initialBearing: number,
|
||||||
|
distanceMeters: number,
|
||||||
|
): GeodeticRadians {
|
||||||
|
const latitude1 = finiteNumber(origin.latitude, "spiral_origin_invalid");
|
||||||
|
const longitude1 = finiteNumber(origin.longitude, "spiral_origin_invalid");
|
||||||
|
const bearing = finiteNumber(initialBearing, "spiral_bearing_invalid");
|
||||||
|
const distance = Math.max(0, finiteNumber(distanceMeters, "spiral_distance_invalid"));
|
||||||
|
if (Math.abs(latitude1) > Math.PI / 2 + 1e-12) throw new Error("spiral_origin_invalid");
|
||||||
|
if (distance === 0) return { longitude: normalizeRadians(longitude1), latitude: latitude1 };
|
||||||
|
|
||||||
|
const sinBearing = Math.sin(bearing);
|
||||||
|
const cosBearing = Math.cos(bearing);
|
||||||
|
const tanReducedLatitude1 = (1 - WGS84_FLATTENING) * Math.tan(latitude1);
|
||||||
|
const cosReducedLatitude1 = 1 / Math.sqrt(1 + tanReducedLatitude1 * tanReducedLatitude1);
|
||||||
|
const sinReducedLatitude1 = tanReducedLatitude1 * cosReducedLatitude1;
|
||||||
|
const sigma1 = Math.atan2(tanReducedLatitude1, cosBearing);
|
||||||
|
const sinAlpha = cosReducedLatitude1 * sinBearing;
|
||||||
|
const cosSquaredAlpha = 1 - sinAlpha * sinAlpha;
|
||||||
|
const uSquared = cosSquaredAlpha
|
||||||
|
* (WGS84_SEMI_MAJOR_METERS ** 2 - WGS84_SEMI_MINOR_METERS ** 2)
|
||||||
|
/ (WGS84_SEMI_MINOR_METERS ** 2);
|
||||||
|
const coefficientA = 1 + (uSquared / 16_384)
|
||||||
|
* (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared)));
|
||||||
|
const coefficientB = (uSquared / 1024)
|
||||||
|
* (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared)));
|
||||||
|
|
||||||
|
let sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA);
|
||||||
|
let previousSigma = Number.POSITIVE_INFINITY;
|
||||||
|
let sinSigma = 0;
|
||||||
|
let cosSigma = 1;
|
||||||
|
let cosTwoSigmaMiddle = 0;
|
||||||
|
for (let iteration = 0; iteration < 24 && Math.abs(sigma - previousSigma) > 1e-12; iteration += 1) {
|
||||||
|
cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma);
|
||||||
|
sinSigma = Math.sin(sigma);
|
||||||
|
cosSigma = Math.cos(sigma);
|
||||||
|
const deltaSigma = coefficientB * sinSigma * (
|
||||||
|
cosTwoSigmaMiddle
|
||||||
|
+ (coefficientB / 4) * (
|
||||||
|
cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2)
|
||||||
|
- (coefficientB / 6) * cosTwoSigmaMiddle
|
||||||
|
* (-3 + 4 * sinSigma ** 2)
|
||||||
|
* (-3 + 4 * cosTwoSigmaMiddle ** 2)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
previousSigma = sigma;
|
||||||
|
sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA) + deltaSigma;
|
||||||
|
}
|
||||||
|
|
||||||
|
sinSigma = Math.sin(sigma);
|
||||||
|
cosSigma = Math.cos(sigma);
|
||||||
|
cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma);
|
||||||
|
const temporary = sinReducedLatitude1 * sinSigma
|
||||||
|
- cosReducedLatitude1 * cosSigma * cosBearing;
|
||||||
|
const latitude2 = Math.atan2(
|
||||||
|
sinReducedLatitude1 * cosSigma + cosReducedLatitude1 * sinSigma * cosBearing,
|
||||||
|
(1 - WGS84_FLATTENING) * Math.sqrt(sinAlpha ** 2 + temporary ** 2),
|
||||||
|
);
|
||||||
|
const lambda = Math.atan2(
|
||||||
|
sinSigma * sinBearing,
|
||||||
|
cosReducedLatitude1 * cosSigma - sinReducedLatitude1 * sinSigma * cosBearing,
|
||||||
|
);
|
||||||
|
const coefficientC = (WGS84_FLATTENING / 16) * cosSquaredAlpha
|
||||||
|
* (4 + WGS84_FLATTENING * (4 - 3 * cosSquaredAlpha));
|
||||||
|
const longitudeDelta = lambda - (1 - coefficientC) * WGS84_FLATTENING * sinAlpha * (
|
||||||
|
sigma + coefficientC * sinSigma * (
|
||||||
|
cosTwoSigmaMiddle + coefficientC * cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
longitude: normalizeRadians(longitude1 + longitudeDelta),
|
||||||
|
latitude: latitude2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spiralSurfaceFrame(
|
||||||
|
origin: GeodeticRadians,
|
||||||
|
initialHeading: number,
|
||||||
|
surfaceDistanceMeters: number,
|
||||||
|
pitchMetersPerTurn: number,
|
||||||
|
): SpiralSurfaceFrame {
|
||||||
|
const angle = spiralAngleAtArcLength(surfaceDistanceMeters, pitchMetersPerTurn);
|
||||||
|
const radiusMeters = spiralRadiusAtAngle(angle, pitchMetersPerTurn);
|
||||||
|
if (radiusMeters > MAX_SPIRAL_RADIUS_METERS) throw new Error("spiral_extent_limit");
|
||||||
|
const radialBearing = normalizeRadians(initialHeading + angle);
|
||||||
|
const destination = directGeodesicDestination(origin, radialBearing, radiusMeters);
|
||||||
|
// In polar coordinates dr/dθ=b and r=bθ. The path tangent is therefore
|
||||||
|
// rotated atan2(r, dr/dθ)=atan(θ) from the outward radial direction.
|
||||||
|
const tangentHeading = normalizeRadians(radialBearing + Math.atan(angle));
|
||||||
|
return {
|
||||||
|
...destination,
|
||||||
|
angle,
|
||||||
|
radiusMeters,
|
||||||
|
radialBearing,
|
||||||
|
tangentHeading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: number, errorCode: string) {
|
||||||
|
if (!Number.isFinite(value)) throw new Error(errorCode);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveNumber(value: number, errorCode: string) {
|
||||||
|
const finite = finiteNumber(value, errorCode);
|
||||||
|
if (finite <= 0) throw new Error(errorCode);
|
||||||
|
return finite;
|
||||||
|
}
|
||||||
|
|
@ -787,8 +787,7 @@ textarea {
|
||||||
font-size: var(--nodedc-font-size-xs);
|
font-size: var(--nodedc-font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-map-fixture__cache-toggle,
|
.catalog-map-fixture__cache-toggle {
|
||||||
.catalog-map-inspector__cache-toggle {
|
|
||||||
min-height: 3.4rem;
|
min-height: 3.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -818,6 +817,13 @@ textarea {
|
||||||
font-weight: var(--nodedc-font-weight-strong);
|
font-weight: var(--nodedc-font-weight-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-map-inspector__cache-fact .nodedc-control-row__control {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: var(--nodedc-font-size-md);
|
||||||
|
font-weight: var(--nodedc-font-weight-strong);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-map-fixture__assistant {
|
.catalog-map-fixture__assistant {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
||||||
|
|
||||||
Круглый бинарный контрол из нового Engine settings/agent inspector. Он использует checkbox semantics, но не копирует квадратный системный checkbox.
|
Круглый бинарный контрол из нового Engine settings/agent inspector. Он использует checkbox semantics, но не копирует квадратный системный checkbox.
|
||||||
|
|
||||||
|
Внутри Checker допускается только короткий однострочный заголовок. Пояснение, hint или описание размещается отдельным текстовым блоком перед Checker; сам бинарный контрол не является контейнером для вторичной копии.
|
||||||
|
|
||||||
## RangeControl
|
## RangeControl
|
||||||
|
|
||||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа.
|
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
||||||
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs"
|
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
|
||||||
|
"test:map-animation": "node --test scripts/map-spiral.test.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
|
||||||
|
|
@ -627,13 +627,6 @@ textarea.nodedc-field__control {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-checker__description {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: var(--nodedc-font-size-xs);
|
|
||||||
font-weight: var(--nodedc-font-weight-regular);
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-checker__indicator {
|
.nodedc-checker__indicator {
|
||||||
width: 1.625rem;
|
width: 1.625rem;
|
||||||
height: 1.625rem;
|
height: 1.625rem;
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,19 @@ import { cn } from "./cn.js";
|
||||||
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
/** Helper copy belongs outside the control, immediately before it. */
|
||||||
|
description?: never;
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IsNever<Value> = [Value] extends [never] ? true : false;
|
||||||
|
type Assert<Value extends true> = Value;
|
||||||
|
type _CheckerDescriptionMustStayForbidden = Assert<IsNever<Exclude<CheckerProps["description"], undefined>>>;
|
||||||
|
|
||||||
export function Checker({
|
export function Checker({
|
||||||
checked,
|
checked,
|
||||||
label,
|
label,
|
||||||
description,
|
description: _unsupportedDescription,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
|
|
@ -32,10 +37,8 @@ export function Checker({
|
||||||
>
|
>
|
||||||
<span className="nodedc-checker__copy">
|
<span className="nodedc-checker__copy">
|
||||||
<span className="nodedc-checker__label">{label}</span>
|
<span className="nodedc-checker__label">{label}</span>
|
||||||
{description ? <span className="nodedc-checker__description">{description}</span> : null}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="nodedc-checker__indicator" aria-hidden="true" />
|
<span className="nodedc-checker__indicator" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export function RangeControl({
|
||||||
max={safeMax}
|
max={safeMax}
|
||||||
step={step}
|
step={step}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-valuetext={formatValue(value)}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@
|
||||||
"summary": "Binary setting control derived from the redesigned Engine environment settings.",
|
"summary": "Binary setting control derived from the redesigned Engine environment settings.",
|
||||||
"rules": [
|
"rules": [
|
||||||
"The visual anchor is circular, not a square native checkbox.",
|
"The visual anchor is circular, not a square native checkbox.",
|
||||||
"The control exposes checkbox semantics through role and aria-checked."
|
"The control exposes checkbox semantics through role and aria-checked.",
|
||||||
|
"Checker contains one short label only; helper copy is a separate sibling block before the control."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const source = await readFile(new URL("../apps/catalog/src/mapSpiralMath.ts", import.meta.url), "utf8");
|
||||||
|
const { outputText } = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.ES2022,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
},
|
||||||
|
fileName: "mapSpiralMath.ts",
|
||||||
|
});
|
||||||
|
const spiralMath = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
|
||||||
|
const {
|
||||||
|
MAX_SPIRAL_RADIUS_METERS,
|
||||||
|
directGeodesicDestination,
|
||||||
|
normalizeRadians,
|
||||||
|
spiralAngleAtArcLength,
|
||||||
|
spiralArcLengthAtAngle,
|
||||||
|
spiralRadiusAtAngle,
|
||||||
|
spiralSurfaceFrame,
|
||||||
|
} = spiralMath;
|
||||||
|
|
||||||
|
const twoPi = Math.PI * 2;
|
||||||
|
|
||||||
|
test("one complete spiral turn increases radius by exactly one pitch", () => {
|
||||||
|
assert.equal(spiralRadiusAtAngle(twoPi, 10_000), 10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("arc-length inversion keeps configured surface distance across the route", () => {
|
||||||
|
for (const distance of [0, 1, 20, 1_000, 25_000, 1_000_000]) {
|
||||||
|
const angle = spiralAngleAtArcLength(distance, 10_000);
|
||||||
|
const restored = spiralArcLengthAtAngle(angle, 10_000);
|
||||||
|
assert.ok(Math.abs(restored - distance) <= Math.max(1e-6, distance * 1e-10), `${distance} → ${restored}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("equal elapsed time produces equal traveled surface distance", () => {
|
||||||
|
const speed = 250;
|
||||||
|
const pitch = 10_000;
|
||||||
|
const atOneSecond = spiralAngleAtArcLength(speed, pitch);
|
||||||
|
const atTenSeconds = spiralAngleAtArcLength(speed * 10, pitch);
|
||||||
|
assert.ok(atTenSeconds > atOneSecond);
|
||||||
|
assert.ok(spiralRadiusAtAngle(atTenSeconds, pitch) > spiralRadiusAtAngle(atOneSecond, pitch));
|
||||||
|
assert.ok(Math.abs(spiralArcLengthAtAngle(atTenSeconds, pitch) - speed * 10) < 1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WGS84 direct destination remains finite across dateline and high latitude", () => {
|
||||||
|
const destination = directGeodesicDestination({
|
||||||
|
longitude: 179.9 * Math.PI / 180,
|
||||||
|
latitude: 84 * Math.PI / 180,
|
||||||
|
}, Math.PI / 3, 500_000);
|
||||||
|
assert.ok(Number.isFinite(destination.longitude));
|
||||||
|
assert.ok(Number.isFinite(destination.latitude));
|
||||||
|
assert.ok(destination.longitude >= -Math.PI && destination.longitude <= Math.PI);
|
||||||
|
assert.ok(destination.latitude >= -Math.PI / 2 && destination.latitude <= Math.PI / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eastbound WGS84 destination at equator matches the ellipsoid circumference", () => {
|
||||||
|
const destination = directGeodesicDestination({ longitude: 0, latitude: 0 }, Math.PI / 2, 100_000);
|
||||||
|
assert.ok(Math.abs(destination.latitude) < 1e-12);
|
||||||
|
assert.ok(Math.abs(destination.longitude - 100_000 / 6_378_137) < 1e-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spiral frame starts at the current point and follows the current heading", () => {
|
||||||
|
const origin = { longitude: 37.6173 * Math.PI / 180, latitude: 55.7558 * Math.PI / 180 };
|
||||||
|
const heading = 0.7;
|
||||||
|
const frame = spiralSurfaceFrame(origin, heading, 0, 10_000);
|
||||||
|
assert.ok(Math.abs(normalizeRadians(frame.longitude - origin.longitude)) < 1e-12);
|
||||||
|
assert.ok(Math.abs(frame.latitude - origin.latitude) < 1e-12);
|
||||||
|
assert.equal(frame.radiusMeters, 0);
|
||||||
|
assert.ok(Math.abs(normalizeRadians(frame.tangentHeading - heading)) < 1e-12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("route stops at the documented regional WGS84 safety boundary", () => {
|
||||||
|
const pitch = 100_000;
|
||||||
|
const angle = (MAX_SPIRAL_RADIUS_METERS + pitch) * twoPi / pitch;
|
||||||
|
const distance = spiralArcLengthAtAngle(angle, pitch);
|
||||||
|
assert.throws(
|
||||||
|
() => spiralSurfaceFrame({ longitude: 0, latitude: 0 }, 0, distance, pitch),
|
||||||
|
/spiral_extent_limit/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid animation inputs fail closed", () => {
|
||||||
|
assert.throws(() => spiralAngleAtArcLength(100, 0), /spiral_pitch_invalid/);
|
||||||
|
assert.throws(() => directGeodesicDestination({ longitude: 0, latitude: Number.NaN }, 0, 10), /spiral_origin_invalid/);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue