feat(foundry): productionize Cesium Map Page and platform runtime
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user