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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-catalog",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
+174 -11
View File
@@ -124,6 +124,27 @@ interface StoredLayout {
};
}
interface FoundrySessionProfile {
user: {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
initials: string;
} | null;
profileUrl: string | null;
access?: {
role: "admin" | "user";
};
}
interface CesiumIonSecretStatus {
configured: boolean;
updatedAt: string | null;
updatedBy: string | null;
verification?: "verified" | "failed" | "not-configured";
}
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
dark: {
panelHex: "#151517",
@@ -307,6 +328,12 @@ export function CatalogApp() {
const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null);
const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
const [applicationError, setApplicationError] = useState("");
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [cesiumIonToken, setCesiumIonToken] = useState("");
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
const [cesiumIonError, setCesiumIonError] = useState("");
const [mapTemplateLayout, setMapTemplateLayout] = useState<MapPageLayout | null>(null);
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
@@ -355,8 +382,11 @@ export function CatalogApp() {
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
const [mediaSource, setMediaSource] = useState<"file" | "url">("file");
const [mediaUrl, setMediaUrl] = useState("");
const [mediaFileName, setMediaFileName] = useState("launcher-stage.mp4");
const [fileMediaSrc, setFileMediaSrc] = useState("/launcher-stage.mp4");
// Match the persisted production design-profile default from first paint.
// This prevents the packaged pink sample clip from flashing before
// /api/layout returns the same saved media configuration.
const [mediaFileName, setMediaFileName] = useState("possible shapes.mp4");
const [fileMediaSrc, setFileMediaSrc] = useState("/uploads/1783715822132-possible-shapes.mp4");
const [mediaError, setMediaError] = useState("");
const [mediaVisible, setMediaVisible] = useState(true);
const mediaObjectUrlRef = useRef<string | null>(null);
@@ -431,6 +461,71 @@ export function CatalogApp() {
applyFaviconAssets(faviconAssets);
}, [faviconAssets]);
useEffect(() => {
let active = true;
fetch("/api/session/profile", { cache: "no-store" })
.then((response) => response.ok ? response.json() as Promise<FoundrySessionProfile> : null)
.then((profile) => { if (active) setSessionProfile(profile); })
.catch(() => { if (active) setSessionProfile(null); });
return () => { active = false; };
}, []);
const isFoundryAdmin = sessionProfile?.access?.role === "admin";
const openPlatformSettings = () => {
setPlatformSettingsOpen(true);
setCesiumIonToken("");
setCesiumIonError("");
setCesiumIonSaveState("loading");
void fetch("/api/platform-settings/cesium-ion", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("platform_settings_load_failed");
return await response.json() as CesiumIonSecretStatus;
})
.then((status) => {
setCesiumIonStatus(status);
setCesiumIonSaveState("idle");
})
.catch(() => setCesiumIonSaveState("error"));
};
const closePlatformSettings = () => {
setPlatformSettingsOpen(false);
setCesiumIonToken("");
setCesiumIonError("");
};
const saveCesiumIonToken = async () => {
const token = cesiumIonToken.trim();
if (token.length < 16) return;
setCesiumIonSaveState("saving");
setCesiumIonError("");
try {
const response = await fetch("/api/platform-settings/cesium-ion", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
if (!response.ok) {
const result = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(result?.error || "platform_settings_save_failed");
}
setCesiumIonStatus(await response.json() as CesiumIonSecretStatus);
setCesiumIonToken("");
setCesiumIonSaveState("saved");
} catch (error) {
const code = error instanceof Error ? error.message : "platform_settings_save_failed";
setCesiumIonError(code === "cesium_ion_token_verification_failed"
? "Token не прошёл проверку Cesium Ion для terrain, imagery или 3D Buildings. Значение не сохранено."
: code === "map_gateway_admin_unauthorized"
? "Внутренний signing profile Gateway не совпадает с Foundry. Это исправляется только runner-managed deployment, не вводом значений в интерфейс."
: code === "map_gateway_admin_not_configured"
? "Runner-managed signing file Gateway недоступен."
: "Не удалось применить token. Проверьте права администратора и доступность Gateway.");
setCesiumIonSaveState("error");
}
};
useEffect(() => {
let active = true;
fetch("/api/layout", { cache: "no-store" })
@@ -1291,7 +1386,7 @@ export function CatalogApp() {
return (
<div className="catalog-glass-lab">
<section className="catalog-glass-stage">
<video key={stageMediaSrc} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true"><source src={stageMediaSrc} type="video/mp4" /></video>
<video key={stageMediaSrc} autoPlay muted loop playsInline aria-hidden="true"><source src={stageMediaSrc} type="video/mp4" /></video>
<div className="catalog-glass-stage__shade" />
<GlassMaterialSurface className="catalog-glass-stage__sample">
<span>CANONICAL GLASS</span>
@@ -1616,7 +1711,15 @@ export function CatalogApp() {
}));
return (
<div className="catalog-application-page">
<MapFixturePreview key={page.id} ref={applicationMapPreviewRef} initialLayout={page.layout?.map ?? null} features={page.features} expanded />
<MapFixturePreview
key={page.id}
ref={applicationMapPreviewRef}
applicationId={applicationDraft.id}
pageId={page.id}
initialLayout={page.layout?.map ?? null}
features={page.features}
expanded
/>
{applicationMode === "edit" ? (
<SettingsCard eyebrow="PAGE SETTINGS" title={page.title} description={`${template.title} · ${template.version}`}>
<div className="catalog-application-draft__features">
@@ -1652,7 +1755,7 @@ export function CatalogApp() {
<>
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl={headerMarkSrc} />
<HeaderNavigation
label="Рабочая область Module Studio"
label="Рабочая область Module Foundry"
value={guidelineOpen ? studioContext : undefined}
items={[
{ value: "visual", label: "Visual Library" },
@@ -1673,15 +1776,23 @@ export function CatalogApp() {
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
<HeaderProfileButton>Профиль</HeaderProfileButton>
<HeaderAvatar label="DC" />
<HeaderProfileButton
title={sessionProfile?.user?.displayName || "Профиль NODE.DC"}
onClick={() => { if (sessionProfile?.profileUrl) window.location.assign(sessionProfile.profileUrl); }}
>
{sessionProfile?.user?.displayName || "Профиль"}
</HeaderProfileButton>
<HeaderAvatar
label={sessionProfile?.user?.displayName || sessionProfile?.user?.initials || "DC"}
imageUrl={sessionProfile?.user?.avatarUrl || undefined}
/>
</HeaderProfile>
}
/>
}
stage={
<section className="catalog-launcher-stage">
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline aria-hidden="true">
<source src={stageMediaSrc} type="video/mp4" />
</video>
<div className="catalog-launcher-stage__shade" />
@@ -1696,11 +1807,13 @@ export function CatalogApp() {
<AdminNavigationPanel
eyebrow="NODE.DC"
title={studioContext === "applications" ? "Applications" : studioContext === "pages" ? "Page Library" : "Visual Library"}
closeLabel="Закрыть Module Studio"
closeLabel="Закрыть Module Foundry"
navigationLabel={studioContext === "applications" ? "Application Drafts" : studioContext === "pages" ? "Page Templates" : "Разделы Visual Library"}
onClose={closeGuideline}
headerActions={studioContext === "applications" ? (
<IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton>
) : studioContext === "pages" && isFoundryAdmin ? (
<IconButton label="Настройки Platform" onClick={openPlatformSettings}><Icon name="settings" /></IconButton>
) : undefined}
contextSlot={studioContext === "applications" ? (
<Select
@@ -1746,7 +1859,7 @@ export function CatalogApp() {
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name={studioContext === "applications" ? "apps" : studioContext === "pages" ? "globe" : "shield"} /></span>
<span>{studioContext === "applications" ? activeApplication ? `${activeApplication.metadata.name} · ${activeApplication.version}` : `${applicationSummaries.length} drafts` : studioContext === "pages" ? `${pageTemplates.length} templates` : "Design System 0.6.0"}</span>
<span>{studioContext === "applications" ? activeApplication ? `${activeApplication.metadata.name} · ${activeApplication.version}` : `${applicationSummaries.length} drafts` : studioContext === "pages" ? `${pageTemplates.length} templates` : "Design Guideline 0.7.0"}</span>
</>
}
/>
@@ -1923,7 +2036,7 @@ export function CatalogApp() {
<ConfirmationModal
open={deleteModuleOpen}
title="Удалить модуль?"
description={<><strong>{applicationDraft?.metadata.name}</strong><p>Draft будет убран из Module Studio. Опубликованные releases эта операция не затрагивает.</p></>}
description={<><strong>{applicationDraft?.metadata.name}</strong><p>Draft будет убран из Module Foundry. Опубликованные releases эта операция не затрагивает.</p></>}
confirmLabel="Удалить draft"
pendingLabel="Удаление…"
danger
@@ -1942,6 +2055,56 @@ export function CatalogApp() {
onConfirm={confirmPageRemoval}
/>
<Window
open={platformSettingsOpen}
title="Настройки Platform"
subtitle="Map Gateway · provider credentials"
size="sm"
onClose={closePlatformSettings}
footer={
<>
<Button variant="ghost" onClick={closePlatformSettings}>Отмена</Button>
<WindowFooterActions>
<Button
variant="primary"
shape="pill"
icon={<Icon name="save" />}
disabled={cesiumIonSaveState === "loading" || cesiumIonSaveState === "saving" || cesiumIonToken.trim().length < 16}
onClick={() => { void saveCesiumIonToken(); }}
>{cesiumIonSaveState === "saving" ? "Применение…" : "Применить"}</Button>
</WindowFooterActions>
</>
}
>
<div className="catalog-form">
<SettingsCard
title="Cesium Ion"
description="Master token хранится только в закрытом хранилище Platform Map Gateway. После применения он не отображается и не попадает в browser/env или artifact."
>
<ControlRow label="Состояние">
<strong>{cesiumIonSaveState === "loading" ? "Проверяем…" : cesiumIonStatus?.configured ? "Настроен" : "Не настроен"}</strong>
</ControlRow>
{cesiumIonStatus?.configured ? <ControlRow label="Проверка provider"><strong>{cesiumIonStatus.verification === "verified" ? "Пройдена: terrain и imagery доступны" : cesiumIonStatus.verification === "failed" ? "Не пройдена: проверьте token" : "Будет выполнена при следующем применении"}</strong></ControlRow> : null}
{cesiumIonStatus?.updatedAt ? <small>Последнее изменение: {new Date(cesiumIonStatus.updatedAt).toLocaleString()}</small> : null}
</SettingsCard>
<TextField
type="password"
autoComplete="new-password"
label="Cesium Ion token"
hint="обязательно"
description="Вставьте новый token. Существующее значение намеренно нельзя прочитать обратно."
value={cesiumIonToken}
onChange={(event) => {
setCesiumIonToken(event.target.value);
if (cesiumIonSaveState === "saved" || cesiumIonSaveState === "error") setCesiumIonSaveState("idle");
setCesiumIonError("");
}}
/>
{cesiumIonSaveState === "saved" ? <small className="catalog-application-draft__status">Token применён в Platform Map Gateway.</small> : null}
{cesiumIonSaveState === "error" ? <small className="catalog-application-draft__status" data-state="error">{cesiumIonError}</small> : null}
</div>
</Window>
<Window
open={createModalOpen}
title="Создать проект"
+332 -42
View File
@@ -9,27 +9,56 @@ import {
Cesium3DTileset,
Cesium3DTileStyle,
CesiumTerrainProvider,
CallbackProperty,
ConstantPositionProperty,
CustomDataSource,
DefaultProxy,
DistanceDisplayCondition,
EllipsoidTerrainProvider,
Entity,
HeightReference,
HeadingPitchRange,
HorizontalOrigin,
ImageryLayer,
JulianDate,
LabelGraphics,
Matrix4,
Math as CesiumMath,
PolygonHierarchy,
PointGraphics,
Resource,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
SunLight,
VerticalOrigin,
Viewer,
} from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
type Position = [number, number, number?];
type PinPresentation = {
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
labelOffsetX: number;
labelOffsetY: number;
pinHideCameraHeightMeters?: number;
labelHideCameraHeightMeters?: number;
};
type MapStyleProfile = {
id: string;
kind: string;
color?: string;
opacity?: number;
size?: number;
pinPresentation?: PinPresentation;
};
type RuntimeConfig = {
cesiumVersion: string;
provider: string;
@@ -44,17 +73,48 @@ type RuntimeConfig = {
};
export type MapGatewayHealth = {
cache?: { mode?: string; entries?: number; bytes?: number; maxBytes?: number; persistent?: boolean };
cache?: {
mode?: string;
writePolicy?: string;
entries?: number;
bytes?: number;
maxBytes?: number;
atCapacity?: boolean;
persistent?: boolean;
};
diagnostics?: {
cacheHits?: number;
cacheMisses?: number;
cacheRefreshes?: number;
upstreamRequests?: number;
egressRequests?: number;
upstreamFailures?: number;
slowUpstreamRequests?: number;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
ionConfigured?: boolean;
};
type MapProviderState = "loading" | "ready" | "error" | "not-configured";
// Provider loading must be observable independently. In particular, imagery
// is optional for scene construction: a Bing metadata failure must never
// prevent Cesium World Terrain from being requested and rendered.
export type MapProviderStatus = {
imagery: MapProviderState;
terrain: MapProviderState;
buildings: MapProviderState;
errors: Partial<Record<"imagery" | "terrain" | "buildings", string>>;
};
type IonAssetEndpoint = {
assetId: string;
type: "TERRAIN" | "3DTILES" | "IMAGERY";
url?: string;
accessToken?: string;
credentialMode?: "gateway";
externalType?: "BING";
options?: { url?: string; key?: string; mapStyle?: string };
options?: { url?: string; mapStyle?: string };
attributions: Array<{ html?: string; collapsible?: boolean }>;
};
@@ -62,6 +122,7 @@ export type MapPresentation = {
imagerySource: "cesium-live";
imageryVisible: boolean;
cacheEnabled: boolean;
cacheNoOverwrite: boolean;
terrainEnabled: boolean;
terrainExaggeration: number;
monochrome: boolean;
@@ -121,6 +182,22 @@ const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
const accent = Color.fromCssColorString("#ff2f92");
const violet = Color.fromCssColorString("#8f72dc");
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const defaultElevatedPin: PinPresentation = {
variant: "elevated-spike",
stemHeightMeters: 120,
headSizePx: 8,
stemWidthPx: 2,
outlineColor: "#0c0d12",
outlineOpacity: 0.6,
outlineWidthPx: 1,
labelOffsetX: 10,
labelOffsetY: 0,
};
function showBelowCameraHeight(viewer: Viewer, limit?: number) {
if (!limit) return true;
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
}
function getCameraView(viewer: Viewer): MapCameraView {
const position = viewer.camera.positionCartographic;
@@ -135,6 +212,9 @@ function getCameraView(viewer: Viewer): MapCameraView {
}
function addFixtureEntities(viewer: Viewer) {
const styleProfiles = new Map<string, MapStyleProfile>(
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
);
for (const place of sceneFixture.scene.places) {
viewer.entities.add({
id: place.id,
@@ -207,10 +287,29 @@ function addFixtureEntities(viewer: Viewer) {
},
});
}
const pinStyle = styleProfiles.get(object.pinStyleProfileId);
const pin = pinStyle?.pinPresentation ?? defaultElevatedPin;
const pinColor = Color.fromCssColorString(pinStyle?.color || "#ff2f92").withAlpha(pinStyle?.opacity ?? 1);
const outlineColor = Color.fromCssColorString(pin.outlineColor).withAlpha(pin.outlineOpacity);
const [longitude, latitude] = object.position as Position;
const base = Cartesian3.fromDegrees(longitude, latitude, 0);
const top = Cartesian3.fromDegrees(longitude, latitude, pin.stemHeightMeters);
viewer.entities.add({
id: object.id,
position: toCartesian(object.position as Position),
point: { pixelSize: 18, color: accent, outlineColor: Color.WHITE, outlineWidth: 3 },
position: top,
polyline: {
positions: [base, top],
width: pin.stemWidthPx,
material: pinColor,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
point: {
pixelSize: pin.headSizePx,
color: pinColor,
outlineColor,
outlineWidth: pin.outlineWidthPx,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
label: {
text: object.label.text,
font: "700 13px Arial",
@@ -218,7 +317,12 @@ function addFixtureEntities(viewer: Viewer) {
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(0, -31),
pixelOffset: new Cartesian2(pin.labelOffsetX, pin.labelOffsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, pin.labelHideCameraHeightMeters),
},
});
}
@@ -241,6 +345,82 @@ function addFixtureEntities(viewer: Viewer) {
}
}
function runtimeDisplayLabel(fact: MapRuntimeFact) {
for (const key of ["label", "name", "title", "subject_id"]) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return fact.sourceId;
}
function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it
// later without changing a data product or its L2 workflow.
return fact.semanticType === "map.moving_object" ? accent : violet;
}
function syncRuntimeDataSources(
viewer: Viewer,
dataSources: Map<string, CustomDataSource>,
bindings: MapRuntimeBinding[],
) {
const activeBindings = new Map(bindings
.filter((binding) => binding.slotId === "points")
.map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) {
if (activeBindings.has(bindingId)) continue;
viewer.dataSources.remove(dataSource, true);
dataSources.delete(bindingId);
}
for (const binding of activeBindings.values()) {
let dataSource = dataSources.get(binding.bindingId);
if (!dataSource) {
dataSource = new CustomDataSource(`nodedc-map-slot:${binding.bindingId}`);
viewer.dataSources.add(dataSource);
dataSources.set(binding.bindingId, dataSource);
}
const wanted = new Set<string>();
for (const fact of binding.facts) {
if (!fact.geometry) continue;
const entityId = mapRuntimeEntityId(binding.bindingId, fact);
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const color = runtimePointColor(fact);
const label = runtimeDisplayLabel(fact);
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
}
for (const entity of [...dataSource.entities.values]) {
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
}
}
viewer.scene.requestRender();
}
function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, presentation: MapPresentation) {
const entities = dataSource.entities;
entities.removeAll();
@@ -363,15 +543,21 @@ function applyPresentation(
export function CesiumMapRenderer({
onSelect,
onGatewayHealth,
onProviderStatus,
onCameraChange,
onCacheRefreshConsumed,
initialCamera,
presentation,
runtimeBindings = [],
}: {
onSelect?: (entityId: string) => void;
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
onProviderStatus?: (status: MapProviderStatus) => void;
onCameraChange?: (camera: MapCameraView) => void;
onCacheRefreshConsumed?: () => void;
initialCamera?: MapCameraView;
presentation: MapPresentation;
runtimeBindings?: MapRuntimeBinding[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null);
@@ -380,24 +566,45 @@ export function CesiumMapRenderer({
const buildingsRef = useRef<Cesium3DTileset | null>(null);
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
const rebuildGridRef = useRef<(() => void) | null>(null);
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
const presentationRef = useRef(presentation);
const runtimeBindingsRef = useRef(runtimeBindings);
const onSelectRef = useRef(onSelect);
const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
useEffect(() => {
onSelectRef.current = onSelect;
}, [onSelect]);
useEffect(() => {
onCameraChangeRef.current = onCameraChange;
}, [onCameraChange]);
useEffect(() => {
onCacheRefreshConsumedRef.current = onCacheRefreshConsumed;
}, [onCacheRefreshConsumed]);
useEffect(() => {
presentationRef.current = presentation;
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
rebuildGridRef.current?.();
}, [presentation]);
useEffect(() => {
runtimeBindingsRef.current = runtimeBindings;
if (viewerRef.current && !viewerRef.current.isDestroyed()) {
syncRuntimeDataSources(viewerRef.current, runtimeDataSourcesRef.current, runtimeBindings);
}
}, [runtimeBindings]);
useEffect(() => {
let viewer: Viewer | undefined;
let handler: ScreenSpaceEventHandler | undefined;
let resizeObserver: ResizeObserver | undefined;
let removeGridCameraListener: (() => void) | undefined;
let removeRefreshRenderListener: (() => void) | undefined;
let removeRenderErrorListener: (() => void) | undefined;
let cancelled = false;
const start = async () => {
@@ -426,27 +633,33 @@ export function CesiumMapRenderer({
timeline: false,
requestRenderMode: true,
maximumRenderTimeChange: Number.POSITIVE_INFINITY,
// The default Cesium panel hides the useful UI state and displays
// `[object Object]` for several non-Error render faults. We report a
// safe diagnostic through the Map panel and attempt one bounded
// recovery instead of leaving a modal over a stopped renderer.
showRenderLoopErrors: false,
// Sandbox-only: Cesium writes credits into a dedicated, visually
// suppressed container. Runtime attribution metadata is preserved;
// external and commercial surfaces must provide visible credits.
creditContainer: creditContainerRef.current ?? undefined,
});
const resourceProxy = config?.resourceProxyBase ? new DefaultProxy(config.resourceProxyBase) : undefined;
const buildResource = (url: string, accessToken?: string) => {
const buildResource = (url: string) => {
// Put cache intent into the upstream URL itself. Cesium providers
// derive child resources (metadata, imagery tiles, terrain and 3D
// tiles) from this URL; DefaultProxy then forwards the exact intent
// to Gateway for every derived request.
const routedUrl = new URL(url);
// Provider bytes are cached once by Platform Map Gateway. Version
// the browser-facing route so an old malformed HTTP response cannot
// shadow the shared TileCache after a transport fix.
routedUrl.searchParams.set("nodedc_client_revision", "2");
if (!presentationRef.current.cacheEnabled) routedUrl.searchParams.set("nodedc_cache_mode", "passthrough");
if (presentationRef.current.cacheRefresh) routedUrl.searchParams.set("nodedc_cache_refresh", "1");
if (presentationRef.current.cacheRefresh || !presentationRef.current.cacheNoOverwrite) routedUrl.searchParams.set("nodedc_cache_refresh", "1");
return new Resource({
url: routedUrl.toString(),
queryParameters: {
...(accessToken ? { access_token: accessToken } : {}),
},
proxy: resourceProxy,
});
url: routedUrl.toString(),
proxy: resourceProxy,
});
};
const loadEndpoint = async (assetId: string) => {
if (!config?.gatewayReady) throw new Error("map_gateway_not_ready");
@@ -454,23 +667,14 @@ export function CesiumMapRenderer({
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
return endpointResponse.json() as Promise<IonAssetEndpoint>;
};
const endpoint = await loadEndpoint("2");
if (endpoint.externalType !== "BING" || !endpoint.options?.url || !endpoint.options?.key) throw new Error("cesium_live_imagery_endpoint_invalid");
const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), {
key: endpoint.options.key,
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
tileProtocol: "https",
});
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
const imageryLayer = viewer.imageryLayers.addImageryProvider(imageryProvider);
const gridDataSource = new CustomDataSource("nodedc-map-grid");
viewer.dataSources.add(gridDataSource);
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
viewer.terrainProvider = terrain.ellipsoid;
viewer.scene.globe.depthTestAgainstTerrain = true;
addFixtureEntities(viewer);
syncRuntimeDataSources(viewer, runtimeDataSourcesRef.current, runtimeBindingsRef.current);
viewerRef.current = viewer;
imageryLayerRef.current = imageryLayer;
terrainRef.current = terrain;
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
rebuildGridRef.current = rebuildGrid;
@@ -479,35 +683,96 @@ export function CesiumMapRenderer({
onCameraChangeRef.current?.(getCameraView(viewer!));
});
const providerStatus: MapProviderStatus = {
imagery: config?.gatewayReady ? "loading" : "not-configured",
terrain: config?.gatewayReady ? "loading" : "not-configured",
buildings: config?.gatewayReady ? "loading" : "not-configured",
errors: config?.gatewayReady ? {} : {
imagery: "Platform Map Gateway недоступен",
terrain: "Platform Map Gateway недоступен",
buildings: "Platform Map Gateway недоступен",
},
};
const reportProvider = (provider: "imagery" | "terrain" | "buildings", state: MapProviderState, error?: unknown) => {
providerStatus[provider] = state;
if (state === "error") {
providerStatus.errors[provider] = error instanceof Error && error.message ? error.message : "provider_unavailable";
} else {
delete providerStatus.errors[provider];
}
if (!cancelled) onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
};
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
let renderRecoveryScheduled = false;
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, 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";
reportProvider("imagery", "error", safe);
reportProvider("terrain", "error", safe);
reportProvider("buildings", "error", safe);
if (renderRecoveryScheduled) return;
renderRecoveryScheduled = true;
window.setTimeout(() => {
if (cancelled || !viewer || viewer.isDestroyed()) return;
// Cesium's default listener stops its render loop after a scene
// error. One retry handles a transient resource/resize race but
// remains bounded if the browser or GPU fault is persistent.
viewer.useDefaultRenderLoop = true;
viewer.scene.requestRender();
}, 0);
});
if (config?.gatewayReady) {
// Do not serialize provider startup. A failure in Bing imagery is
// recoverable and must not stop terrain, buildings, or the scene.
void loadEndpoint("2").then(async (endpoint) => {
if (endpoint.externalType !== "BING" || !endpoint.options?.url || endpoint.credentialMode !== "gateway") throw new Error("cesium_live_imagery_endpoint_invalid");
const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), {
// Cesium requires a key-shaped value to form its Bing URL. This
// public marker is stripped by Map Gateway before upstream use.
key: "nodedc-gateway",
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
tileProtocol: "https",
});
if (cancelled || !viewer || viewer.isDestroyed()) return;
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
reportProvider("imagery", "ready");
}).catch((error) => reportProvider("imagery", "error", error));
void loadEndpoint("1").then(async (terrainEndpoint) => {
if (!terrainEndpoint.url || !terrainEndpoint.accessToken) throw new Error("terrain_endpoint_invalid");
const terrainResource = buildResource(terrainEndpoint.url, terrainEndpoint.accessToken);
if (!terrainEndpoint.url || terrainEndpoint.credentialMode !== "gateway") throw new Error("terrain_endpoint_invalid");
const terrainResource = buildResource(terrainEndpoint.url);
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
if (cancelled || !viewer || viewer.isDestroyed()) return;
terrain.world = world;
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
}).catch(() => undefined);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
reportProvider("terrain", "ready");
}).catch((error) => reportProvider("terrain", "error", error));
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
if (!buildingsEndpoint.url || !buildingsEndpoint.accessToken) throw new Error("buildings_endpoint_invalid");
const buildingsResource = buildResource(buildingsEndpoint.url, buildingsEndpoint.accessToken);
if (!buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid");
const buildingsResource = buildResource(buildingsEndpoint.url);
const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
if (cancelled || !viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.add(buildings);
buildingsRef.current = buildings;
for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayer, buildings, terrain, presentationRef.current);
}).catch(() => undefined);
applyPresentation(viewer, imageryLayerRef.current, buildings, terrain, presentationRef.current);
reportProvider("buildings", "ready");
}).catch((error) => reportProvider("buildings", "error", error));
if (config.gaussianSplatsReady && config.gaussianAssetId) {
const gaussianEndpoint = await loadEndpoint(config.gaussianAssetId);
if (!gaussianEndpoint.url || !gaussianEndpoint.accessToken) throw new Error("gaussian_endpoint_invalid");
const gaussianResource = buildResource(gaussianEndpoint.url, gaussianEndpoint.accessToken);
viewer.scene.primitives.add(await Cesium3DTileset.fromUrl(gaussianResource));
void loadEndpoint(config.gaussianAssetId).then(async (gaussianEndpoint) => {
if (!gaussianEndpoint.url || gaussianEndpoint.credentialMode !== "gateway") throw new Error("gaussian_endpoint_invalid");
const gaussianResource = buildResource(gaussianEndpoint.url);
const gaussian = await Cesium3DTileset.fromUrl(gaussianResource);
if (cancelled || !viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.add(gaussian);
}).catch(() => undefined);
}
}
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
if (initialCamera) {
viewer.camera.setView({
@@ -530,11 +795,23 @@ export function CesiumMapRenderer({
rebuildGrid();
onCameraChangeRef.current?.(getCameraView(viewer));
if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) {
// All root provider resources for the current view were created with
// `nodedc_cache_refresh=1`. Reset after the first render so later
// navigation goes back to the selected steady-state policy.
removeRefreshRenderListener = viewer.scene.postRender.addEventListener(() => {
removeRefreshRenderListener?.();
removeRefreshRenderListener = undefined;
if (!cancelled) onCacheRefreshConsumedRef.current?.();
});
viewer.scene.requestRender();
}
handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement: { position: Cartesian2 }) => {
const picked = viewer?.scene.pick(movement.position);
const entity = picked?.id instanceof Entity ? picked.id : undefined;
if (entity?.id) onSelect?.(entity.id);
if (entity?.id) onSelectRef.current?.(entity.id);
}, ScreenSpaceEventType.LEFT_CLICK);
resizeObserver = new ResizeObserver(() => {
if (!viewer || viewer.isDestroyed()) return;
@@ -542,9 +819,19 @@ export function CesiumMapRenderer({
viewer.scene.requestRender();
});
resizeObserver.observe(containerRef.current);
} catch {
// The canvas stays available even if an optional provider fails. Its
// detailed state belongs to the Inspector, not to a map overlay label.
} catch (error) {
// Failure before viewer creation is distinct from a provider failure;
// surface it through the Inspector without exposing any credentials.
if (!cancelled) onProviderStatus?.({
imagery: "error",
terrain: "error",
buildings: "error",
errors: {
imagery: error instanceof Error ? error.message : "map_start_failed",
terrain: error instanceof Error ? error.message : "map_start_failed",
buildings: error instanceof Error ? error.message : "map_start_failed",
},
});
}
};
@@ -553,6 +840,8 @@ export function CesiumMapRenderer({
cancelled = true;
resizeObserver?.disconnect();
removeGridCameraListener?.();
removeRefreshRenderListener?.();
removeRenderErrorListener?.();
handler?.destroy();
if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null;
@@ -560,8 +849,9 @@ export function CesiumMapRenderer({
buildingsRef.current = null;
terrainRef.current = null;
rebuildGridRef.current = null;
runtimeDataSourcesRef.current.clear();
};
}, [onGatewayHealth, onSelect]);
}, [onGatewayHealth, onProviderStatus]);
return (
<div className="catalog-cesium-map">
+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}
+3 -1
View File
@@ -38,7 +38,9 @@ textarea {
isolation: isolate;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-canvas-soft) url("/launcher-stage-poster.png") center / cover no-repeat;
/* The actual saved video is the first visual frame. Never show the bundled
pink sample poster while its runtime media stream is starting. */
background: var(--nodedc-canvas-soft);
box-shadow: 0 44px 150px color-mix(in srgb, var(--nodedc-canvas) 70%, transparent);
}
@@ -0,0 +1,301 @@
import { useEffect, useMemo, useState } from "react";
import type { MapDataProductBinding } from "./MapFixturePreview.js";
export type DataProductPoint = {
type: "Point";
coordinates: [number, number];
};
/**
* The only entity shape the map runtime accepts from Platform. It is the
* canonical data-product fact, not a provider payload and not a Cesium model.
*/
export type MapRuntimeFact = {
sourceId: string;
semanticType: string;
observedAt: string;
receivedAt: string;
attributes: Record<string, unknown>;
geometry: DataProductPoint | null;
};
export type MapRuntimeBinding = {
bindingId: string;
dataProductId: string;
slotId: string;
facts: MapRuntimeFact[];
cursor: string | null;
state: "idle" | "loading" | "ready" | "reconnecting" | "error";
};
type SnapshotEnvelope = {
schemaVersion: "nodedc.data-product.snapshot/v1";
dataProduct: { id: string; version: string };
generatedAt: string;
cursor: string;
facts: MapRuntimeFact[];
};
type PatchEnvelope = {
schemaVersion: "nodedc.data-product.patch/v1";
dataProduct: { id: string; version: string };
cursor: string;
previousCursor: string;
emittedAt: string;
operations: Array<{ op: "upsert"; fact: MapRuntimeFact }>;
};
type BindingState = {
binding: MapDataProductBinding;
cursor: string | null;
facts: Record<string, MapRuntimeFact>;
state: MapRuntimeBinding["state"];
};
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const cursorPattern = /^(?:0|[1-9]\d*)$/;
function factKey(fact: MapRuntimeFact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
}
function isIsoTimestamp(value: unknown): value is string {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function asPoint(value: unknown): DataProductPoint | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as { type?: unknown; coordinates?: unknown };
if (candidate.type !== "Point" || !Array.isArray(candidate.coordinates) || candidate.coordinates.length !== 2) return null;
const [longitude, latitude] = candidate.coordinates;
if (typeof longitude !== "number" || !Number.isFinite(longitude) || typeof latitude !== "number" || !Number.isFinite(latitude)) return null;
return { type: "Point", coordinates: [longitude, latitude] };
}
function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const sourceId = typeof candidate.sourceId === "string" ? candidate.sourceId : "";
const semanticType = typeof candidate.semanticType === "string" ? candidate.semanticType : "";
if (!identifier.test(sourceId) || !identifier.test(semanticType) || !binding.semanticTypes.includes(semanticType)) return null;
if (!isIsoTimestamp(candidate.observedAt) || !isIsoTimestamp(candidate.receivedAt)) return null;
const sourceAttributes = candidate.attributes && typeof candidate.attributes === "object" && !Array.isArray(candidate.attributes)
? candidate.attributes as Record<string, unknown>
: {};
// A data-product binding is also a field-level browser projection. Treat an
// empty projection as no optional attributes rather than as "all fields".
const allowed = new Set(binding.fieldProjection);
const attributes = Object.fromEntries(Object.entries(sourceAttributes).filter(([key]) => allowed.has(key)));
return {
sourceId,
semanticType,
observedAt: candidate.observedAt,
receivedAt: candidate.receivedAt,
attributes,
geometry: asPoint(candidate.geometry),
};
}
function asSnapshot(value: unknown, binding: MapDataProductBinding): SnapshotEnvelope | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const product = candidate.dataProduct;
if (candidate.schemaVersion !== "nodedc.data-product.snapshot/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !isIsoTimestamp(candidate.generatedAt) || !Array.isArray(candidate.facts)) return null;
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
generatedAt: candidate.generatedAt,
cursor: String(candidate.cursor),
facts: candidate.facts.map((fact) => asFact(fact, binding)).filter((fact): fact is MapRuntimeFact => Boolean(fact)),
};
}
function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const product = candidate.dataProduct;
if (candidate.schemaVersion !== "nodedc.data-product.patch/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !cursorPattern.test(String(candidate.previousCursor || "")) || !isIsoTimestamp(candidate.emittedAt) || !Array.isArray(candidate.operations)) return null;
const operations = candidate.operations.flatMap((operation) => {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
const value = operation as { op?: unknown; fact?: unknown };
if (value.op !== "upsert") return [];
const fact = asFact(value.fact, binding);
return fact ? [{ op: "upsert" as const, fact }] : [];
});
return {
schemaVersion: "nodedc.data-product.patch/v1",
dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
cursor: String(candidate.cursor),
previousCursor: String(candidate.previousCursor),
emittedAt: candidate.emittedAt,
operations,
};
}
function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): BindingState {
return {
...current,
cursor: snapshot.cursor,
state: "ready",
facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])),
};
}
function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState | null {
// A durable stream must advance from the exact snapshot/previous patch
// cursor. Any gap is recoverable: re-fetch a current snapshot before the
// browser applies more updates.
if (current.cursor !== patch.previousCursor) return null;
const facts = { ...current.facts };
for (const operation of patch.operations) facts[factKey(operation.fact)] = operation.fact;
return { ...current, facts, cursor: patch.cursor, state: "ready" };
}
function stateFor(binding: MapDataProductBinding, state: MapRuntimeBinding["state"] = "idle"): BindingState {
return { binding, cursor: null, facts: {}, state };
}
function runtimePath(applicationId: string, pageId: string, bindingId: string, resource: "snapshot" | "stream") {
return `/api/applications/${encodeURIComponent(applicationId)}/pages/${encodeURIComponent(pageId)}/data-bindings/${encodeURIComponent(bindingId)}/${resource}`;
}
/**
* Opens a Platform data product only while the page is mounted. The browser
* knows the target binding but never a provider URL, tenant/connection scope,
* or reader credential: those are resolved by Foundry's same-origin BFF.
*/
export function useMapDataProductRuntime({
applicationId,
pageId,
bindings,
enabled = true,
}: {
applicationId?: string;
pageId?: string;
bindings: MapDataProductBinding[];
enabled?: boolean;
}) {
const [records, setRecords] = useState<Record<string, BindingState>>({});
const bindingSignature = useMemo(
() => JSON.stringify(bindings.map((binding) => ({
id: binding.id,
dataProductId: binding.dataProductId,
slotId: binding.slotId,
semanticTypes: binding.semanticTypes,
fieldProjection: binding.fieldProjection,
}))),
[bindings],
);
useEffect(() => {
if (!enabled || !applicationId || !pageId || bindings.length === 0) {
setRecords({});
return;
}
let disposed = false;
const sources = new Map<string, EventSource>();
const controllers = new Set<AbortController>();
const retries = new Map<string, number>();
const latest = new Map<string, BindingState>();
const update = (binding: MapDataProductBinding, updater: (current: BindingState) => BindingState) => {
if (disposed) return stateFor(binding);
const next = updater(latest.get(binding.id) ?? stateFor(binding));
latest.set(binding.id, next);
setRecords((current) => ({ ...current, [binding.id]: next }));
return next;
};
const scheduleReconnect = (binding: MapDataProductBinding) => {
if (disposed || retries.has(binding.id)) return;
update(binding, (current) => ({ ...current, state: "reconnecting" }));
const timer = window.setTimeout(() => {
retries.delete(binding.id);
void loadSnapshotAndStream(binding);
}, 1_500);
retries.set(binding.id, timer);
};
const loadSnapshotAndStream = async (binding: MapDataProductBinding) => {
const existing = sources.get(binding.id);
if (existing) {
existing.close();
sources.delete(binding.id);
}
update(binding, (current) => ({ ...current, state: "loading" }));
const controller = new AbortController();
controllers.add(controller);
try {
const snapshotResponse = await fetch(runtimePath(applicationId, pageId, binding.id, "snapshot"), {
cache: "no-store",
credentials: "same-origin",
signal: controller.signal,
});
if (!snapshotResponse.ok) throw new Error(`snapshot_${snapshotResponse.status}`);
const snapshot = asSnapshot(await snapshotResponse.json(), binding);
if (!snapshot) throw new Error("snapshot_contract_invalid");
if (disposed) return;
update(binding, (current) => replaceSnapshot(current, snapshot));
const source = new EventSource(`${runtimePath(applicationId, pageId, binding.id, "stream")}?after=${encodeURIComponent(snapshot.cursor)}`, { withCredentials: true });
sources.set(binding.id, source);
source.addEventListener("nodedc.data-product.patch.v1", (event) => {
let patch: PatchEnvelope | null = null;
try {
patch = asPatch(JSON.parse((event as MessageEvent<string>).data), binding);
} catch {
return;
}
if (!patch) return;
const next = update(binding, (current) => {
const next = applyPatch(current, patch);
return next ?? current;
});
if (next.cursor !== patch.cursor) void loadSnapshotAndStream(binding);
});
source.addEventListener("nodedc.data-product.resync-required.v1", () => {
void loadSnapshotAndStream(binding);
});
source.onerror = () => {
// EventSource will retry transient transport failures itself. A
// permanent upstream rejection closes the stream; loading a fresh
// snapshot also handles an expired outbox cursor deterministically.
if (source.readyState === EventSource.CLOSED) scheduleReconnect(binding);
};
} catch (error) {
if (disposed || controller.signal.aborted) return;
update(binding, (current) => ({ ...current, state: "error" }));
scheduleReconnect(binding);
} finally {
controllers.delete(controller);
}
};
for (const binding of bindings) void loadSnapshotAndStream(binding);
return () => {
disposed = true;
for (const source of sources.values()) source.close();
for (const controller of controllers) controller.abort();
for (const timer of retries.values()) window.clearTimeout(timer);
};
}, [applicationId, bindingSignature, bindings, enabled, pageId]);
return useMemo<MapRuntimeBinding[]>(() => bindings.map((binding) => {
const record = records[binding.id] ?? stateFor(binding);
return {
bindingId: binding.id,
dataProductId: binding.dataProductId,
slotId: binding.slotId,
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
cursor: record.cursor,
state: record.state,
};
}), [bindings, records]);
}