How to Implement a CesiumJS 3D Globe as a Secondary Map Pane with Camera Sync
GeoLibre synchronizes a CesiumJS 3D globe with 2D map panes through a shared Zustand store that coordinates camera state bi-directionally, allowing real-time view mirroring across mixed rendering engines.
The opengeos/GeoLibre architecture treats every visualization as a thin view component that reads from a centralized state store in @geolibre/core. When implementing a CesiumJS 3D globe as a secondary map pane, you leverage the same reactive patterns used for 2D MapLibre canvases, ensuring camera movements and layer visibility stay synchronized across all panes.
Defining the Secondary Map View Schema
Before rendering a 3D globe, you must declare the pane configuration in the shared store. In packages/core/src/types.ts, the SecondaryMapView interface supports both 2D and 3D rendering engines through a discriminated union on the viewKind property.
// packages/core/src/types.ts
export interface SecondaryMapView {
id: string;
view?: MapViewState; // Saved camera position
layerVisibility?: Record<string, boolean>; // Per-pane overrides
viewKind?: "cesium" | "maplibre"; // Determines component selection
}
The secondaryMapViews array in your project JSON (or Zustand store) holds these definitions. When viewKind is "cesium", the layout grid automatically mounts the CesiumCanvas component instead of the standard SecondaryMapCanvas.
Preparing the Cesium Environment
CesiumJS requires specific asset loading configuration before the viewer initializes. The prepareCesiumEnvironment() function in packages/map/src/CesiumCanvas.tsx sets window.CESIUM_BASE_URL and injects the widget stylesheet so the engine can locate its Web Workers and assets.
// packages/map/src/CesiumCanvas.tsx
function prepareCesiumEnvironment() {
window.CESIUM_BASE_URL = "/cesium";
// Stylesheet injection logic ensures proper widget rendering
}
Call this setup routine before the dynamic import to prevent 404 errors on Cesium's static resources.
Initializing the Cesium Canvas Component
The CesiumCanvas component uses a lazy dynamic import to avoid bundling Cesium until needed. A useEffect hook with an empty dependency list guarantees a single Viewer instance per pane, preventing memory leaks and duplicate globe initializations.
// packages/map/src/CesiumCanvas.tsx
export const CesiumCanvas = memo(function CesiumCanvas({
viewId,
ionToken
}: {
viewId: string;
ionToken?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<any>(null);
useEffect(() => {
prepareCesiumEnvironment();
const init = async () => {
const Cesium = await import("cesium");
if (!containerRef.current) return;
viewerRef.current = new Cesium.Viewer(containerRef.current, {
terrainProvider: Cesium.createWorldTerrain(),
shouldAnimate: false
});
// Layer sync and camera setup follows...
};
init();
// Cleanup on unmount
return () => {
viewerRef.current?.destroy();
};
}, []); // Empty array ensures single instance
return <div ref={containerRef} className="cesium-container" />;
});
Synchronizing Camera State Globally
Camera synchronization operates in two modes controlled by mapLayout.syncView. When global sync is enabled, the pane reads the shared mapView (global view) from the store and applies it to the Cesium camera using applyView. When disabled, it uses its own saved entryView.
The global sync effect in CesiumCanvas.tsx (lines 106-112) watches the store and updates the camera programmatically:
// Global sync: react to shared store changes
useEffect(() => {
if (!viewerRef.current || !syncView) return;
const view = globalView; // From Zustand store
applyMapViewToCamera(viewerRef.current.camera, view);
}, [globalView, syncView]);
The local sync effect (lines 123-130) handles independent pane state when synchronization is toggled off:
// Local sync: use pane-specific saved view
useEffect(() => {
if (!viewerRef.current || syncView || !entryView) return;
applyMapViewToCamera(viewerRef.current.camera, entryView);
}, [entryView, syncView]);
Both effects rely on conversion utilities in packages/map/src/cesium-camera.ts to translate between GeoLibre's MapViewState and Cesium's Camera orientation.
Mirroring User Navigation Back to the Store
Bi-directional sync requires capturing manual camera movements and writing them back to the store. The camera.moveEnd event listener (lines 250-275 in CesiumCanvas.tsx) compares the current camera state against the last programmatic view to filter out echo updates from terrain streaming or window resizes.
// Inside the mount effect
viewerRef.current.camera.moveEnd.addEventListener(() => {
if (userMovedRef.current === false) return; // Skip programmatic moves
const currentView = readMapViewFromCamera(viewerRef.current.camera);
if (syncView) {
setMapView(currentView); // Update global view
} else {
setSecondaryMapView(viewId, currentView); // Update local pane view
}
});
The userMovedRef boolean distinguishes genuine user interaction from autonomous camera settles, preventing sync loops that could jerk the view.
Syncing Layers to the 3D Globe
Layer visibility propagates to the Cesium globe through the CesiumLayerSync class imported from packages/map/src/cesium-layer-sync.ts. This helper translates GeoLibreLayer objects into Cesium-native sources including GeoJSON, XYZ imagery, and 3D Tiles.
// Inside CesiumCanvas.tsx mount effect (lines 46-48)
const layerSyncRef = useRef(new CesiumLayerSync(viewerRef.current));
// Sync whenever layer list changes (lines 98-104)
useEffect(() => {
if (!layerSyncRef.current) return;
layerSyncRef.current.sync(paneLayers); // Respects visibility overrides
}, [paneLayers]);
The paneLayers variable combines the global layer list with any per-pane visibility overrides stored in secondaryMapViews[id].layerVisibility.
Complete Project Configuration Example
To add a Cesium pane to your GeoLibre project, define the view in your project configuration and ensure the grid layout can access the component:
/* .geolibre.json project configuration */
{
"mapLayout": {
"rows": 2,
"cols": 2,
"syncView": true
},
"secondaryMapViews": [
{
"id": "cesium-globe",
"viewKind": "cesium",
"view": {
"center": [0, 0],
"zoom": 2,
"bearing": 0,
"pitch": 45
},
"layerVisibility": {
"satellite-layer": true,
"terrain-layer": true
}
}
]
}
Toggle camera synchronization at runtime using the store's setMapLayoutSyncView action:
import { useAppStore } from "@geolibre/core";
function SyncToggle() {
const sync = useAppStore(s => s.mapLayout.syncView);
const setSync = useAppStore.getState().setMapLayoutSyncView;
return (
<button onClick={() => setSync(!sync)}>
{sync ? "Unsync Panes" : "Sync Panes"}
</button>
);
}
Summary
Implementing a CesiumJS 3D globe as a secondary map pane in GeoLibre requires coordinating state across three architectural layers:
- Store Definition: Use
SecondaryMapViewwithviewKind: "cesium"to declare 3D panes inpackages/core/src/types.ts - Environment Setup: Call
prepareCesiumEnvironment()before dynamically importing Cesium to resolve asset paths - Camera Synchronization: Apply views using
applyMapViewToCamerafrompackages/map/src/cesium-camera.ts, with separate effects handling global and local sync modes - Bi-Directional Updates: Write user navigation back to the store via
camera.moveEndlisteners that respect theuserMovedRefguard - Layer Management: Instantiate
CesiumLayerSyncto translate GeoLibre layers into Cesium primitives while honoring per-pane visibility overrides
Frequently Asked Questions
How does GeoLibre prevent infinite camera sync loops between panes?
The system uses a userMovedRef boolean flag to distinguish between programmatic camera updates (which should not trigger store writes) and genuine user interaction. When applyMapViewToCamera updates the camera programmatically, userMovedRef is set to false, causing the camera.moveEnd listener to skip the reciprocal store update. Only physical mouse or touch navigation sets userMovedRef to true, ensuring one-way propagation during synchronized moves.
Can I display multiple Cesium 3D globes simultaneously?
Yes. Each CesiumCanvas component mounts an independent Cesium Viewer instance within its own DOM container. Because each pane maintains its own viewId and references the shared store individually, you can configure multiple entries in secondaryMapViews with viewKind: "cesium". Each globe syncs to the global camera state or maintains independent views based on the syncView layout setting.
What geospatial data formats does the Cesium secondary pane support?
According to the CesiumLayerSync implementation in packages/map/src/cesium-layer-sync.ts, the 3D pane supports GeoJSON vectors, XYZ raster tiles, and 3D Tiles for mesh and point cloud data. The sync helper automatically converts GeoLibre layer definitions into Cesium GeoJsonDataSource, UrlTemplateImageryProvider, or Cesium3DTileset instances, applying per-pane visibility filters from the store.
How do I disable camera synchronization for only the Cesium pane while keeping 2D panes synced?
Set mapLayout.syncView to true for the 2D panes, but store an independent view in the specific secondaryMapViews entry for the Cesium pane. When the global sync is enabled, the Cesium component checks for a local entryView override. Alternatively, modify the sync logic in CesiumCanvas.tsx to ignore globalView when a specific flag is set, though the default architecture requires toggling sync globally or managing the pane's view state independently when sync is disabled.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →