feat(foundry): add OSM cache survey presets
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||||
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
|
||||
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Select, Window } from "@nodedc/ui-react";
|
||||
import type { SelectOption } from "@nodedc/ui-react";
|
||||
import type {
|
||||
CameraSpiralState,
|
||||
CesiumMapRendererHandle,
|
||||
@@ -9,16 +10,29 @@ import type {
|
||||
MapProviderStatus,
|
||||
} from "./CesiumMapRenderer.js";
|
||||
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
|
||||
import {
|
||||
CAMERA_SURVEY_PRESETS,
|
||||
DEFAULT_CAMERA_SURVEY_PRESET,
|
||||
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
|
||||
cameraSurveySpiralDistance,
|
||||
findCameraSurveyPreset,
|
||||
type CameraSurveySelection,
|
||||
} from "./mapCameraPresets.js";
|
||||
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
||||
|
||||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
|
||||
type SurveySettingsSnapshot = Pick<
|
||||
MapPageSettings,
|
||||
"imageryVisible" | "monochrome" | "cacheEnabled" | "cacheNoOverwrite" | "terrainEnabled" | "buildingsVisible" | "buildingsDetail"
|
||||
>;
|
||||
type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null };
|
||||
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
|
||||
type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number };
|
||||
const RENDERER_GATEWAY_HEALTH_EPOCH = 1;
|
||||
const SURVEY_GATEWAY_HEALTH_MAX_AGE_MS = 30_000;
|
||||
|
||||
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
|
||||
order.nextEpoch += 1;
|
||||
@@ -46,6 +60,13 @@ function gatewayCheckMessage(code: string, stale: boolean) {
|
||||
return `${prefix} (${code}). ${suffix}`;
|
||||
}
|
||||
|
||||
function isWritableAppendOnlyTileCache(health: MapGatewayHealth | null) {
|
||||
return health?.cache?.persistent === true
|
||||
&& health.cache.mode === "readwrite"
|
||||
&& health.cache.writePolicy === "append-only-no-eviction"
|
||||
&& health.cache.atCapacity === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral visual binding. Foundry stores this on an Application page
|
||||
* instance; a future data binding resolves the live source behind `source`.
|
||||
@@ -176,10 +197,19 @@ const formatMetricDistance = (value: number) => value >= 1000
|
||||
const formatMetricSpeed = (value: number) => value >= 1000
|
||||
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
|
||||
: `${Math.round(value)} м/с`;
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`;
|
||||
if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`;
|
||||
if (seconds >= 60) return `${Math.round(seconds / 60)} мин`;
|
||||
return `${Math.max(1, Math.round(seconds))} с`;
|
||||
};
|
||||
|
||||
function spiralStopMessage(reason: CameraSpiralState["reason"]) {
|
||||
if (reason === "target_radius_reached") return "Проход завершён: камера достигла выбранного радиуса.";
|
||||
if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута.";
|
||||
if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка.";
|
||||
if (reason === "tile_loading_timeout") return "Режим остановлен: текущий набор Imagery, Terrain или OSM Buildings не загрузился за отведённое время.";
|
||||
if (reason === "tile_loading_error") return "Режим остановлен: Cesium сообщил об ошибке tile в Imagery, Terrain или OSM Buildings. Неполный участок не засчитан.";
|
||||
if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры.";
|
||||
if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer.";
|
||||
return null;
|
||||
@@ -213,10 +243,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
|
||||
const [mapRendererReady, setMapRendererReady] = useState(false);
|
||||
const [animationModeEnabled, setAnimationModeEnabled] = useState(false);
|
||||
const animationSettingsSnapshotRef = useRef<SurveySettingsSnapshot | null>(null);
|
||||
const [spiralRunning, setSpiralRunning] = useState(false);
|
||||
const [spiralHeightMeters, setSpiralHeightMeters] = useState(1500);
|
||||
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(250);
|
||||
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(10_000);
|
||||
const [spiralPresetId, setSpiralPresetId] = useState<CameraSurveySelection>(DEFAULT_CAMERA_SURVEY_PRESET.id);
|
||||
const [spiralHeightMeters, setSpiralHeightMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.heightAboveGroundMeters);
|
||||
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.speedMetersPerSecond);
|
||||
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.pitchMetersPerTurn);
|
||||
const [spiralTargetRadiusMeters, setSpiralTargetRadiusMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.targetRadiusMeters);
|
||||
const [spiralMessage, setSpiralMessage] = useState<string | null>(null);
|
||||
// Map pin bindings belong to the application page instance. They are kept
|
||||
// intact when a human changes camera or visual settings and presses Save.
|
||||
@@ -268,6 +301,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null);
|
||||
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
|
||||
const [cacheRefresh, setCacheRefresh] = useState(false);
|
||||
const spiralPresetOptions = useMemo<Array<SelectOption<CameraSurveySelection>>>(() => [
|
||||
...CAMERA_SURVEY_PRESETS.map((preset) => ({
|
||||
value: preset.id,
|
||||
label: preset.label,
|
||||
description: `шаг ${formatMetricDistance(preset.pitchMetersPerTurn)} · до ${formatMetricDistance(preset.targetRadiusMeters)}`,
|
||||
})),
|
||||
...(spiralPresetId === "custom" ? [{
|
||||
value: "custom" as const,
|
||||
label: "Пользовательский",
|
||||
description: "Значения изменены вручную",
|
||||
}] : []),
|
||||
], [spiralPresetId]);
|
||||
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
|
||||
const presentation = useMemo<MapPresentation>(
|
||||
() => ({ ...mapSettings, cacheRefresh }),
|
||||
@@ -303,24 +348,124 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
setSpiralMessage(state.running ? null : spiralStopMessage(state.reason));
|
||||
}, []);
|
||||
|
||||
const setAnimationMode = (enabled: boolean) => {
|
||||
setAnimationModeEnabled(enabled);
|
||||
setSpiralMessage(null);
|
||||
if (!enabled) {
|
||||
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
|
||||
setSpiralRunning(false);
|
||||
const prepareAnimationSurvey = () => {
|
||||
const failedProviderNeedsRetry = [providerStatus.imagery, providerStatus.terrain, providerStatus.buildings]
|
||||
.some((state) => state === "error" || state === "not-configured");
|
||||
const rendererRestartRequired = !mapSettings.cacheEnabled
|
||||
|| !mapSettings.cacheNoOverwrite
|
||||
|| cacheRefresh
|
||||
|| failedProviderNeedsRetry;
|
||||
updateMapSettings({
|
||||
imageryVisible: true,
|
||||
// Monochrome deliberately hides the imagery layer. A cache survey must
|
||||
// render it so Cesium actually requests every visible imagery tile.
|
||||
monochrome: false,
|
||||
cacheEnabled: true,
|
||||
cacheNoOverwrite: true,
|
||||
terrainEnabled: true,
|
||||
buildingsVisible: true,
|
||||
// The measured OSM hierarchy and presets use the canonical SSE value.
|
||||
buildingsDetail: 16,
|
||||
});
|
||||
setCacheRefresh(false);
|
||||
if (rendererRestartRequired) {
|
||||
setMapRendererReady(false);
|
||||
setProviderStatus(initialProviderStatus);
|
||||
setRendererRevision((value) => value + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const setAnimationMode = (enabled: boolean) => {
|
||||
if (enabled === animationModeEnabled) return;
|
||||
setAnimationModeEnabled(enabled);
|
||||
setSpiralMessage(null);
|
||||
if (enabled) {
|
||||
// Survey preparation is transient. Save the page presentation before
|
||||
// forcing all cacheable providers on, and restore it when the mode ends.
|
||||
animationSettingsSnapshotRef.current = {
|
||||
imageryVisible: mapSettings.imageryVisible,
|
||||
monochrome: mapSettings.monochrome,
|
||||
cacheEnabled: mapSettings.cacheEnabled,
|
||||
cacheNoOverwrite: mapSettings.cacheNoOverwrite,
|
||||
terrainEnabled: mapSettings.terrainEnabled,
|
||||
buildingsVisible: mapSettings.buildingsVisible,
|
||||
buildingsDetail: mapSettings.buildingsDetail,
|
||||
};
|
||||
prepareAnimationSurvey();
|
||||
void verifyGateway();
|
||||
} else {
|
||||
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
|
||||
setSpiralRunning(false);
|
||||
const snapshot = animationSettingsSnapshotRef.current;
|
||||
animationSettingsSnapshotRef.current = null;
|
||||
if (snapshot) {
|
||||
const rendererRestartRequired = snapshot.cacheEnabled !== mapSettings.cacheEnabled
|
||||
|| snapshot.cacheNoOverwrite !== mapSettings.cacheNoOverwrite;
|
||||
// Restore only fields owned by the survey overlay. Any unrelated
|
||||
// Inspector edits made while the mode was open remain intact.
|
||||
setMapSettings((current) => ({ ...current, ...snapshot }));
|
||||
setCacheRefresh(false);
|
||||
if (rendererRestartRequired) {
|
||||
setMapRendererReady(false);
|
||||
setProviderStatus(initialProviderStatus);
|
||||
setRendererRevision((value) => value + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectSpiralPreset = (presetId: CameraSurveySelection) => {
|
||||
if (presetId === "custom") return;
|
||||
const preset = findCameraSurveyPreset(presetId);
|
||||
if (!preset) return;
|
||||
setSpiralPresetId(preset.id);
|
||||
setSpiralHeightMeters(preset.heightAboveGroundMeters);
|
||||
setSpiralSpeedMetersPerSecond(preset.speedMetersPerSecond);
|
||||
setSpiralPitchMetersPerTurn(preset.pitchMetersPerTurn);
|
||||
setSpiralTargetRadiusMeters(preset.targetRadiusMeters);
|
||||
setSpiralMessage(null);
|
||||
};
|
||||
|
||||
const spiralSurveyConfigured = mapSettings.cacheEnabled
|
||||
&& mapSettings.cacheNoOverwrite
|
||||
&& mapSettings.imageryVisible
|
||||
&& !mapSettings.monochrome
|
||||
&& mapSettings.terrainEnabled
|
||||
&& mapSettings.buildingsVisible
|
||||
&& mapSettings.buildingsDetail === 16;
|
||||
const spiralProvidersReady = providerStatus.imagery === "ready"
|
||||
&& providerStatus.terrain === "ready"
|
||||
&& providerStatus.buildings === "ready";
|
||||
const spiralGatewayHealthFresh = Boolean(
|
||||
gatewayLastVerifiedAt
|
||||
&& Date.now() - gatewayLastVerifiedAt.getTime() <= SURVEY_GATEWAY_HEALTH_MAX_AGE_MS
|
||||
&& gatewayCheckState !== "error"
|
||||
&& gatewayCheckState !== "stale",
|
||||
);
|
||||
const spiralTileCacheReady = spiralGatewayHealthFresh && isWritableAppendOnlyTileCache(gatewayHealth);
|
||||
const spiralCanStart = mapRendererReady && spiralSurveyConfigured && spiralProvidersReady && spiralTileCacheReady;
|
||||
|
||||
useEffect(() => {
|
||||
if (!spiralRunning || (spiralSurveyConfigured && spiralTileCacheReady)) return;
|
||||
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
|
||||
}, [spiralRunning, spiralSurveyConfigured, spiralTileCacheReady]);
|
||||
|
||||
const toggleSpiralAnimation = () => {
|
||||
if (spiralRunning) {
|
||||
mapRendererRef.current?.stopSpiralAnimation("stopped");
|
||||
return;
|
||||
}
|
||||
if (!spiralCanStart) {
|
||||
setSpiralMessage("Подождите готовности Imagery, Terrain, OSM Buildings и свежей проверки writable append-only TileCache.");
|
||||
return;
|
||||
}
|
||||
const started = mapRendererRef.current?.startSpiralAnimation({
|
||||
heightAboveGroundMeters: spiralHeightMeters,
|
||||
speedMetersPerSecond: spiralSpeedMetersPerSecond,
|
||||
pitchMetersPerTurn: spiralPitchMetersPerTurn,
|
||||
targetRadiusMeters: spiralTargetRadiusMeters,
|
||||
viewPitchRadians: -Math.PI / 2 + 0.01,
|
||||
waitForTiles: true,
|
||||
}) ?? false;
|
||||
if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации.");
|
||||
};
|
||||
@@ -329,7 +474,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
getLayout: () => ({
|
||||
schemaVersion: 1,
|
||||
pageId: "map",
|
||||
settings: mapSettings,
|
||||
// Survey-only layer overrides never leak into a saved page layout.
|
||||
settings: animationSettingsSnapshotRef.current
|
||||
? { ...mapSettings, ...animationSettingsSnapshotRef.current }
|
||||
: mapSettings,
|
||||
mapHeight: Math.round(mapHeight),
|
||||
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
|
||||
pinBindings,
|
||||
@@ -435,7 +583,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
}, [beginGatewayHealthVerification, isLatestGatewayHealthRequest, rememberGatewayHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inspectorOpen && !layersOpen) return;
|
||||
if (!inspectorOpen && !layersOpen && !animationModeEnabled) return;
|
||||
void verifyGateway();
|
||||
const interval = window.setInterval(() => void verifyGateway(), 15_000);
|
||||
return () => {
|
||||
@@ -446,7 +594,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
pending.controller.abort();
|
||||
}
|
||||
};
|
||||
}, [inspectorOpen, layersOpen, verifyGateway]);
|
||||
}, [animationModeEnabled, inspectorOpen, layersOpen, verifyGateway]);
|
||||
|
||||
const liveCacheStatus = gatewayHealth?.cache;
|
||||
const liveCacheSummary = liveCacheStatus
|
||||
@@ -566,7 +714,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
content: <>
|
||||
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
|
||||
{animationModeEnabled ? <>
|
||||
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта.</small>
|
||||
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
|
||||
<ControlRow label="Профиль покрытия">
|
||||
<Select
|
||||
value={spiralPresetId}
|
||||
options={spiralPresetOptions}
|
||||
label="Профиль покрытия камеры"
|
||||
disabled={spiralRunning}
|
||||
onChange={selectSpiralPreset}
|
||||
/>
|
||||
</ControlRow>
|
||||
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
|
||||
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
|
||||
<RangeControl
|
||||
label="Высота над землёй"
|
||||
value={logarithmicControlValue(spiralHeightMeters)}
|
||||
@@ -575,7 +734,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
step={0.01}
|
||||
disabled={spiralRunning}
|
||||
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||
onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))}
|
||||
onChange={(value) => {
|
||||
setSpiralPresetId("custom");
|
||||
setSpiralHeightMeters(valueFromLogarithmicControl(value));
|
||||
}}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Скорость камеры"
|
||||
@@ -585,7 +747,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
step={0.01}
|
||||
disabled={spiralRunning}
|
||||
formatValue={(value) => formatMetricSpeed(10 ** value)}
|
||||
onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))}
|
||||
onChange={(value) => {
|
||||
setSpiralPresetId("custom");
|
||||
setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value));
|
||||
}}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Шаг спирали"
|
||||
@@ -595,9 +760,27 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
step={0.01}
|
||||
disabled={spiralRunning}
|
||||
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||
onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))}
|
||||
onChange={(value) => {
|
||||
setSpiralPresetId("custom");
|
||||
setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value));
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!mapRendererReady}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
|
||||
<RangeControl
|
||||
label="Радиус прохода"
|
||||
value={logarithmicControlValue(spiralTargetRadiusMeters)}
|
||||
min={logarithmicControlValue(1_000)}
|
||||
max={logarithmicControlValue(250_000)}
|
||||
step={0.01}
|
||||
disabled={spiralRunning}
|
||||
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||
onChange={(value) => {
|
||||
setSpiralPresetId("custom");
|
||||
setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value));
|
||||
}}
|
||||
/>
|
||||
<small className="catalog-map-inspector__note">Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время.</small>
|
||||
{!spiralCanStart && !spiralRunning ? <small className="catalog-map-inspector__note" role="status">Подготовка: imagery — {providerStateLabel[providerStatus.imagery]}, terrain — {providerStateLabel[providerStatus.terrain]}, OSM Buildings — {providerStateLabel[providerStatus.buildings]}, TileCache — {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}.</small> : null}
|
||||
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!spiralRunning && !spiralCanStart}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
|
||||
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
|
||||
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
|
||||
</> : null}
|
||||
|
||||
Reference in New Issue
Block a user