perf(map): stage cesium provider startup
This commit is contained in:
@@ -37,6 +37,11 @@ import type {
|
||||
MapPresentationProfile,
|
||||
} from "./mapPresentationProfile.js";
|
||||
import { fixedGridOrigin } from "./mapSectorGrid.mjs";
|
||||
import {
|
||||
MAP_PROVIDER_STARTUP_POLICY,
|
||||
runStagedMapProviders,
|
||||
waitForGlobeViewportReady,
|
||||
} from "./mapProviderStartup.mjs";
|
||||
import type {
|
||||
CameraSpiralConfig,
|
||||
CameraSpiralState,
|
||||
@@ -941,14 +946,15 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
let removeRefreshRenderListener: (() => void) | undefined;
|
||||
let removeRenderErrorListener: (() => void) | undefined;
|
||||
const removeProviderFailureListeners: Array<() => void> = [];
|
||||
const providerStartupAbort = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/map/runtime-config");
|
||||
const response = await fetch("/api/map/runtime-config", { signal: providerStartupAbort.signal });
|
||||
const config = response.ok ? await response.json() as RuntimeConfig : null;
|
||||
if (config?.gatewayHealthUrl) {
|
||||
void fetch(config.gatewayHealthUrl)
|
||||
void fetch(config.gatewayHealthUrl, { signal: providerStartupAbort.signal })
|
||||
.then((healthResponse) => healthResponse.ok ? healthResponse.json() as Promise<MapGatewayHealth> : null)
|
||||
.then((health) => { if (!cancelled) onGatewayHealth?.(health); })
|
||||
.catch(() => { if (!cancelled) onGatewayHealth?.(null); });
|
||||
@@ -997,9 +1003,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
proxy: resourceProxy,
|
||||
});
|
||||
};
|
||||
const loadEndpoint = async (assetId: string) => {
|
||||
const loadEndpoint = async (assetId: string, signal = providerStartupAbort.signal) => {
|
||||
if (!config?.gatewayReady) throw new Error("map_gateway_not_ready");
|
||||
const endpointResponse = await fetch(`${config.assetEndpointBase}/${assetId}/endpoint`);
|
||||
const endpointResponse = await fetch(`${config.assetEndpointBase}/${assetId}/endpoint`, {
|
||||
signal,
|
||||
});
|
||||
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
|
||||
return endpointResponse.json() as Promise<IonAssetEndpoint>;
|
||||
};
|
||||
@@ -1102,57 +1110,83 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
);
|
||||
|
||||
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;
|
||||
removeProviderFailureListeners.push(imageryProvider.errorEvent.addEventListener(() => latchProviderTileFailure("imagery")));
|
||||
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.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;
|
||||
removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => latchProviderTileFailure("terrain")));
|
||||
terrain.world = world;
|
||||
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
|
||||
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
|
||||
reportProvider("terrain", "ready");
|
||||
}).catch((error) => reportProvider("terrain", "error", error));
|
||||
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
|
||||
if (buildingsEndpoint.type !== "3DTILES" || !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;
|
||||
removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings")));
|
||||
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, imageryLayerRef.current, buildings, terrain, presentationRef.current);
|
||||
reportProvider("buildings", "ready");
|
||||
}).catch((error) => reportProvider("buildings", "error", error));
|
||||
if (config.gaussianSplatsReady && config.gaussianAssetId) {
|
||||
void loadEndpoint(config.gaussianAssetId).then(async (gaussianEndpoint) => {
|
||||
// Give the visible base map first use of network and decoder
|
||||
// capacity. Public Globe readiness events provide bounded gates;
|
||||
// provider errors remain isolated and never block a later layer.
|
||||
void runStagedMapProviders({
|
||||
signal: providerStartupAbort.signal,
|
||||
isCancelled: () => cancelled || !viewer || viewer.isDestroyed(),
|
||||
loadImagery: async ({ signal }) => {
|
||||
const endpoint = await loadEndpoint("2", signal);
|
||||
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 Gateway before upstream.
|
||||
key: "nodedc-gateway",
|
||||
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
|
||||
tileProtocol: "https",
|
||||
});
|
||||
if (signal.aborted || cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
removeProviderFailureListeners.push(imageryProvider.errorEvent.addEventListener(() => latchProviderTileFailure("imagery")));
|
||||
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");
|
||||
},
|
||||
waitAfterImagery: async () => {
|
||||
if (!viewer || viewer.isDestroyed() || !imageryLayerRef.current?.show) return;
|
||||
await waitForGlobeViewportReady({
|
||||
globe: viewer.scene.globe,
|
||||
scene: viewer.scene,
|
||||
signal: providerStartupAbort.signal,
|
||||
timeoutMs: MAP_PROVIDER_STARTUP_POLICY.imageryViewportTimeoutMs,
|
||||
});
|
||||
},
|
||||
loadTerrain: async ({ signal }) => {
|
||||
const terrainEndpoint = await loadEndpoint("1", signal);
|
||||
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 (signal.aborted || cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => latchProviderTileFailure("terrain")));
|
||||
terrain.world = world;
|
||||
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
|
||||
onReadyChangeRef.current?.(!presentationRef.current.terrainEnabled || Boolean(terrain.world));
|
||||
reportProvider("terrain", "ready");
|
||||
},
|
||||
waitAfterTerrain: async () => {
|
||||
if (!viewer || viewer.isDestroyed() || !presentationRef.current.terrainEnabled || !terrain.world) return;
|
||||
await waitForGlobeViewportReady({
|
||||
globe: viewer.scene.globe,
|
||||
scene: viewer.scene,
|
||||
signal: providerStartupAbort.signal,
|
||||
timeoutMs: MAP_PROVIDER_STARTUP_POLICY.terrainViewportTimeoutMs,
|
||||
});
|
||||
},
|
||||
loadBuildings: async ({ signal }) => {
|
||||
const buildingsEndpoint = await loadEndpoint("96188", signal);
|
||||
if (buildingsEndpoint.type !== "3DTILES" || !buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid");
|
||||
const buildingsResource = buildResource(buildingsEndpoint.url);
|
||||
const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
|
||||
if (signal.aborted || cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings")));
|
||||
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, imageryLayerRef.current, buildings, terrain, presentationRef.current);
|
||||
reportProvider("buildings", "ready");
|
||||
},
|
||||
loadDeferred: config.gaussianSplatsReady && config.gaussianAssetId ? async () => {
|
||||
const gaussianEndpoint = await loadEndpoint(config.gaussianAssetId!);
|
||||
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);
|
||||
}
|
||||
} : undefined,
|
||||
onProviderError: (provider, error) => reportProvider(provider, "error", error),
|
||||
});
|
||||
}
|
||||
|
||||
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
|
||||
@@ -1250,6 +1284,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
void start();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
providerStartupAbort.abort();
|
||||
stopSpiralAnimation("renderer_restarted");
|
||||
onReadyChangeRef.current?.(false);
|
||||
resizeObserver?.disconnect();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export type MapProviderStartupPolicy = Readonly<{
|
||||
providerInitializationTimeoutMs: number;
|
||||
imageryViewportTimeoutMs: number;
|
||||
terrainViewportTimeoutMs: number;
|
||||
minimumRenderFrames: number;
|
||||
}>;
|
||||
|
||||
export type MapProviderStartupReason = "loaded" | "timeout" | "cancelled";
|
||||
export type MapProviderStartupResult = "complete" | "cancelled";
|
||||
export type MapProviderName = "imagery" | "terrain" | "buildings";
|
||||
|
||||
type CesiumEventLike<TListener extends (...args: never[]) => void> = {
|
||||
addEventListener(listener: TListener): () => void;
|
||||
};
|
||||
|
||||
export const MAP_PROVIDER_STARTUP_POLICY: MapProviderStartupPolicy;
|
||||
|
||||
export function waitForGlobeViewportReady(input: {
|
||||
globe: {
|
||||
readonly tilesLoaded: boolean;
|
||||
tileLoadProgressEvent: CesiumEventLike<(pendingRequests: number) => void>;
|
||||
};
|
||||
scene: {
|
||||
postRender: CesiumEventLike<() => void>;
|
||||
requestRender(): void;
|
||||
};
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
minimumRenderFrames?: number;
|
||||
}): Promise<MapProviderStartupReason>;
|
||||
|
||||
export function runStagedMapProviders(input: {
|
||||
signal?: AbortSignal;
|
||||
isCancelled?: () => boolean;
|
||||
providerInitializationTimeoutMs?: number;
|
||||
loadImagery: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||
waitAfterImagery?: () => void | Promise<void>;
|
||||
loadTerrain: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||
waitAfterTerrain?: () => void | Promise<void>;
|
||||
loadBuildings: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||
loadDeferred?: () => void | Promise<void>;
|
||||
onProviderError?: (provider: MapProviderName, error: unknown) => void;
|
||||
onDeferredError?: (error: unknown) => void;
|
||||
}): Promise<MapProviderStartupResult>;
|
||||
@@ -0,0 +1,141 @@
|
||||
export const MAP_PROVIDER_STARTUP_POLICY = Object.freeze({
|
||||
providerInitializationTimeoutMs: 8_000,
|
||||
imageryViewportTimeoutMs: 6_000,
|
||||
terrainViewportTimeoutMs: 5_000,
|
||||
minimumRenderFrames: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Wait until Cesium has rendered enough frames to discover the current
|
||||
* viewport and the public Globe queue reports that its terrain and imagery
|
||||
* are loaded. The deadline is deliberate: a slow or unavailable provider
|
||||
* must never block the next independent layer.
|
||||
*/
|
||||
export function waitForGlobeViewportReady({
|
||||
globe,
|
||||
scene,
|
||||
signal,
|
||||
timeoutMs,
|
||||
minimumRenderFrames = MAP_PROVIDER_STARTUP_POLICY.minimumRenderFrames,
|
||||
}) {
|
||||
if (signal?.aborted) return Promise.resolve("cancelled");
|
||||
|
||||
const frameTarget = Math.max(1, Math.trunc(minimumRenderFrames));
|
||||
const deadlineMs = Math.max(0, Math.trunc(timeoutMs));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let renderFrames = 0;
|
||||
let removeProgressListener;
|
||||
let removePostRenderListener;
|
||||
let timeout;
|
||||
|
||||
const cleanup = () => {
|
||||
removeProgressListener?.();
|
||||
removePostRenderListener?.();
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const finish = (reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(reason);
|
||||
};
|
||||
const inspect = () => {
|
||||
if (renderFrames >= frameTarget && globe.tilesLoaded) finish("loaded");
|
||||
};
|
||||
const onAbort = () => finish("cancelled");
|
||||
const onTileLoadProgress = () => {
|
||||
inspect();
|
||||
if (!settled) scene.requestRender();
|
||||
};
|
||||
const onPostRender = () => {
|
||||
renderFrames += 1;
|
||||
inspect();
|
||||
if (!settled && renderFrames < frameTarget) scene.requestRender();
|
||||
};
|
||||
|
||||
removeProgressListener = globe.tileLoadProgressEvent.addEventListener(onTileLoadProgress);
|
||||
removePostRenderListener = scene.postRender.addEventListener(onPostRender);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
timeout = setTimeout(() => finish("timeout"), deadlineMs);
|
||||
scene.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve independent failure domains while giving the base map first use
|
||||
* of network and decode capacity. Each provider owns its error; viewport
|
||||
* gates are scheduling hints and therefore never become provider failures.
|
||||
*/
|
||||
export async function runStagedMapProviders({
|
||||
signal,
|
||||
isCancelled,
|
||||
providerInitializationTimeoutMs = MAP_PROVIDER_STARTUP_POLICY.providerInitializationTimeoutMs,
|
||||
loadImagery,
|
||||
waitAfterImagery,
|
||||
loadTerrain,
|
||||
waitAfterTerrain,
|
||||
loadBuildings,
|
||||
loadDeferred,
|
||||
onProviderError,
|
||||
onDeferredError,
|
||||
}) {
|
||||
const cancelled = () => Boolean(signal?.aborted || isCancelled?.());
|
||||
const loadProvider = async (provider, load) => {
|
||||
if (cancelled()) return false;
|
||||
const stageAbort = new AbortController();
|
||||
let timeout;
|
||||
let rejectDeadline;
|
||||
const abortStage = () => {
|
||||
rejectDeadline?.(new Error(`${provider}_startup_cancelled`));
|
||||
stageAbort.abort();
|
||||
};
|
||||
signal?.addEventListener("abort", abortStage, { once: true });
|
||||
const deadline = new Promise((_, reject) => {
|
||||
rejectDeadline = reject;
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error(`${provider}_startup_timeout`));
|
||||
stageAbort.abort();
|
||||
}, Math.max(0, Math.trunc(providerInitializationTimeoutMs)));
|
||||
});
|
||||
try {
|
||||
await Promise.race([load({ signal: stageAbort.signal }), deadline]);
|
||||
return !cancelled() && !stageAbort.signal.aborted;
|
||||
} catch (error) {
|
||||
if (!cancelled()) onProviderError?.(provider, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abortStage);
|
||||
}
|
||||
};
|
||||
const waitForViewport = async (wait) => {
|
||||
if (!wait || cancelled()) return;
|
||||
try {
|
||||
await wait();
|
||||
} catch {
|
||||
// A readiness gate controls ordering only. The provider's own error
|
||||
// event remains the authority for availability and user diagnostics.
|
||||
}
|
||||
};
|
||||
|
||||
const imageryReady = await loadProvider("imagery", loadImagery);
|
||||
if (imageryReady) await waitForViewport(waitAfterImagery);
|
||||
|
||||
const terrainReady = await loadProvider("terrain", loadTerrain);
|
||||
if (terrainReady) await waitForViewport(waitAfterTerrain);
|
||||
|
||||
await loadProvider("buildings", loadBuildings);
|
||||
|
||||
if (loadDeferred && !cancelled()) {
|
||||
try {
|
||||
await loadDeferred();
|
||||
} catch (error) {
|
||||
if (!cancelled()) onDeferredError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
return cancelled() ? "cancelled" : "complete";
|
||||
}
|
||||
Reference in New Issue
Block a user