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";
|
||||
}
|
||||
+2
-1
@@ -11,7 +11,7 @@
|
||||
"scripts": {
|
||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-provider-startup && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
@@ -32,6 +32,7 @@
|
||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
||||
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
||||
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||
"test:map-provider-startup": "node --test scripts/map-provider-startup.test.mjs",
|
||||
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
runStagedMapProviders,
|
||||
waitForGlobeViewportReady,
|
||||
} from "../apps/catalog/src/mapProviderStartup.mjs";
|
||||
|
||||
function eventHarness() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
event: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
raise(...args) {
|
||||
for (const listener of [...listeners]) listener(...args);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("providers start in base-map order", async () => {
|
||||
const calls = [];
|
||||
const result = await runStagedMapProviders({
|
||||
loadImagery: () => calls.push("imagery"),
|
||||
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||
loadTerrain: () => calls.push("terrain"),
|
||||
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||
loadBuildings: () => calls.push("buildings"),
|
||||
loadDeferred: () => calls.push("deferred"),
|
||||
});
|
||||
|
||||
assert.equal(result, "complete");
|
||||
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "terrain-ready", "buildings", "deferred"]);
|
||||
});
|
||||
|
||||
test("imagery failure is reported without blocking terrain or buildings", async () => {
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
await runStagedMapProviders({
|
||||
loadImagery: () => { calls.push("imagery"); throw new Error("imagery failed"); },
|
||||
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||
loadTerrain: () => calls.push("terrain"),
|
||||
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||
loadBuildings: () => calls.push("buildings"),
|
||||
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["imagery", "terrain", "terrain-ready", "buildings"]);
|
||||
assert.deepEqual(errors, [["imagery", "imagery failed"]]);
|
||||
});
|
||||
|
||||
test("terrain failure is reported without blocking buildings", async () => {
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
await runStagedMapProviders({
|
||||
loadImagery: () => calls.push("imagery"),
|
||||
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||
loadTerrain: () => { calls.push("terrain"); throw new Error("terrain failed"); },
|
||||
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||
loadBuildings: () => calls.push("buildings"),
|
||||
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "buildings"]);
|
||||
assert.deepEqual(errors, [["terrain", "terrain failed"]]);
|
||||
});
|
||||
|
||||
test("cancellation prevents all later stages", async () => {
|
||||
const controller = new AbortController();
|
||||
const calls = [];
|
||||
const result = await runStagedMapProviders({
|
||||
signal: controller.signal,
|
||||
loadImagery: () => { calls.push("imagery"); controller.abort(); },
|
||||
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||
loadTerrain: () => calls.push("terrain"),
|
||||
loadBuildings: () => calls.push("buildings"),
|
||||
});
|
||||
|
||||
assert.equal(result, "cancelled");
|
||||
assert.deepEqual(calls, ["imagery"]);
|
||||
});
|
||||
|
||||
test("a stalled provider times out without inserting late or blocking later stages", async () => {
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
let releaseImagery;
|
||||
const stalledImagery = new Promise((resolve) => { releaseImagery = resolve; });
|
||||
await runStagedMapProviders({
|
||||
providerInitializationTimeoutMs: 1,
|
||||
loadImagery: async ({ signal }) => {
|
||||
calls.push("imagery-start");
|
||||
await stalledImagery;
|
||||
if (!signal.aborted) calls.push("imagery-insert");
|
||||
},
|
||||
loadTerrain: () => calls.push("terrain"),
|
||||
loadBuildings: () => calls.push("buildings"),
|
||||
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||
});
|
||||
releaseImagery();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ["imagery-start", "terrain", "buildings"]);
|
||||
assert.deepEqual(errors, [["imagery", "imagery_startup_timeout"]]);
|
||||
});
|
||||
|
||||
test("viewport readiness waits for discovery frames and cleans listeners", async () => {
|
||||
const progress = eventHarness();
|
||||
const postRender = eventHarness();
|
||||
const globe = { tilesLoaded: true, tileLoadProgressEvent: progress.event };
|
||||
const scene = { postRender: postRender.event, requestRender() {} };
|
||||
let settled = false;
|
||||
const ready = waitForGlobeViewportReady({ globe, scene, timeoutMs: 100, minimumRenderFrames: 2 })
|
||||
.then((reason) => { settled = true; return reason; });
|
||||
|
||||
postRender.raise();
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false);
|
||||
postRender.raise();
|
||||
assert.equal(await ready, "loaded");
|
||||
assert.equal(progress.size, 0);
|
||||
assert.equal(postRender.size, 0);
|
||||
});
|
||||
|
||||
test("viewport readiness times out and aborts without leaking listeners", async () => {
|
||||
const timeoutProgress = eventHarness();
|
||||
const timeoutPostRender = eventHarness();
|
||||
assert.equal(await waitForGlobeViewportReady({
|
||||
globe: { tilesLoaded: false, tileLoadProgressEvent: timeoutProgress.event },
|
||||
scene: { postRender: timeoutPostRender.event, requestRender() {} },
|
||||
timeoutMs: 1,
|
||||
}), "timeout");
|
||||
assert.equal(timeoutProgress.size, 0);
|
||||
assert.equal(timeoutPostRender.size, 0);
|
||||
|
||||
const abortProgress = eventHarness();
|
||||
const abortPostRender = eventHarness();
|
||||
const controller = new AbortController();
|
||||
const pending = waitForGlobeViewportReady({
|
||||
globe: { tilesLoaded: false, tileLoadProgressEvent: abortProgress.event },
|
||||
scene: { postRender: abortPostRender.event, requestRender() {} },
|
||||
signal: controller.signal,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
controller.abort();
|
||||
assert.equal(await pending, "cancelled");
|
||||
assert.equal(abortProgress.size, 0);
|
||||
assert.equal(abortPostRender.size, 0);
|
||||
});
|
||||
|
||||
test("the live renderer uses the staged provider coordinator", async () => {
|
||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
assert.match(renderer, /runStagedMapProviders\(\{/);
|
||||
assert.match(renderer, /waitForGlobeViewportReady\(\{/);
|
||||
assert.match(renderer, /providerStartupAbort\.abort\(\)/);
|
||||
assert.doesNotMatch(renderer, /Do not serialize provider startup/);
|
||||
});
|
||||
Reference in New Issue
Block a user