From 65644a86091281ed111e102673f27dabcfb2d60c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Jul 2026 17:55:25 +0300 Subject: [PATCH] feat(map): share canonical Foundry presentation --- docs/MAP_CESIUM_REACT_ADAPTER.md | 18 + .../src/CesiumMapRenderer.tsx | 377 +++++++++++++++--- packages/map-cesium-react/src/contracts.ts | 39 +- .../map-cesium-react/src/providerEndpoint.ts | 5 +- packages/map-cesium-react/src/runtime.ts | 5 +- 5 files changed, 383 insertions(+), 61 deletions(-) diff --git a/docs/MAP_CESIUM_REACT_ADAPTER.md b/docs/MAP_CESIUM_REACT_ADAPTER.md index 92225e5..ff56d14 100644 --- a/docs/MAP_CESIUM_REACT_ADAPTER.md +++ b/docs/MAP_CESIUM_REACT_ADAPTER.md @@ -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 diff --git a/packages/map-cesium-react/src/CesiumMapRenderer.tsx b/packages/map-cesium-react/src/CesiumMapRenderer.tsx index ceb2bbe..6b8e1c9 100644 --- a/packages/map-cesium-react/src/CesiumMapRenderer.tsx +++ b/packages/map-cesium-react/src/CesiumMapRenderer.tsx @@ -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(null); const stateCallbackRef = useRef(onRuntimeStateChange); const cameraCallbackRef = useRef(onCameraChange); + const runtimeRef = useRef(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
; @@ -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>, 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 { + 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)); } diff --git a/packages/map-cesium-react/src/contracts.ts b/packages/map-cesium-react/src/contracts.ts index 0c5a066..bfed36c 100644 --- a/packages/map-cesium-react/src/contracts.ts +++ b/packages/map-cesium-react/src/contracts.ts @@ -25,6 +25,9 @@ export type MapRuntimeConfiguration = { cache_proxy_prefix: string; }; assets: Record; + 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; }; diff --git a/packages/map-cesium-react/src/providerEndpoint.ts b/packages/map-cesium-react/src/providerEndpoint.ts index 394660d..6f4538d 100644 --- a/packages/map-cesium-react/src/providerEndpoint.ts +++ b/packages/map-cesium-react/src/providerEndpoint.ts @@ -11,7 +11,10 @@ export type ProviderEndpoint = { url?: string; mapStyle?: string; }; - attributions: readonly unknown[]; + attributions: readonly { + html?: string; + collapsible?: boolean; + }[]; }; export function parseProviderEndpoint( diff --git a/packages/map-cesium-react/src/runtime.ts b/packages/map-cesium-react/src/runtime.ts index 62fc429..05241f5 100644 --- a/packages/map-cesium-react/src/runtime.ts +++ b/packages/map-cesium-react/src/runtime.ts @@ -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"); }