feat(foundry): productionize Cesium Map Page and platform runtime
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-catalog",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+174
-11
@@ -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="Создать проект"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user