feat(foundry): add map camera spiral controls
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user