feat(foundry): add map camera spiral controls

This commit is contained in:
Codex
2026-07-16 12:47:48 +03:00
parent 5e8c1cc3fc
commit e96401358b
11 changed files with 802 additions and 43 deletions
+136 -13
View File
@@ -1,6 +1,13 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
import type { MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus } from "./CesiumMapRenderer.js";
import type {
CameraSpiralState,
CesiumMapRendererHandle,
MapCameraView,
MapGatewayHealth,
MapPresentation,
MapProviderStatus,
} from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
@@ -161,6 +168,23 @@ const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
"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, {
features?: PreviewFeatures;
expanded?: boolean;
@@ -186,6 +210,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}));
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
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
// intact when a human changes camera or visual settings and presses Save.
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
@@ -237,7 +269,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
const [cacheRefresh, setCacheRefresh] = useState(false);
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 setCacheEnabled = (cacheEnabled: boolean) => {
updateMapSettings({ cacheEnabled });
@@ -263,13 +298,40 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
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, () => ({
getLayout: () => ({
schemaVersion: 1,
pageId: "map",
settings: mapSettings,
mapHeight: Math.round(mapHeight),
camera: mapCameraRef.current ?? mapCamera,
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
pinBindings,
dataProductBindings,
}),
@@ -426,7 +488,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<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>
{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 })} />
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
<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",
group: "Слои",
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 })} />
<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 })} />
@@ -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 })} />
</>,
},
{
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",
label: "TileCache",
@@ -501,13 +610,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
group: "Хранение",
content: <>
<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.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученный cache" description="Cache hit отдаётся как есть; новый tile записывается только при miss." onChange={setCacheNoOverwrite} />
<ControlRow label="Режим"><strong>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</strong></ControlRow>
<ControlRow label="Хранилище"><strong>Platform Map Gateway</strong></ControlRow>
<ControlRow label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
<ControlRow label="Записано"><strong>{liveCacheSummary}</strong></ControlRow>
<ControlRow label="Политика"><strong>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</strong></ControlRow>
<Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
<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 className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></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={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
<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"
>
<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>
<div className="catalog-map-fixture__actions">