feat(map): publish shared Cesium adapter
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# Shared Cesium React adapter
|
||||||
|
|
||||||
|
`@nodedc/map-cesium-react 0.1.0` is the shared renderer boundary for the
|
||||||
|
canonical `Map Page 0.1.0` and CesiumJS `1.143.0`.
|
||||||
|
|
||||||
|
It is not a Foundry application component and does not own product navigation,
|
||||||
|
Inspector fields, tokens, provider allowlists, TileCache storage or admin
|
||||||
|
settings. A product supplies a same-origin BFF runtime contract and persisted
|
||||||
|
provider-neutral view state.
|
||||||
|
|
||||||
|
## Security and runtime contract
|
||||||
|
|
||||||
|
- The browser receives only same-origin BFF paths.
|
||||||
|
- Runtime config must use `missioncore.map-runtime/v1`, Map Page `0.1.0` and
|
||||||
|
Cesium `1.143.0`.
|
||||||
|
- Canonical assets are fixed to imagery `2`, terrain `1` and buildings
|
||||||
|
`96188`.
|
||||||
|
- Provider endpoint documents must declare `credentialMode=gateway`.
|
||||||
|
- Every provider resource uses the BFF cache proxy through Cesium
|
||||||
|
`DefaultProxy`.
|
||||||
|
- Direct provider credentials, Gateway origins and arbitrary proxy paths are
|
||||||
|
rejected.
|
||||||
|
- Imagery, terrain and buildings start independently. One failed provider
|
||||||
|
yields a degraded scene when another provider remains ready.
|
||||||
|
- Render-loop recovery is bounded to one request; a repeated fault becomes
|
||||||
|
`cesium_render_error`.
|
||||||
|
|
||||||
|
## Asset delivery
|
||||||
|
|
||||||
|
The package keeps Cesium in a dynamic import so the application receives it as
|
||||||
|
a lazy chunk only when the map renderer mounts. Consumers import
|
||||||
|
`@nodedc/map-cesium-react/widgets.css` and copy the four runtime directories
|
||||||
|
from `cesium/Build/Cesium` into the deployed `CESIUM_BASE_URL`:
|
||||||
|
|
||||||
|
- `Assets`;
|
||||||
|
- `ThirdParty`;
|
||||||
|
- `Widgets`;
|
||||||
|
- `Workers`.
|
||||||
|
|
||||||
|
The build/runtime integration must not copy Cesium credentials or NODE.DC
|
||||||
|
TileCache data. Those remain outside application artifacts.
|
||||||
|
|
||||||
|
## Cache intent
|
||||||
|
|
||||||
|
`cacheIntent.enabled=false` adds `nodedc_cache_mode=passthrough` while
|
||||||
|
preserving the Gateway security boundary. Enabled mode selects the shared
|
||||||
|
`live` profile. `no_overwrite=false` or a separate explicit cache-refresh
|
||||||
|
request adds `nodedc_cache_refresh=1`. Changing `rendererGeneration` only
|
||||||
|
recreates the local Cesium viewer and never changes the TileCache write intent.
|
||||||
|
|
||||||
|
The package never creates browser, application, Docker or per-user TileCache
|
||||||
|
storage.
|
||||||
Generated
+18
@@ -921,6 +921,10 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodedc/map-cesium-react": {
|
||||||
|
"resolved": "packages/map-cesium-react",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@nodedc/page-patterns": {
|
"node_modules/@nodedc/page-patterns": {
|
||||||
"resolved": "packages/page-patterns",
|
"resolved": "packages/page-patterns",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -2456,6 +2460,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"packages/map-cesium-react": {
|
||||||
|
"name": "@nodedc/map-cesium-react",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"cesium": "1.143.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"react": "^19.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/page-patterns": {
|
"packages/page-patterns": {
|
||||||
"name": "@nodedc/page-patterns",
|
"name": "@nodedc/page-patterns",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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/ui-catalog",
|
"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",
|
"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-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-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",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@nodedc/map-cesium-react",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Shared credential-free Cesium adapter for NODE.DC Map Page consumers.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./widgets.css": "./dist/widgets.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json && cp ../../node_modules/cesium/Build/Cesium/Widgets/widgets.css dist/widgets.css",
|
||||||
|
"test": "npm run build && node --test test/*.test.mjs",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cesium": "1.143.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"react": "^19.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type * as CesiumModule from "cesium";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CesiumMapRendererProps,
|
||||||
|
MapCamera,
|
||||||
|
MapProviderId,
|
||||||
|
MapRuntimeState,
|
||||||
|
} from "./contracts.js";
|
||||||
|
import {
|
||||||
|
MapRuntimeError,
|
||||||
|
applyCacheIntent,
|
||||||
|
assetEndpointPath,
|
||||||
|
fetchMapRuntimeConfiguration,
|
||||||
|
fetchSameOriginJson,
|
||||||
|
initialMapRuntimeState,
|
||||||
|
withProviderState,
|
||||||
|
} from "./runtime.js";
|
||||||
|
|
||||||
|
type CesiumNamespace = typeof CesiumModule;
|
||||||
|
type ProviderEndpoint = {
|
||||||
|
assetId: number;
|
||||||
|
type: "IMAGERY" | "TERRAIN" | "3DTILES";
|
||||||
|
externalType?: "BING";
|
||||||
|
credentialMode: "gateway";
|
||||||
|
url?: string;
|
||||||
|
options?: {
|
||||||
|
url?: string;
|
||||||
|
mapStyle?: string;
|
||||||
|
};
|
||||||
|
attributions?: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CesiumMapRenderer({
|
||||||
|
runtimeConfigUrl,
|
||||||
|
camera,
|
||||||
|
layers,
|
||||||
|
settings,
|
||||||
|
cacheIntent,
|
||||||
|
rendererGeneration = 0,
|
||||||
|
className,
|
||||||
|
onRuntimeStateChange,
|
||||||
|
onCameraChange,
|
||||||
|
}: CesiumMapRendererProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stateCallbackRef = useRef(onRuntimeStateChange);
|
||||||
|
const cameraCallbackRef = useRef(onCameraChange);
|
||||||
|
|
||||||
|
stateCallbackRef.current = onRuntimeStateChange;
|
||||||
|
cameraCallbackRef.current = onCameraChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let disposed = false;
|
||||||
|
let viewer: CesiumModule.Viewer | null = null;
|
||||||
|
let runtimeState = initialMapRuntimeState();
|
||||||
|
|
||||||
|
const publish = (next: MapRuntimeState) => {
|
||||||
|
runtimeState = next;
|
||||||
|
if (!disposed) {
|
||||||
|
stateCallbackRef.current?.(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
publish(runtimeState);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const configuration = await fetchMapRuntimeConfiguration(
|
||||||
|
runtimeConfigUrl,
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
const Cesium = await import("cesium");
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer = createViewer(Cesium, container, settings);
|
||||||
|
applyCamera(Cesium, viewer, camera);
|
||||||
|
const removeCameraListener = bindCamera(Cesium, viewer, cameraCallbackRef);
|
||||||
|
const removeRenderListener = bindRenderRecovery(viewer, publish);
|
||||||
|
|
||||||
|
const providerJobs = (["imagery", "terrain", "buildings"] as const).map(
|
||||||
|
async (providerId) => {
|
||||||
|
const visible = layers[providerId];
|
||||||
|
publish(
|
||||||
|
withProviderState(runtimeState, providerId, {
|
||||||
|
phase: visible ? "loading" : "disabled",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const endpoint = await fetchProviderEndpoint(
|
||||||
|
configuration,
|
||||||
|
providerId,
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
if (disposed || !viewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await attachProvider(
|
||||||
|
Cesium,
|
||||||
|
viewer,
|
||||||
|
configuration.gateway.cache_proxy_prefix,
|
||||||
|
endpoint,
|
||||||
|
providerId,
|
||||||
|
cacheIntent,
|
||||||
|
false,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
publish(
|
||||||
|
withProviderState(runtimeState, providerId, {
|
||||||
|
phase: "ready",
|
||||||
|
attributions: endpoint.attributions,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publish(
|
||||||
|
withProviderState(runtimeState, providerId, {
|
||||||
|
phase: "error",
|
||||||
|
code: safeErrorCode(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await Promise.allSettled(providerJobs);
|
||||||
|
if (!disposed && viewer && layers.grid) {
|
||||||
|
viewer.imageryLayers.addImageryProvider(
|
||||||
|
new Cesium.GridImageryProvider({
|
||||||
|
cells: 16,
|
||||||
|
color: tokenColor(
|
||||||
|
Cesium,
|
||||||
|
container,
|
||||||
|
"--nodedc-text-secondary",
|
||||||
|
"rgba(247, 248, 244, 0.72)",
|
||||||
|
),
|
||||||
|
glowColor: tokenColor(
|
||||||
|
Cesium,
|
||||||
|
container,
|
||||||
|
"--nodedc-text-muted",
|
||||||
|
"rgba(247, 248, 244, 0.48)",
|
||||||
|
),
|
||||||
|
backgroundColor: Cesium.Color.TRANSPARENT,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (disposed) {
|
||||||
|
removeCameraListener();
|
||||||
|
removeRenderListener();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
const code = safeErrorCode(error);
|
||||||
|
publish({
|
||||||
|
phase: "gateway-unavailable",
|
||||||
|
providers: {
|
||||||
|
imagery: { phase: "error", code },
|
||||||
|
terrain: { phase: "error", code },
|
||||||
|
buildings: { phase: "error", code },
|
||||||
|
},
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
abortController.abort();
|
||||||
|
viewer?.destroy();
|
||||||
|
viewer = null;
|
||||||
|
container.replaceChildren();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
runtimeConfigUrl,
|
||||||
|
rendererGeneration,
|
||||||
|
layers.imagery,
|
||||||
|
layers.terrain,
|
||||||
|
layers.buildings,
|
||||||
|
layers.grid,
|
||||||
|
cacheIntent.enabled,
|
||||||
|
cacheIntent.no_overwrite,
|
||||||
|
settings.atmosphere_enabled,
|
||||||
|
settings.lighting_enabled,
|
||||||
|
settings.monochrome_enabled,
|
||||||
|
settings.terrain_exaggeration,
|
||||||
|
settings.buildings_maximum_screen_space_error,
|
||||||
|
settings.camera_animation_enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className={className} data-nodedc-map-renderer="cesium" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createViewer(
|
||||||
|
Cesium: CesiumNamespace,
|
||||||
|
container: HTMLElement,
|
||||||
|
settings: CesiumMapRendererProps["settings"],
|
||||||
|
): CesiumModule.Viewer {
|
||||||
|
const viewer = new Cesium.Viewer(container, {
|
||||||
|
animation: false,
|
||||||
|
baseLayer: false,
|
||||||
|
baseLayerPicker: false,
|
||||||
|
fullscreenButton: false,
|
||||||
|
geocoder: false,
|
||||||
|
homeButton: false,
|
||||||
|
infoBox: false,
|
||||||
|
navigationHelpButton: false,
|
||||||
|
sceneModePicker: false,
|
||||||
|
selectionIndicator: false,
|
||||||
|
timeline: false,
|
||||||
|
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
|
||||||
|
requestRenderMode: true,
|
||||||
|
maximumRenderTimeChange: Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
viewer.scene.globe.enableLighting = settings.lighting_enabled;
|
||||||
|
if (viewer.scene.skyAtmosphere) {
|
||||||
|
viewer.scene.skyAtmosphere.show = settings.atmosphere_enabled;
|
||||||
|
}
|
||||||
|
viewer.scene.verticalExaggeration = settings.terrain_exaggeration;
|
||||||
|
viewer.scene.rethrowRenderErrors = false;
|
||||||
|
return viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProviderEndpoint(
|
||||||
|
configuration: Awaited<ReturnType<typeof fetchMapRuntimeConfiguration>>,
|
||||||
|
providerId: MapProviderId,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<ProviderEndpoint> {
|
||||||
|
const document = await fetchSameOriginJson(
|
||||||
|
assetEndpointPath(configuration, providerId),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const expectedAssetId = configuration.assets[providerId];
|
||||||
|
const expectedType =
|
||||||
|
providerId === "imagery"
|
||||||
|
? "IMAGERY"
|
||||||
|
: providerId === "terrain"
|
||||||
|
? "TERRAIN"
|
||||||
|
: "3DTILES";
|
||||||
|
if (
|
||||||
|
document.assetId !== expectedAssetId ||
|
||||||
|
document.type !== expectedType ||
|
||||||
|
document.credentialMode !== "gateway"
|
||||||
|
) {
|
||||||
|
throw new MapRuntimeError("map_provider_contract_mismatch");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(providerId === "imagery" &&
|
||||||
|
(document.externalType !== "BING" ||
|
||||||
|
!isEndpointOptions(document.options) ||
|
||||||
|
typeof document.options.url !== "string")) ||
|
||||||
|
(providerId !== "imagery" && typeof document.url !== "string")
|
||||||
|
) {
|
||||||
|
throw new MapRuntimeError("map_provider_contract_mismatch");
|
||||||
|
}
|
||||||
|
return document as ProviderEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachProvider(
|
||||||
|
Cesium: CesiumNamespace,
|
||||||
|
viewer: CesiumModule.Viewer,
|
||||||
|
cacheProxyPrefix: string,
|
||||||
|
endpoint: ProviderEndpoint,
|
||||||
|
providerId: MapProviderId,
|
||||||
|
cacheIntent: CesiumMapRendererProps["cacheIntent"],
|
||||||
|
refresh: boolean,
|
||||||
|
settings: CesiumMapRendererProps["settings"],
|
||||||
|
): Promise<void> {
|
||||||
|
const publicUrl =
|
||||||
|
providerId === "imagery" ? endpoint.options?.url : endpoint.url;
|
||||||
|
if (!publicUrl) {
|
||||||
|
throw new MapRuntimeError("map_provider_contract_mismatch");
|
||||||
|
}
|
||||||
|
const resource = new Cesium.Resource({
|
||||||
|
url: applyCacheIntent(publicUrl, cacheIntent, refresh),
|
||||||
|
proxy: new Cesium.DefaultProxy(cacheProxyPrefix),
|
||||||
|
});
|
||||||
|
if (providerId === "imagery") {
|
||||||
|
const provider = await Cesium.BingMapsImageryProvider.fromUrl(resource, {
|
||||||
|
key: "gateway-proxy",
|
||||||
|
mapStyle: bingMapStyle(Cesium, endpoint.options?.mapStyle),
|
||||||
|
});
|
||||||
|
const layer = viewer.imageryLayers.addImageryProvider(provider);
|
||||||
|
if (settings.monochrome_enabled) {
|
||||||
|
layer.saturation = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (providerId === "terrain") {
|
||||||
|
viewer.terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(resource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tileset = await Cesium.Cesium3DTileset.fromUrl(resource, {
|
||||||
|
maximumScreenSpaceError: settings.buildings_maximum_screen_space_error,
|
||||||
|
});
|
||||||
|
viewer.scene.primitives.add(tileset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCamera(
|
||||||
|
Cesium: CesiumNamespace,
|
||||||
|
viewer: CesiumModule.Viewer,
|
||||||
|
callbackRef: { current: ((camera: MapCamera) => void) | undefined },
|
||||||
|
): () => void {
|
||||||
|
const listener = () => {
|
||||||
|
const position = viewer.camera.positionCartographic;
|
||||||
|
callbackRef.current?.({
|
||||||
|
longitude: Cesium.Math.toDegrees(position.longitude),
|
||||||
|
latitude: Cesium.Math.toDegrees(position.latitude),
|
||||||
|
height: position.height,
|
||||||
|
heading: Cesium.Math.toDegrees(viewer.camera.heading),
|
||||||
|
pitch: Cesium.Math.toDegrees(viewer.camera.pitch),
|
||||||
|
roll: Cesium.Math.toDegrees(viewer.camera.roll),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return viewer.camera.moveEnd.addEventListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRenderRecovery(
|
||||||
|
viewer: CesiumModule.Viewer,
|
||||||
|
publish: (state: MapRuntimeState) => void,
|
||||||
|
): () => void {
|
||||||
|
let recoveryAttempted = false;
|
||||||
|
return viewer.scene.renderError.addEventListener(() => {
|
||||||
|
if (!recoveryAttempted) {
|
||||||
|
recoveryAttempted = true;
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publish({
|
||||||
|
phase: "render-error",
|
||||||
|
providers: {
|
||||||
|
imagery: { phase: "error", code: "cesium_render_error" },
|
||||||
|
terrain: { phase: "error", code: "cesium_render_error" },
|
||||||
|
buildings: { phase: "error", code: "cesium_render_error" },
|
||||||
|
},
|
||||||
|
code: "cesium_render_error",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCamera(
|
||||||
|
Cesium: CesiumNamespace,
|
||||||
|
viewer: CesiumModule.Viewer,
|
||||||
|
camera: MapCamera | null,
|
||||||
|
): void {
|
||||||
|
if (!camera) {
|
||||||
|
viewer.camera.flyHome(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: Cesium.Cartesian3.fromDegrees(
|
||||||
|
camera.longitude,
|
||||||
|
camera.latitude,
|
||||||
|
camera.height,
|
||||||
|
),
|
||||||
|
orientation: {
|
||||||
|
heading: Cesium.Math.toRadians(camera.heading),
|
||||||
|
pitch: Cesium.Math.toRadians(camera.pitch),
|
||||||
|
roll: Cesium.Math.toRadians(camera.roll),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bingMapStyle(
|
||||||
|
Cesium: CesiumNamespace,
|
||||||
|
value: string | undefined,
|
||||||
|
): CesiumModule.BingMapsStyle {
|
||||||
|
if (value === "Road") {
|
||||||
|
return Cesium.BingMapsStyle.ROAD;
|
||||||
|
}
|
||||||
|
if (value === "AerialWithLabels") {
|
||||||
|
return Cesium.BingMapsStyle.AERIAL_WITH_LABELS;
|
||||||
|
}
|
||||||
|
return Cesium.BingMapsStyle.AERIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeErrorCode(error: unknown): string {
|
||||||
|
return error instanceof MapRuntimeError ? error.code : "map_provider_unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEndpointOptions(
|
||||||
|
value: unknown,
|
||||||
|
): value is NonNullable<ProviderEndpoint["options"]> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenColor(
|
||||||
|
Cesium: CesiumNamespace,
|
||||||
|
container: HTMLElement,
|
||||||
|
token: string,
|
||||||
|
fallback: string,
|
||||||
|
): CesiumModule.Color {
|
||||||
|
const value = getComputedStyle(container).getPropertyValue(token).trim();
|
||||||
|
return Cesium.Color.fromCssColorString(value || fallback) ?? Cesium.Color.WHITE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export const MAP_RUNTIME_SCHEMA_VERSION = "missioncore.map-runtime/v1" as const;
|
||||||
|
export const MAP_PAGE_VERSION = "0.1.0" as const;
|
||||||
|
export const CESIUM_RENDERER_VERSION = "1.143.0" as const;
|
||||||
|
|
||||||
|
export type MapProviderId = "imagery" | "terrain" | "buildings";
|
||||||
|
export type MapProviderPhase = "idle" | "loading" | "ready" | "disabled" | "error";
|
||||||
|
export type MapRenderPhase =
|
||||||
|
| "loading"
|
||||||
|
| "ready"
|
||||||
|
| "degraded"
|
||||||
|
| "gateway-unavailable"
|
||||||
|
| "render-error";
|
||||||
|
|
||||||
|
export type MapRuntimeConfiguration = {
|
||||||
|
schema_version: typeof MAP_RUNTIME_SCHEMA_VERSION;
|
||||||
|
map_page_version: typeof MAP_PAGE_VERSION;
|
||||||
|
renderer: {
|
||||||
|
id: "cesium";
|
||||||
|
version: typeof CESIUM_RENDERER_VERSION;
|
||||||
|
};
|
||||||
|
gateway: {
|
||||||
|
configured: true;
|
||||||
|
health_url: string;
|
||||||
|
asset_endpoint_template: string;
|
||||||
|
cache_proxy_prefix: string;
|
||||||
|
};
|
||||||
|
assets: Record<MapProviderId, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapGatewayError = {
|
||||||
|
schema_version?: string;
|
||||||
|
code?: string;
|
||||||
|
retryable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapProviderState = {
|
||||||
|
phase: MapProviderPhase;
|
||||||
|
code?: string;
|
||||||
|
attributions?: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapRuntimeState = {
|
||||||
|
phase: MapRenderPhase;
|
||||||
|
providers: Record<MapProviderId, MapProviderState>;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapCamera = {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
height: number;
|
||||||
|
heading: number;
|
||||||
|
pitch: number;
|
||||||
|
roll: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapLayerVisibility = {
|
||||||
|
imagery: boolean;
|
||||||
|
terrain: boolean;
|
||||||
|
buildings: boolean;
|
||||||
|
grid: boolean;
|
||||||
|
targets: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapVisualSettings = {
|
||||||
|
atmosphere_enabled: boolean;
|
||||||
|
lighting_enabled: boolean;
|
||||||
|
monochrome_enabled: boolean;
|
||||||
|
terrain_exaggeration: number;
|
||||||
|
buildings_maximum_screen_space_error: number;
|
||||||
|
camera_animation_enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapCacheIntent = {
|
||||||
|
enabled: boolean;
|
||||||
|
no_overwrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CesiumMapRendererProps = {
|
||||||
|
runtimeConfigUrl: string;
|
||||||
|
camera: MapCamera | null;
|
||||||
|
layers: MapLayerVisibility;
|
||||||
|
settings: MapVisualSettings;
|
||||||
|
cacheIntent: MapCacheIntent;
|
||||||
|
rendererGeneration?: number;
|
||||||
|
className?: string;
|
||||||
|
onRuntimeStateChange?: (state: MapRuntimeState) => void;
|
||||||
|
onCameraChange?: (camera: MapCamera) => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export { CesiumMapRenderer } from "./CesiumMapRenderer.js";
|
||||||
|
export {
|
||||||
|
CESIUM_RENDERER_VERSION,
|
||||||
|
MAP_PAGE_VERSION,
|
||||||
|
MAP_RUNTIME_SCHEMA_VERSION,
|
||||||
|
} from "./contracts.js";
|
||||||
|
export type {
|
||||||
|
CesiumMapRendererProps,
|
||||||
|
MapCacheIntent,
|
||||||
|
MapCamera,
|
||||||
|
MapLayerVisibility,
|
||||||
|
MapProviderId,
|
||||||
|
MapProviderPhase,
|
||||||
|
MapProviderState,
|
||||||
|
MapRenderPhase,
|
||||||
|
MapRuntimeConfiguration,
|
||||||
|
MapRuntimeState,
|
||||||
|
MapVisualSettings,
|
||||||
|
} from "./contracts.js";
|
||||||
|
export {
|
||||||
|
MapRuntimeError,
|
||||||
|
applyCacheIntent,
|
||||||
|
assetEndpointPath,
|
||||||
|
fetchMapRuntimeConfiguration,
|
||||||
|
initialMapRuntimeState,
|
||||||
|
readSafeErrorCode,
|
||||||
|
withProviderState,
|
||||||
|
} from "./runtime.js";
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import type {
|
||||||
|
MapCacheIntent,
|
||||||
|
MapGatewayError,
|
||||||
|
MapProviderId,
|
||||||
|
MapProviderState,
|
||||||
|
MapRuntimeConfiguration,
|
||||||
|
MapRuntimeState,
|
||||||
|
} from "./contracts.js";
|
||||||
|
import {
|
||||||
|
CESIUM_RENDERER_VERSION,
|
||||||
|
MAP_PAGE_VERSION,
|
||||||
|
MAP_RUNTIME_SCHEMA_VERSION,
|
||||||
|
} from "./contracts.js";
|
||||||
|
|
||||||
|
const SAFE_ERROR_CODE = /^[a-z][a-z0-9_]{0,95}$/;
|
||||||
|
const PROVIDER_IDS: readonly MapProviderId[] = ["imagery", "terrain", "buildings"];
|
||||||
|
const CREDENTIAL_QUERY_KEYS = new Set([
|
||||||
|
"access_token",
|
||||||
|
"access-token",
|
||||||
|
"authorization",
|
||||||
|
"credential",
|
||||||
|
"key",
|
||||||
|
"signature",
|
||||||
|
"sig",
|
||||||
|
"token",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class MapRuntimeError extends Error {
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(code: string) {
|
||||||
|
super(code);
|
||||||
|
this.name = "MapRuntimeError";
|
||||||
|
this.code = SAFE_ERROR_CODE.test(code) ? code : "map_runtime_error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialMapRuntimeState(): MapRuntimeState {
|
||||||
|
return {
|
||||||
|
phase: "loading",
|
||||||
|
providers: {
|
||||||
|
imagery: { phase: "idle" },
|
||||||
|
terrain: { phase: "idle" },
|
||||||
|
buildings: { phase: "idle" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withProviderState(
|
||||||
|
current: MapRuntimeState,
|
||||||
|
providerId: MapProviderId,
|
||||||
|
providerState: MapProviderState,
|
||||||
|
): MapRuntimeState {
|
||||||
|
const providers = {
|
||||||
|
...current.providers,
|
||||||
|
[providerId]: providerState,
|
||||||
|
};
|
||||||
|
const enabled = PROVIDER_IDS.map((id) => providers[id]).filter(
|
||||||
|
(state) => state.phase !== "disabled",
|
||||||
|
);
|
||||||
|
const readyCount = enabled.filter((state) => state.phase === "ready").length;
|
||||||
|
const errorCount = enabled.filter((state) => state.phase === "error").length;
|
||||||
|
const loadingCount = enabled.filter((state) =>
|
||||||
|
["idle", "loading"].includes(state.phase),
|
||||||
|
).length;
|
||||||
|
const phase =
|
||||||
|
loadingCount > 0
|
||||||
|
? "loading"
|
||||||
|
: errorCount === 0
|
||||||
|
? "ready"
|
||||||
|
: readyCount > 0
|
||||||
|
? "degraded"
|
||||||
|
: "gateway-unavailable";
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
providers,
|
||||||
|
...(errorCount > 0
|
||||||
|
? {
|
||||||
|
code:
|
||||||
|
enabled.find((state) => state.phase === "error")?.code ??
|
||||||
|
"map_provider_unavailable",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMapRuntimeConfiguration(
|
||||||
|
path: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<MapRuntimeConfiguration> {
|
||||||
|
const document = await fetchSameOriginJson(path, signal);
|
||||||
|
if (
|
||||||
|
document.schema_version !== MAP_RUNTIME_SCHEMA_VERSION ||
|
||||||
|
document.map_page_version !== MAP_PAGE_VERSION ||
|
||||||
|
!isRecord(document.renderer) ||
|
||||||
|
document.renderer.id !== "cesium" ||
|
||||||
|
document.renderer.version !== CESIUM_RENDERER_VERSION ||
|
||||||
|
!isRecord(document.gateway) ||
|
||||||
|
document.gateway.configured !== true ||
|
||||||
|
!isSafeSameOriginPath(document.gateway.health_url) ||
|
||||||
|
!isSafeSameOriginPath(document.gateway.asset_endpoint_template) ||
|
||||||
|
!isSafeSameOriginPath(document.gateway.cache_proxy_prefix) ||
|
||||||
|
!isRecord(document.assets) ||
|
||||||
|
document.assets.imagery !== 2 ||
|
||||||
|
document.assets.terrain !== 1 ||
|
||||||
|
document.assets.buildings !== 96188
|
||||||
|
) {
|
||||||
|
throw new MapRuntimeError("map_runtime_contract_mismatch");
|
||||||
|
}
|
||||||
|
return document as MapRuntimeConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSameOriginJson(
|
||||||
|
path: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (!isSafeSameOriginPath(path)) {
|
||||||
|
throw new MapRuntimeError("map_runtime_cross_origin_forbidden");
|
||||||
|
}
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
signal,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
const document = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new MapRuntimeError(readSafeErrorCode(document));
|
||||||
|
}
|
||||||
|
if (!isRecord(document)) {
|
||||||
|
throw new MapRuntimeError("map_runtime_invalid_response");
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetEndpointPath(
|
||||||
|
configuration: MapRuntimeConfiguration,
|
||||||
|
providerId: MapProviderId,
|
||||||
|
): string {
|
||||||
|
const assetId = configuration.assets[providerId];
|
||||||
|
return configuration.gateway.asset_endpoint_template.replace(
|
||||||
|
"{asset_id}",
|
||||||
|
String(assetId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCacheIntent(
|
||||||
|
rawUrl: string,
|
||||||
|
intent: MapCacheIntent,
|
||||||
|
refresh: boolean,
|
||||||
|
): string {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
||||||
|
throw new MapRuntimeError("map_provider_url_rejected");
|
||||||
|
}
|
||||||
|
for (const key of url.searchParams.keys()) {
|
||||||
|
if (CREDENTIAL_QUERY_KEYS.has(key.toLowerCase())) {
|
||||||
|
throw new MapRuntimeError("map_provider_credential_rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!intent.enabled) {
|
||||||
|
url.searchParams.set("nodedc_cache_mode", "passthrough");
|
||||||
|
} else {
|
||||||
|
url.searchParams.set("nodedc_cache_profile", "live");
|
||||||
|
if (!intent.no_overwrite || refresh) {
|
||||||
|
url.searchParams.set("nodedc_cache_refresh", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeSameOriginPath(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolved = new URL(value, window.location.origin);
|
||||||
|
return resolved.origin === window.location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSafeErrorCode(value: unknown): string {
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const direct = value.code;
|
||||||
|
if (typeof direct === "string" && SAFE_ERROR_CODE.test(direct)) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
const error = value.error;
|
||||||
|
if (isRecord(error) && typeof error.code === "string" && SAFE_ERROR_CODE.test(error.code)) {
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
if (typeof error === "string" && SAFE_ERROR_CODE.test(error)) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "map_gateway_request_failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asMapGatewayError(value: unknown): MapGatewayError | null {
|
||||||
|
return isRecord(value) ? (value as MapGatewayError) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MapRuntimeError,
|
||||||
|
applyCacheIntent,
|
||||||
|
initialMapRuntimeState,
|
||||||
|
withProviderState,
|
||||||
|
} from "../dist/index.js";
|
||||||
|
|
||||||
|
test("cache intent keeps all provider traffic behind the gateway contract", () => {
|
||||||
|
const cached = new URL(
|
||||||
|
applyCacheIntent("https://assets.ion.cesium.com/tileset.json?v=1", {
|
||||||
|
enabled: true,
|
||||||
|
no_overwrite: true,
|
||||||
|
}, false),
|
||||||
|
);
|
||||||
|
assert.equal(cached.searchParams.get("nodedc_cache_profile"), "live");
|
||||||
|
assert.equal(cached.searchParams.has("nodedc_cache_refresh"), false);
|
||||||
|
|
||||||
|
const passthrough = new URL(
|
||||||
|
applyCacheIntent("https://assets.ion.cesium.com/tileset.json", {
|
||||||
|
enabled: false,
|
||||||
|
no_overwrite: true,
|
||||||
|
}, false),
|
||||||
|
);
|
||||||
|
assert.equal(passthrough.searchParams.get("nodedc_cache_mode"), "passthrough");
|
||||||
|
|
||||||
|
const refresh = new URL(
|
||||||
|
applyCacheIntent("https://assets.ion.cesium.com/tileset.json", {
|
||||||
|
enabled: true,
|
||||||
|
no_overwrite: true,
|
||||||
|
}, true),
|
||||||
|
);
|
||||||
|
assert.equal(refresh.searchParams.get("nodedc_cache_refresh"), "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provider URLs reject direct credentials and non-HTTPS transport", () => {
|
||||||
|
for (const value of [
|
||||||
|
"http://assets.ion.cesium.com/tile.bin",
|
||||||
|
"https://user:pass@assets.ion.cesium.com/tile.bin",
|
||||||
|
"https://assets.ion.cesium.com/tile.bin?access_token=forbidden",
|
||||||
|
"https://assets.ion.cesium.com/tile.bin#token",
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => applyCacheIntent(value, { enabled: true, no_overwrite: true }, false),
|
||||||
|
MapRuntimeError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("independent provider states produce ready, degraded and unavailable phases", () => {
|
||||||
|
let state = initialMapRuntimeState();
|
||||||
|
state = withProviderState(state, "imagery", { phase: "ready" });
|
||||||
|
state = withProviderState(state, "terrain", { phase: "ready" });
|
||||||
|
state = withProviderState(state, "buildings", { phase: "ready" });
|
||||||
|
assert.equal(state.phase, "ready");
|
||||||
|
|
||||||
|
state = withProviderState(state, "buildings", {
|
||||||
|
phase: "error",
|
||||||
|
code: "cesium_ion_endpoint_unavailable",
|
||||||
|
});
|
||||||
|
assert.equal(state.phase, "degraded");
|
||||||
|
assert.equal(state.code, "cesium_ion_endpoint_unavailable");
|
||||||
|
|
||||||
|
state = withProviderState(state, "imagery", {
|
||||||
|
phase: "error",
|
||||||
|
code: "map_upstream_timeout",
|
||||||
|
});
|
||||||
|
state = withProviderState(state, "terrain", {
|
||||||
|
phase: "error",
|
||||||
|
code: "map_upstream_timeout",
|
||||||
|
});
|
||||||
|
assert.equal(state.phase, "gateway-unavailable");
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@
|
|||||||
"name": "@nodedc/page-patterns",
|
"name": "@nodedc/page-patterns",
|
||||||
"path": "packages/page-patterns",
|
"path": "packages/page-patterns",
|
||||||
"role": "runtime-neutral page template contracts and canonical template manifests"
|
"role": "runtime-neutral page template contracts and canonical template manifests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@nodedc/map-cesium-react",
|
||||||
|
"path": "packages/map-cesium-react",
|
||||||
|
"role": "credential-free Cesium renderer adapter for the canonical Map Page and same-origin Platform Map Gateway BFFs"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sourceRegistry": "sources.json",
|
"sourceRegistry": "sources.json",
|
||||||
|
|||||||
Reference in New Issue
Block a user