feat(foundry): productionize Cesium Map Page and platform runtime

This commit is contained in:
Codex
2026-07-16 02:25:58 +03:00
parent 1a2ca8c82c
commit 5e8c1cc3fc
41 changed files with 6977 additions and 179 deletions
+278 -39
View File
@@ -1,6 +1,7 @@
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 } from "./CesiumMapRenderer.js";
import type { MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus } from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
@@ -8,6 +9,67 @@ const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((modu
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
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;
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
order.nextEpoch += 1;
order.latestStartedEpoch = order.nextEpoch;
return order.nextEpoch;
}
function isLatestGatewayHealthEpoch(order: GatewayHealthOrder, epoch: number) {
return order.latestStartedEpoch === epoch;
}
function safeGatewayCheckCode(value: unknown, fallback = "gateway_not_ready") {
const code = value instanceof Error && value.message ? value.message : fallback;
return code.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 80) || fallback;
}
function gatewayCheckMessage(code: string, stale: boolean) {
if (code === "persistent_cache_unavailable") {
return "Persistent TileCache не подключён: карта не должна продолжать работу с локальной временной папкой.";
}
const prefix = stale ? "Текущая проверка не прошла" : "Проверка Platform Map Gateway не прошла";
const suffix = stale
? "Показаны последние успешно полученные данные TileCache."
: "TileCache и runtime profile не изменялись.";
return `${prefix} (${code}). ${suffix}`;
}
/**
* Provider-neutral visual binding. Foundry stores this on an Application page
* instance; a future data binding resolves the live source behind `source`.
*/
export type MapPinBinding = {
id: string;
subjectId: string;
kind: "elevated-spike";
label: string;
status: string;
coordinates: { longitude: number; latitude: number; heightMeters: number };
source: { entityId: string; streamId: string; displayFields: string[] };
attributes: Record<string, string | number | boolean>;
};
/**
* A renderer-neutral declaration of an entity stream assigned to this page.
*
* It intentionally contains no provider endpoint, tenant/connection scope,
* credential reference, or browser token. The Foundry runtime resolves the
* matching server-side consumer grant from the application/page/binding
* target before it asks the External Data Plane for a snapshot or patches.
*/
export type MapDataProductBinding = {
id: string;
dataProductId: string;
slotId: string;
delivery: "snapshot+patch";
semanticTypes: string[];
fieldProjection: string[];
};
export type MapPageLayout = {
schemaVersion: 1;
@@ -15,6 +77,8 @@ export type MapPageLayout = {
settings: MapPageSettings;
mapHeight: number;
camera: MapCameraView;
pinBindings: MapPinBinding[];
dataProductBindings: MapDataProductBinding[];
savedAt?: string;
};
@@ -26,6 +90,7 @@ const initialMapSettings: MapPageSettings = {
imagerySource: "cesium-live",
imageryVisible: true,
cacheEnabled: true,
cacheNoOverwrite: true,
terrainEnabled: true,
terrainExaggeration: 1,
monochrome: false,
@@ -82,23 +147,73 @@ const fallbackMapCamera: MapCameraView = {
roll: 0,
};
const initialProviderStatus: MapProviderStatus = {
imagery: "loading",
terrain: "loading",
buildings: "loading",
errors: {},
};
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
loading: "загружается",
ready: "готов",
error: "недоступен",
"not-configured": "не настроен",
};
export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
features?: PreviewFeatures;
expanded?: boolean;
initialLayout?: MapPageLayout | null;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null }, ref) {
const selectable = useMemo(() => [
applicationId?: string;
pageId?: string;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
const fixtureSelectable = useMemo(() => [
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })),
...sceneFixture.scene.stations.map((entity) => ({ id: entity.id, title: entity.label.text, kind: `${entity.stationType} station`, status: undefined })),
], []);
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? selectable[0]?.id);
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? fixtureSelectable[0]?.id);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [layersOpen, setLayersOpen] = useState(false);
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({ ...initialMapSettings, ...initialLayout?.settings }));
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
...initialMapSettings,
...initialLayout?.settings,
// Layouts saved before the cache policy field existed retain the safe
// append-only default when they are opened again.
cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true,
}));
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
// 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 ?? []);
// Data-product bindings are provisioned by Foundry MCP / Platform and do
// not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout.
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const runtimeBindings = useMapDataProductRuntime({
applicationId,
pageId,
bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId),
});
const selectable = useMemo(() => [
...fixtureSelectable,
...runtimeBindings.flatMap((binding) => binding.facts.map((fact) => {
const attributes = fact.attributes;
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
.find((value) => typeof value === "string" && value.trim());
const status = typeof attributes.status === "string" ? attributes.status : undefined;
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: typeof label === "string" ? label : fact.sourceId,
kind: fact.semanticType,
status,
};
})),
], [fixtureSelectable, runtimeBindings]);
// The header Save action can be pressed immediately after Cesium finishes
// constructing the scene. Keep the last camera synchronously as well as in
// state, so the imperative page-layout contract never waits for React's
@@ -106,16 +221,42 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const mapCameraRef = useRef<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
const [rendererRevision, setRendererRevision] = useState(0);
const [gatewayHealth, setGatewayHealth] = useState<MapGatewayHealth | null>(null);
const gatewayHealthRef = useRef<MapGatewayHealth | null>(null);
// The renderer owns epoch 1 as a bootstrap health source. As soon as the UI
// starts an explicit verification, its higher epoch becomes authoritative;
// a slower renderer request can no longer overwrite that newer result.
const gatewayHealthOrderRef = useRef<GatewayHealthOrder>({
nextEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
latestStartedEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
});
const gatewayCheckRequestRef = useRef<{ id: symbol; controller: AbortController; promise: Promise<void> } | null>(null);
const [gatewayEndpoint, setGatewayEndpoint] = useState<string | null>(null);
const [gatewayCheckState, setGatewayCheckState] = useState<"idle" | "checking" | "ready" | "error">("idle");
const [gatewayCheckState, setGatewayCheckState] = useState<GatewayCheckState>("idle");
const [gatewayCheckError, setGatewayCheckError] = useState<string | null>(null);
const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null);
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: false };
const presentation: MapPresentation = { ...mapSettings, cacheRefresh };
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
const setCacheEnabled = (cacheEnabled: boolean) => {
updateMapSettings({ cacheEnabled });
setRendererRevision((value) => value + 1);
};
const setCacheNoOverwrite = (cacheNoOverwrite: boolean) => {
updateMapSettings({ cacheNoOverwrite });
setCacheRefresh(false);
setRendererRevision((value) => value + 1);
};
const refreshCurrentViewport = () => {
if (!mapSettings.cacheEnabled) return;
setCacheRefresh(true);
setRendererRevision((value) => value + 1);
};
const handleCacheRefreshConsumed = useCallback(() => {
setCacheRefresh(false);
setRendererRevision((value) => value + 1);
}, []);
const handleCameraChange = useCallback((camera: MapCameraView) => {
mapCameraRef.current = camera;
@@ -129,50 +270,132 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
settings: mapSettings,
mapHeight: Math.round(mapHeight),
camera: mapCameraRef.current ?? mapCamera,
pinBindings,
dataProductBindings,
}),
}), [mapCamera, mapHeight, mapSettings]);
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings]);
const handleSelect = useCallback((entityId: string) => {
if (!selectable.some((entity) => entity.id === entityId)) return;
setSelectedId(entityId);
}, [selectable]);
const verifyGateway = useCallback(async () => {
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health;
setGatewayHealth(health);
setGatewayLastVerifiedAt(new Date());
}, []);
const beginGatewayHealthVerification = useCallback(() => {
return beginGatewayHealthEpoch(gatewayHealthOrderRef.current);
}, []);
const isLatestGatewayHealthRequest = useCallback((epoch: number) => (
isLatestGatewayHealthEpoch(gatewayHealthOrderRef.current, epoch)
), []);
const handleRendererGatewayHealth = useCallback((health: MapGatewayHealth | null) => {
if (!isLatestGatewayHealthRequest(RENDERER_GATEWAY_HEALTH_EPOCH)) return;
if (health?.cache?.persistent === true) {
rememberGatewayHealth(health);
setGatewayCheckError(null);
setGatewayCheckState("ready");
return;
}
const stale = Boolean(gatewayHealthRef.current);
const code = health ? "persistent_cache_unavailable" : "gateway_health_unavailable";
setGatewayCheckError(gatewayCheckMessage(code, stale));
setGatewayCheckState(stale ? "stale" : "error");
}, [isLatestGatewayHealthRequest, rememberGatewayHealth]);
const verifyGateway = useCallback(() => {
const pending = gatewayCheckRequestRef.current;
if (pending) return pending.promise;
const epoch = beginGatewayHealthVerification();
const id = Symbol("gateway-check");
const controller = new AbortController();
let timedOut = false;
let stage: "runtime" | "gateway_health" = "runtime";
setGatewayCheckState("checking");
setGatewayCheckError(null);
try {
const runtimeResponse = await fetch("/api/map/runtime-config");
const runtime = runtimeResponse.ok ? await runtimeResponse.json() as MapRuntimeConfig : null;
if (!runtime?.gatewayHealthUrl) throw new Error("gateway_not_configured");
const healthResponse = await fetch(runtime.gatewayHealthUrl);
if (!healthResponse.ok) throw new Error("gateway_not_ready");
const health = await healthResponse.json() as MapGatewayHealth;
if (health.cache?.persistent !== true) throw new Error("persistent_cache_unavailable");
setGatewayHealth(health);
setGatewayEndpoint(new URL(runtime.gatewayHealthUrl).origin);
setGatewayCheckState("ready");
} catch (error) {
const code = error instanceof Error ? error.message : "gateway_not_ready";
setGatewayHealth(null);
setGatewayEndpoint(null);
setGatewayCheckError(code === "persistent_cache_unavailable"
? "Persistent TileCache не подключён: карта не должна продолжать работу с локальной временной папкой."
: "Platform Map Gateway недоступен: проверьте runtime profile и persistent volume.");
setGatewayCheckState("error");
}
}, []);
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, 10_000);
const promise = (async () => {
try {
const runtimeResponse = await fetch("/api/map/runtime-config", { cache: "no-store", signal: controller.signal });
if (!runtimeResponse.ok) throw new Error(`runtime_http_${runtimeResponse.status}`);
let runtime: MapRuntimeConfig;
try {
runtime = await runtimeResponse.json() as MapRuntimeConfig;
} catch {
throw new Error("runtime_invalid_response");
}
if (!runtime?.gatewayHealthUrl) throw new Error("gateway_not_configured");
stage = "gateway_health";
const healthResponse = await fetch(runtime.gatewayHealthUrl, { cache: "no-store", signal: controller.signal });
if (!healthResponse.ok) throw new Error(`gateway_health_http_${healthResponse.status}`);
let health: MapGatewayHealth;
try {
health = await healthResponse.json() as MapGatewayHealth;
} catch {
throw new Error("gateway_health_invalid_response");
}
if (health.cache?.persistent !== true) throw new Error("persistent_cache_unavailable");
if (!isLatestGatewayHealthRequest(epoch)) return;
rememberGatewayHealth(health);
// Runtime configuration deliberately exposes a same-origin relative
// route. Resolve it against the current browser origin before displaying
// the connection; new URL("/api/…") without a base throws and used to
// turn a healthy Gateway into a false "unavailable" state.
setGatewayEndpoint(new URL(runtime.gatewayHealthUrl, window.location.origin).origin);
setGatewayCheckState("ready");
} catch (error) {
if (controller.signal.aborted && !timedOut) return;
if (!isLatestGatewayHealthRequest(epoch)) return;
const code = timedOut
? "gateway_check_timeout"
: error instanceof TypeError
? `${stage}_network_error`
: safeGatewayCheckCode(error);
const stale = Boolean(gatewayHealthRef.current);
if (!stale) setGatewayEndpoint(null);
setGatewayCheckError(gatewayCheckMessage(code, stale));
setGatewayCheckState(stale ? "stale" : "error");
} finally {
window.clearTimeout(timeout);
if (gatewayCheckRequestRef.current?.id === id) gatewayCheckRequestRef.current = null;
}
})();
gatewayCheckRequestRef.current = { id, controller, promise };
return promise;
}, [beginGatewayHealthVerification, isLatestGatewayHealthRequest, rememberGatewayHealth]);
useEffect(() => {
if (!inspectorOpen && !layersOpen) return;
void verifyGateway();
const interval = window.setInterval(() => void verifyGateway(), 5000);
return () => window.clearInterval(interval);
const interval = window.setInterval(() => void verifyGateway(), 15_000);
return () => {
window.clearInterval(interval);
const pending = gatewayCheckRequestRef.current;
if (pending) {
gatewayCheckRequestRef.current = null;
pending.controller.abort();
}
};
}, [inspectorOpen, layersOpen, verifyGateway]);
const liveCacheStatus = gatewayHealth?.cache;
const liveCacheSummary = liveCacheStatus
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} MB`
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB`
: "индекс ещё не получен";
const transportDiagnostic = gatewayHealth?.diagnostics?.lastFailure
? `Последняя transport-ошибка: ${gatewayHealth.diagnostics.lastFailure}${gatewayHealth.diagnostics.lastFailureAt ? ` · ${new Date(gatewayHealth.diagnostics.lastFailureAt).toLocaleTimeString()}` : ""}`
: null;
const gatewayHealthAge = gatewayCheckState === "stale" && gatewayLastVerifiedAt
? `Последняя успешная проверка: ${gatewayLastVerifiedAt.toLocaleTimeString()}`
: null;
const startResize = (event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
@@ -201,6 +424,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
content: <>
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
<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 })} />
<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 })} />
@@ -275,16 +500,22 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
description: "Platform Map Gateway",
group: "Хранение",
content: <>
<small className="catalog-map-inspector__note">По умолчанию официальный live-маршрут. Включите запись, чтобы одновременно смотреть карту и пополнять серверный cache.</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} />
<ControlRow label="Режим"><strong>{mapSettings.cacheEnabled ? "Live + Cache" : "Live"}</strong></ControlRow>
<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>
<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>
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? "Hybrid: provider остаётся официальным, новые tiles сохраняются в серверный cache." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayCheckState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayHealth?.cache?.atCapacity ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется.</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
</>,
},
{
@@ -306,7 +537,7 @@ 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={setGatewayHealth} onCameraChange={handleCameraChange} initialCamera={mapCamera ?? undefined} presentation={presentation} />
<CesiumMapRenderer key={rendererRevision} onSelect={handleSelect} onGatewayHealth={handleRendererGatewayHealth} onProviderStatus={setProviderStatus} onCameraChange={handleCameraChange} onCacheRefreshConsumed={handleCacheRefreshConsumed} initialCamera={mapCamera ?? undefined} presentation={presentation} runtimeBindings={runtimeBindings} />
</Suspense>
<div className="catalog-map-fixture__actions">
@@ -319,12 +550,20 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{layersOpen ? (
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div>
<div className="catalog-map-fixture__provider"><strong>Cesium World Imagery</strong><small>официальный live provider</small></div>
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
</div>
{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" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</GlassSurface>
) : null}