feat(map): share canonical Foundry presentation

This commit is contained in:
Codex
2026-07-29 17:55:25 +03:00
parent bc1c0dd6e6
commit 65644a8609
5 changed files with 383 additions and 61 deletions
+18
View File
@@ -8,6 +8,19 @@ 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.
## Canonical presentation contract
The adapter accepts the complete provider-neutral Map Page presentation state
used by the Foundry `DC Default` profile: imagery color correction, globe and
background colors, atmosphere, fog, sun, shadows, terrain exaggeration,
building style/detail and the elevated grid with camera-height LOD. Consumers
must persist and pass those values explicitly; the adapter does not invent a
second product-specific visual profile.
The canonical black-and-white scene keeps imagery enabled and sets imagery
saturation to zero. `monochrome_enabled` is a separate solid-globe mode and
must not be used as a substitute for the `DC Default` treatment.
## Security and runtime contract
- The browser receives only same-origin BFF paths.
@@ -24,6 +37,11 @@ provider-neutral view state.
yields a degraded scene when another provider remains ready.
- Render-loop recovery is bounded to one request; a repeated fault becomes
`cesium_render_error`.
- Provider attribution is always registered with Cesium. An internal sandbox
may route the visual credit overlay into a hidden dedicated container only
through the explicit optional runtime flag
`sandbox.hide_credit_overlay=true`. The flag is absent or false by default
and must remain false for every external or production release.
## Asset delivery
@@ -22,6 +22,17 @@ import {
} from "./runtime.js";
type CesiumNamespace = typeof CesiumModule;
type RendererRuntime = {
Cesium: CesiumNamespace;
viewer: CesiumModule.Viewer;
ellipsoidTerrain: CesiumModule.EllipsoidTerrainProvider;
worldTerrain: CesiumModule.CesiumTerrainProvider | null;
imageryLayer: CesiumModule.ImageryLayer | null;
buildings: CesiumModule.Cesium3DTileset | null;
grid: CesiumModule.CustomDataSource;
rebuildGrid: () => void;
};
export function CesiumMapRenderer({
runtimeConfigUrl,
camera,
@@ -36,9 +47,23 @@ export function CesiumMapRenderer({
const containerRef = useRef<HTMLDivElement | null>(null);
const stateCallbackRef = useRef(onRuntimeStateChange);
const cameraCallbackRef = useRef(onCameraChange);
const runtimeRef = useRef<RendererRuntime | null>(null);
const settingsRef = useRef(settings);
const layersRef = useRef(layers);
stateCallbackRef.current = onRuntimeStateChange;
cameraCallbackRef.current = onCameraChange;
settingsRef.current = settings;
layersRef.current = layers;
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
applyPresentation(runtime, settings, layers);
runtime.rebuildGrid();
}, [settings, layers]);
useEffect(() => {
const container = containerRef.current;
@@ -48,6 +73,8 @@ export function CesiumMapRenderer({
const abortController = new AbortController();
let disposed = false;
let viewer: CesiumModule.Viewer | null = null;
let removeCameraListener: (() => void) | undefined;
let removeRenderListener: (() => void) | undefined;
let runtimeState = initialMapRuntimeState();
const publish = (next: MapRuntimeState) => {
@@ -68,10 +95,39 @@ export function CesiumMapRenderer({
if (disposed) {
return;
}
viewer = createViewer(Cesium, container, settings);
const creditContainer = configuration.sandbox?.hide_credit_overlay
? createSandboxCreditContainer(container)
: undefined;
viewer = createViewer(Cesium, container, creditContainer);
const ellipsoidTerrain = new Cesium.EllipsoidTerrainProvider();
const grid = new Cesium.CustomDataSource("nodedc-map-grid");
void viewer.dataSources.add(grid);
const runtime: RendererRuntime = {
Cesium,
viewer,
ellipsoidTerrain,
worldTerrain: null,
imageryLayer: null,
buildings: null,
grid,
rebuildGrid: () => undefined,
};
runtime.rebuildGrid = () => {
rebuildElevatedGrid(runtime, settingsRef.current, layersRef.current.grid);
};
runtimeRef.current = runtime;
viewer.terrainProvider = ellipsoidTerrain;
viewer.scene.globe.depthTestAgainstTerrain = true;
applyPresentation(runtime, settingsRef.current, layersRef.current);
applyCamera(Cesium, viewer, camera);
const removeCameraListener = bindCamera(Cesium, viewer, cameraCallbackRef);
const removeRenderListener = bindRenderRecovery(viewer, publish);
runtime.rebuildGrid();
removeCameraListener = bindCamera(
Cesium,
viewer,
cameraCallbackRef,
runtime.rebuildGrid,
);
removeRenderListener = bindRenderRecovery(viewer, publish);
const providerJobs = (["imagery", "terrain", "buildings"] as const).map(
async (providerId) => {
@@ -94,15 +150,15 @@ export function CesiumMapRenderer({
return;
}
await attachProvider(
Cesium,
viewer,
runtime,
configuration.gateway.cache_proxy_prefix,
endpoint,
providerId,
cacheIntent,
false,
settings,
);
addAttributions(Cesium, viewer, endpoint);
applyPresentation(runtime, settingsRef.current, layersRef.current);
publish(
withProviderState(runtimeState, providerId, {
phase: "ready",
@@ -123,29 +179,9 @@ export function CesiumMapRenderer({
},
);
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();
removeCameraListener?.();
removeRenderListener?.();
}
} catch (error) {
if (!abortController.signal.aborted) {
@@ -166,6 +202,9 @@ export function CesiumMapRenderer({
return () => {
disposed = true;
abortController.abort();
removeCameraListener?.();
removeRenderListener?.();
runtimeRef.current = null;
viewer?.destroy();
viewer = null;
container.replaceChildren();
@@ -176,15 +215,8 @@ export function CesiumMapRenderer({
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" />;
@@ -193,7 +225,7 @@ export function CesiumMapRenderer({
function createViewer(
Cesium: CesiumNamespace,
container: HTMLElement,
settings: CesiumMapRendererProps["settings"],
creditContainer?: HTMLElement,
): CesiumModule.Viewer {
const viewer = new Cesium.Viewer(container, {
animation: false,
@@ -210,16 +242,22 @@ function createViewer(
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
requestRenderMode: true,
maximumRenderTimeChange: Number.POSITIVE_INFINITY,
showRenderLoopErrors: false,
creditContainer,
});
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;
}
function createSandboxCreditContainer(container: HTMLElement): HTMLDivElement {
const credits = document.createElement("div");
credits.hidden = true;
credits.setAttribute("aria-hidden", "true");
credits.dataset.nodedcSandboxCredits = "suppressed";
container.appendChild(credits);
return credits;
}
async function fetchProviderEndpoint(
configuration: Awaited<ReturnType<typeof fetchMapRuntimeConfiguration>>,
providerId: MapProviderId,
@@ -234,15 +272,14 @@ async function fetchProviderEndpoint(
}
async function attachProvider(
Cesium: CesiumNamespace,
viewer: CesiumModule.Viewer,
runtime: RendererRuntime,
cacheProxyPrefix: string,
endpoint: ProviderEndpoint,
providerId: MapProviderId,
cacheIntent: CesiumMapRendererProps["cacheIntent"],
refresh: boolean,
settings: CesiumMapRendererProps["settings"],
): Promise<void> {
const { Cesium, viewer } = runtime;
const publicUrl =
providerId === "imagery" ? endpoint.options?.url : endpoint.url;
if (!publicUrl) {
@@ -261,28 +298,39 @@ async function attachProvider(
mapStyle: bingMapStyle(Cesium, endpoint.options?.mapStyle),
tileProtocol: "https",
});
const layer = viewer.imageryLayers.addImageryProvider(provider);
if (settings.monochrome_enabled) {
layer.saturation = 0;
}
runtime.imageryLayer = viewer.imageryLayers.addImageryProvider(provider);
return;
}
if (providerId === "terrain") {
viewer.terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(resource);
runtime.worldTerrain = 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);
runtime.buildings = await Cesium.Cesium3DTileset.fromUrl(resource);
viewer.scene.primitives.add(runtime.buildings);
}
function addAttributions(
Cesium: CesiumNamespace,
viewer: CesiumModule.Viewer,
endpoint: ProviderEndpoint,
): void {
for (const attribution of endpoint.attributions) {
if (attribution.html) {
viewer.creditDisplay.addStaticCredit(
new Cesium.Credit(attribution.html, attribution.collapsible),
);
}
}
}
function bindCamera(
Cesium: CesiumNamespace,
viewer: CesiumModule.Viewer,
callbackRef: { current: ((camera: MapCamera) => void) | undefined },
onMoveEnd: () => void,
): () => void {
const listener = () => {
onMoveEnd();
const position = viewer.camera.positionCartographic;
callbackRef.current?.({
longitude: Cesium.Math.toDegrees(position.longitude),
@@ -355,16 +403,229 @@ function bingMapStyle(
return Cesium.BingMapsStyle.AERIAL;
}
function applyPresentation(
runtime: RendererRuntime,
settings: CesiumMapRendererProps["settings"],
layers: CesiumMapRendererProps["layers"],
): void {
const { Cesium, viewer, imageryLayer, buildings } = runtime;
if (imageryLayer) {
imageryLayer.show = layers.imagery && !settings.monochrome_enabled;
imageryLayer.brightness = settings.imagery_brightness / 100;
imageryLayer.contrast = settings.imagery_contrast / 100;
imageryLayer.saturation = settings.imagery_saturation / 100;
imageryLayer.gamma = settings.imagery_gamma / 100;
imageryLayer.hue = Cesium.Math.toRadians(settings.imagery_hue);
imageryLayer.alpha = settings.imagery_alpha / 100;
}
if (buildings) {
buildings.show = layers.buildings;
buildings.maximumScreenSpaceError =
settings.buildings_maximum_screen_space_error;
buildings.style = new Cesium.Cesium3DTileStyle({
color: `color('${settings.buildings_color}', ${settings.buildings_opacity})`,
});
}
viewer.terrainProvider =
layers.terrain && runtime.worldTerrain
? runtime.worldTerrain
: runtime.ellipsoidTerrain;
viewer.scene.globe.show = true;
viewer.scene.globe.baseColor = cssColor(
Cesium,
settings.monochrome_enabled
? settings.monochrome_color
: settings.globe_color,
"#15151b",
);
viewer.scene.globe.enableLighting = settings.sun_enabled;
viewer.scene.verticalExaggeration = clamp(
settings.terrain_exaggeration,
0.25,
3,
);
viewer.scene.backgroundColor = cssColor(
Cesium,
settings.background_color,
"#08090d",
);
viewer.scene.fog.enabled = settings.fog_enabled;
viewer.scene.fog.density = clamp(settings.fog_density / 10_000, 0, 0.01);
const atmosphere = viewer.scene.skyAtmosphere;
if (atmosphere) {
atmosphere.show = settings.atmosphere_enabled;
atmosphere.hueShift = clamp(settings.atmosphere_hue / 100, -1, 1);
atmosphere.saturationShift = clamp(
settings.atmosphere_saturation / 100,
-1,
1,
);
atmosphere.brightnessShift = clamp(
settings.atmosphere_brightness / 100,
-1,
1,
);
}
viewer.shadows = settings.shadows_enabled;
const sunDate = Cesium.JulianDate.toDate(Cesium.JulianDate.now());
sunDate.setUTCHours(
clamp(Math.round(settings.sun_hour), 0, 24),
0,
0,
0,
);
viewer.clock.currentTime = Cesium.JulianDate.fromDate(sunDate);
viewer.clock.shouldAnimate = false;
const sun = new Cesium.SunLight();
(sun as CesiumModule.SunLight & { intensity?: number }).intensity = clamp(
settings.sun_intensity / 100,
0,
2,
);
viewer.scene.light = sun;
viewer.scene.requestRender();
}
function rebuildElevatedGrid(
runtime: RendererRuntime,
settings: CesiumMapRendererProps["settings"],
visible: boolean,
): void {
const { Cesium, viewer, grid } = runtime;
const entities = grid.entities;
entities.removeAll();
if (!visible) {
viewer.scene.requestRender();
return;
}
const cameraHeightKm =
Math.max(0, Number(viewer.camera.positionCartographic?.height || 0)) / 1000;
const stepKm =
!settings.grid_lod_enabled ||
cameraHeightKm <= settings.grid_lod_1_max_height_km
? settings.grid_lod_1_step_km
: cameraHeightKm <= settings.grid_lod_2_max_height_km
? settings.grid_lod_2_step_km
: settings.grid_lod_3_step_km;
const safeStepKm = clamp(stepKm, 0.25, 100);
const safeRadiusKm = clamp(settings.grid_radius_km, safeStepKm, 150);
const stepsPerSide = Math.min(
32,
Math.max(1, Math.floor(safeRadiusKm / safeStepKm)),
);
const cameraPosition = viewer.camera.positionCartographic;
const latitude = cameraPosition
? Cesium.Math.toDegrees(cameraPosition.latitude)
: 55.751244;
const longitude = cameraPosition
? Cesium.Math.toDegrees(cameraPosition.longitude)
: 37.618423;
const metersPerLatitudeDegree = 110_574;
const metersPerLongitudeDegree = Math.max(
1,
111_320 * Math.cos(Cesium.Math.toRadians(latitude)),
);
const stepMeters = safeStepKm * 1000;
const radiusMeters = stepsPerSide * stepMeters;
const deltaLatitude = stepMeters / metersPerLatitudeDegree;
const deltaLongitude = stepMeters / metersPerLongitudeDegree;
const radiusLatitude = radiusMeters / metersPerLatitudeDegree;
const radiusLongitude = radiusMeters / metersPerLongitudeDegree;
const lineColor = cssColor(Cesium, settings.grid_color, "#f5f5f5").withAlpha(
clamp(settings.grid_opacity / 100, 0, 1),
);
const dotColor = cssColor(Cesium, settings.grid_dots_color, "#ffffff").withAlpha(
clamp(settings.grid_dots_opacity / 100, 0, 1),
);
const elevation = Math.max(0, settings.grid_height_meters);
for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) {
const nextLatitude = latitude + index * deltaLatitude;
const nextLongitude = longitude + index * deltaLongitude;
entities.add({
polyline: {
positions: [
Cesium.Cartesian3.fromDegrees(
longitude - radiusLongitude,
nextLatitude,
elevation,
),
Cesium.Cartesian3.fromDegrees(
longitude + radiusLongitude,
nextLatitude,
elevation,
),
],
width: clamp(settings.grid_line_width, 1, 8),
material: lineColor,
},
});
entities.add({
polyline: {
positions: [
Cesium.Cartesian3.fromDegrees(
nextLongitude,
latitude - radiusLatitude,
elevation,
),
Cesium.Cartesian3.fromDegrees(
nextLongitude,
latitude + radiusLatitude,
elevation,
),
],
width: clamp(settings.grid_line_width, 1, 8),
material: lineColor,
},
});
}
if (settings.grid_dots_enabled) {
const dotStride = Math.max(
1,
Math.ceil((stepsPerSide * 2 + 1) / 25),
);
for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) {
for (
let column = -stepsPerSide;
column <= stepsPerSide;
column += dotStride
) {
entities.add({
position: Cesium.Cartesian3.fromDegrees(
longitude + column * deltaLongitude,
latitude + row * deltaLatitude,
elevation,
),
point: {
pixelSize: clamp(settings.grid_dots_size, 2, 28),
color: dotColor,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
}
}
}
viewer.scene.requestRender();
}
function safeErrorCode(error: unknown): string {
return error instanceof MapRuntimeError ? error.code : "map_provider_unavailable";
}
function tokenColor(
function cssColor(
Cesium: CesiumNamespace,
container: HTMLElement,
token: string,
value: string,
fallback: string,
): CesiumModule.Color {
const value = getComputedStyle(container).getPropertyValue(token).trim();
return Cesium.Color.fromCssColorString(value || fallback) ?? Cesium.Color.WHITE;
return (
Cesium.Color.fromCssColorString(value) ??
Cesium.Color.fromCssColorString(fallback) ??
Cesium.Color.WHITE
);
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
+38 -1
View File
@@ -25,6 +25,9 @@ export type MapRuntimeConfiguration = {
cache_proxy_prefix: string;
};
assets: Record<MapProviderId, number>;
sandbox?: {
hide_credit_overlay: boolean;
};
};
export type MapGatewayError = {
@@ -64,10 +67,44 @@ export type MapLayerVisibility = {
export type MapVisualSettings = {
atmosphere_enabled: boolean;
lighting_enabled: boolean;
atmosphere_hue: number;
atmosphere_saturation: number;
atmosphere_brightness: number;
fog_enabled: boolean;
fog_density: number;
sun_enabled: boolean;
sun_hour: number;
sun_intensity: number;
shadows_enabled: boolean;
monochrome_enabled: boolean;
monochrome_color: string;
imagery_brightness: number;
imagery_contrast: number;
imagery_saturation: number;
imagery_gamma: number;
imagery_hue: number;
imagery_alpha: number;
globe_color: string;
background_color: string;
terrain_exaggeration: number;
buildings_color: string;
buildings_opacity: number;
buildings_maximum_screen_space_error: number;
grid_lod_enabled: boolean;
grid_height_meters: number;
grid_lod_1_max_height_km: number;
grid_lod_1_step_km: number;
grid_lod_2_max_height_km: number;
grid_lod_2_step_km: number;
grid_lod_3_step_km: number;
grid_radius_km: number;
grid_line_width: number;
grid_color: string;
grid_opacity: number;
grid_dots_enabled: boolean;
grid_dots_size: number;
grid_dots_color: string;
grid_dots_opacity: number;
camera_animation_enabled: boolean;
};
@@ -11,7 +11,10 @@ export type ProviderEndpoint = {
url?: string;
mapStyle?: string;
};
attributions: readonly unknown[];
attributions: readonly {
html?: string;
collapsible?: boolean;
}[];
};
export function parseProviderEndpoint(
+4 -1
View File
@@ -103,7 +103,10 @@ export async function fetchMapRuntimeConfiguration(
!isRecord(document.assets) ||
document.assets.imagery !== 2 ||
document.assets.terrain !== 1 ||
document.assets.buildings !== 96188
document.assets.buildings !== 96188 ||
(document.sandbox !== undefined &&
(!isRecord(document.sandbox) ||
typeof document.sandbox.hide_credit_overlay !== "boolean"))
) {
throw new MapRuntimeError("map_runtime_contract_mismatch");
}