# How CesiumJS 3D Globe Integration Synchronizes with MapLibre's 2D View and Store State

> Learn how GeoLibre synchronizes CesiumJS 3D globe and MapLibre 2D views using a single Zustand store. Achieve seamless integration without direct coupling between rendering engines.

- Repository: [Open Geospatial Solutions/GeoLibre](https://github.com/opengeos/GeoLibre)
- Tags: deep-dive
- Published: 2026-08-15

---

**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 tiles
- `MapViewState` — 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`](https://github.com/opengeos/GeoLibre/blob/main/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:

```typescript
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`](https://github.com/opengeos/GeoLibre/blob/main/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`](https://github.com/opengeos/GeoLibre/blob/main/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.

```typescript
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`](https://github.com/opengeos/GeoLibre/blob/main/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

```tsx
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:

1. **User pans the 2D MapLibre map** — the map controller detects the change
2. **MapLibre updates `MapViewState`** in the Zustand store
3. **`applyMapViewToCamera`** pushes the new state to Cesium's camera
4. **Cesium renders the 3D view** from the new perspective
5. **User interacts with the globe** — Cesium's camera moves
6. **`readMapViewFromCamera`** extracts the new `MapViewState`
7. **`isSameView` tolerance check** passes, so the store updates
8. **MapLibre receives the update** and adjusts its viewport
9. **`CesiumLayerSync`** simultaneously 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`](https://github.com/opengeos/GeoLibre/blob/main/packages/core/src/types.ts).

## Key Files for CesiumJS MapLibre Integration

| Path | Purpose |
|------|---------|
| [`packages/core/src/types.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/core/src/types.ts) | Store type definitions (`GeoLibreLayer`, `MapViewState`) |
| [`packages/map/src/cesium-camera.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-camera.ts) | Camera conversion utilities |
| [`packages/map/src/cesium-layer-sync.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-layer-sync.ts) | Layer synchronization implementation |
| [`packages/map/src/CesiumCanvas.tsx`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/CesiumCanvas.tsx) | React component managing Cesium lifecycle and store subscriptions |
| [`apps/geolibre-desktop/vite-plugins/copy-cesium-assets.ts`](https://github.com/opengeos/GeoLibre/blob/main/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
- **`readMapViewFromCamera` and `applyMapViewToCamera`** in [`cesium-camera.ts`](https://github.com/opengeos/GeoLibre/blob/main/cesium-camera.ts) handle bidirectional camera translation with ground resolution preservation
- **`CesiumLayerSync`** mirrors 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`](https://github.com/opengeos/GeoLibre/blob/main/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.