How CesiumJS 3D Globe Integration Synchronizes with MapLibre's 2D View and Store State
GeoLibre uses a single Zustand store to keep CesiumJS and MapLibre views in perfect synchronization without any direct coupling between the two rendering engines.
The GeoLibre project solves a complex geospatial challenge: running both a 2D MapLibre map and a 3D CesiumJS globe side-by-side with shared state. Rather than creating brittle bidirectional bindings between engines, the architecture extracts all map-related data into an engine-agnostic store that both panes subscribe to independently. This CesiumJS MapLibre synchronization pattern ensures consistent behavior across 2D and 3D views while maintaining clean separation of concerns.
The Engine-Agnostic State Store
At the heart of this integration lies a centralized Zustand store defined in @geolibre/core. This store holds two critical data structures:
GeoLibreLayer[]— an array of layer definitions supporting GeoJSON, XYZ tiles, WMS, WMTS, and 3D tilesMapViewState— a normalized camera description containing zoom, bearing, pitch, and center coordinates
The store also tracks which view kind ("maplibre" or "cesium") each pane prefers. These type definitions live in packages/core/src/types.ts.
Because both rendering engines read from and write to this same store, they achieve synchronization implicitly. Neither engine needs to know the other exists—they simply react to shared state changes.
Lazy Loading CesiumJS for Performance
The 3D globe pane only loads CesiumJS when actually needed. This dynamic import keeps initial bundle sizes small:
const Cesium = await import("cesium");
Supporting this lazy load requires static assets. The custom Vite plugin apps/geolibre-desktop/vite-plugins/copy-cesium-assets.ts copies Cesium's workers and asset files into public/cesium/ at build time. When the dynamic import executes, Cesium can locate its dependencies at the expected paths.
This architecture lets users work purely in 2D without paying the performance cost of a 3D engine they never use.
Camera Synchronization: MapLibre ↔ CesiumJS
Bidirectional camera synchronization happens through two conversion functions in packages/map/src/cesium-camera.ts:
Reading Cesium Camera State
readMapViewFromCamera extracts a MapViewState from a Cesium Camera instance. The conversion preserves ground resolution so that scale bars match across views:
- Cesium's range (meters from ellipsoid) → MapLibre zoom level
- Cesium's heading → MapLibre bearing
- Cesium's pitch → MapLibre pitch with horizon-reference adjustment
Applying MapLibre State to Cesium
applyMapViewToCamera performs the inverse transformation. It converts MapLibre zoom back to a metric range value and clamps pitch to Cesium's coordinate system where 0° looks straight down and -90° looks at the horizon.
The pitch mapping uses cesiumPitchToMapLibreDeg (directly tested in the suite) to handle the coordinate system translation correctly.
import { readMapViewFromCamera, applyMapViewToCamera } from "@geolibre/map/cesium-camera";
// Push store state to Cesium camera
store.subscribe((state) => {
const view = state.mapView;
const cesium = cesiumRef.current;
const viewer = cesiumCanvas.viewer;
applyMapViewToCamera(cesium, viewer.camera, view);
});
Layer Synchronization with CesiumLayerSync
While cameras control what users see, CesiumLayerSync in packages/map/src/cesium-layer-sync.ts controls what gets rendered. This class mirrors the MapController.syncLayers logic used for MapLibre, but adapts layer operations to Cesium's entity and imagery systems.
Supported Layer Types
| Layer Kind | Cesium Implementation |
|---|---|
| GeoJSON | GeoJSON datasource with entity styling |
| XYZ/WMS/WMTS | ImageryLayer with corresponding providers |
| 3D Tiles | Cesium3DTileset with altitude scaling |
Unsupported layer types gracefully degrade—the 2D pane renders them while the 3D pane simply ignores them. The sync process respects:
- Visibility toggles
- Opacity values
- Layer ordering (z-index translation)
- Altitude offsets for 3D tilesets
import { CesiumLayerSync } from "@geolibre/map/cesium-layer-sync";
const cesiumSync = useRef<CesiumLayerSync | null>(null);
// Initialize sync when Cesium loads
useEffect(() => {
if (Cesium && viewer) {
cesiumSync.current = new CesiumLayerSync(Cesium, viewer);
}
}, [Cesium, viewer]);
// Subscribe to layer changes
useEffect(() => {
const unsub = store.subscribe((state) => {
cesiumSync.current?.sync(state.layers);
});
return unsub;
}, []);
Preventing Jitter: The Tolerance Check
A naive bidirectional sync would create infinite feedback loops: updating the store updates Cesium, which triggers a camera change, which updates the store, which updates MapLibre...
GeoLibre breaks this cycle with isSameView tolerance checking. Before writing camera changes back to the store, the system checks whether the new state differs meaningfully from what's already stored. Near-identical values are discarded, preventing the "apply → moveEnd → apply" echo that causes visual jitter.
This creates a deconflicted update loop where:
- User interaction on either pane updates the store
- The other pane receives the update
- Its resulting camera change only propagates back if it represents genuine new input
Complete Synchronization Flow
Understanding the full 3D globe 2D view state synchronization requires following one complete interaction cycle:
- User pans the 2D MapLibre map — the map controller detects the change
- MapLibre updates
MapViewStatein the Zustand store applyMapViewToCamerapushes the new state to Cesium's camera- Cesium renders the 3D view from the new perspective
- User interacts with the globe — Cesium's camera moves
readMapViewFromCameraextracts the newMapViewStateisSameViewtolerance check passes, so the store updates- MapLibre receives the update and adjusts its viewport
CesiumLayerSyncsimultaneously updates imagery and entities on the globe
This flow runs continuously during split-pane use, keeping both views visually consistent while the underlying data remains a single source of truth in packages/core/src/types.ts.
Key Files for CesiumJS MapLibre Integration
| Path | Purpose |
|---|---|
packages/core/src/types.ts |
Store type definitions (GeoLibreLayer, MapViewState) |
packages/map/src/cesium-camera.ts |
Camera conversion utilities |
packages/map/src/cesium-layer-sync.ts |
Layer synchronization implementation |
packages/map/src/CesiumCanvas.tsx |
React component managing Cesium lifecycle and store subscriptions |
apps/geolibre-desktop/vite-plugins/copy-cesium-assets.ts |
Build-time asset handling for Cesium workers |
Summary
- Engine-agnostic Zustand store holds all map state, enabling clean CesiumJS MapLibre synchronization without direct coupling
- Lazy CesiumJS loading via dynamic imports keeps 2D-only usage lightweight
readMapViewFromCameraandapplyMapViewToCameraincesium-camera.tshandle bidirectional camera translation with ground resolution preservationCesiumLayerSyncmirrors 2D layer state onto the 3D globe, supporting GeoJSON, imagery services, and 3D tiles- Tolerance-based deduplication prevents jitter in the bidirectional update loop
- The entire system treats both engines as pure view layers over a single authoritative state store
Frequently Asked Questions
How does GeoLibre handle CesiumJS assets in a Vite build?
The custom plugin apps/geolibre-desktop/vite-plugins/copy-cesium-assets.ts copies Cesium's worker scripts, WASM, and imagery assets into public/cesium/ during build. When import("cesium") executes at runtime, the engine finds its dependencies at predictable relative paths.
What happens to unsupported layer types on the 3D globe?
CesiumLayerSync explicitly filters unsupported layer kinds. A custom vector tile layer or specialized raster source still renders in MapLibre panes while the Cesium pane simply skips it—no errors, no visual artifacts. The layer remains in the store and contributes to shared state like bounding box calculations.
Can multiple Cesium globe panes run simultaneously?
Yes, though the primary use case is one 3D pane alongside 2D views. Each CesiumCanvas component creates its own Viewer instance and subscribes independently to the global store. Multiple globe panes would synchronize to the same camera position and layer set, which is computationally expensive but architecturally valid.
How does pitch mapping between engines preserve intuitive navigation?
Cesium uses a horizon-relative pitch where 0° points at the horizon and -90° points straight down. MapLibre uses a nadir-relative system where 0° is straight down and increasing values tilt toward the horizon. The cesiumPitchToMapLibreDeg function in the test suite implements this translation so that equivalent viewing angles feel consistent to users switching between panes.
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 →