# How GeoLibre Bidirectionally Syncs CesiumJS 3D Globe Camera and Layer State with MapLibre

> Discover how GeoLibre bidirectionally syncs CesiumJS 3D globe camera and layer state with MapLibre using a shared Zustand store and conversion functions. Explore the repository.

- Repository: [Open Geospatial Solutions/GeoLibre](https://github.com/opengeos/GeoLibre)
- Tags: how-to-guide
- Published: 2026-08-04

---

**GeoLibre achieves bidirectional synchronization between CesiumJS and MapLibre by converting camera parameters and layer state through a shared Zustand store, using pure conversion functions in [`cesium-camera.ts`](https://github.com/opengeos/GeoLibre/blob/main/cesium-camera.ts) and a reconciliation engine in [`cesium-layer-sync.ts`](https://github.com/opengeos/GeoLibre/blob/main/cesium-layer-sync.ts) to prevent feedback loops.**

The open-source **GeoLibre** project unifies 2D and 3D geospatial visualization by keeping MapLibre's flat map and Cesium's globe in perfect lockstep. This article examines the exact implementation details of how camera movements and layer changes propagate in both directions without infinite loops or visual jitter, based on the actual source code in the `opengeos/GeoLibre` repository.

## The Bidirectional Sync Architecture

GeoLibre's synchronization system operates through three distinct paths. Understanding each direction separately clarifies how the system maintains consistency.

### MapLibre → Cesium: Pushing 2D View State

When users interact with the MapLibre map, the shared **Zustand store** captures `MapViewState` changes containing center coordinates, zoom level, bearing, and pitch. The function `applyMapViewToCamera` in [`packages/map/src/cesium-camera.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-camera.ts) converts these parameters into Cesium-native camera properties:

- **Zoom → Range**: Uses `zoomToRange` to calculate the camera-to-target distance that produces equivalent ground coverage
- **Bearing → Heading**: Normalized through `normalizeBearing` to Cesium's 0-360° convention
- **Pitch → Pitch**: Transformed via `mapLibrePitchToCesiumDeg` (MapLibre's 0° nadir becomes Cesium's -90°)

The implementation calls `viewer.camera.lookAt` to apply these values instantly:

```typescript
import { applyMapViewToCamera } from "@geolibre/map/src/cesium-camera";

// Called whenever MapLibre's view changes
function onMapLibreViewChanged(view: MapViewState) {
  applyMapViewToCamera(cesiumNamespace, viewer, view);
}

```

Layer synchronization follows the same push pattern. `CesiumLayerSync.sync(layers)` in [`packages/map/src/cesium-layer-sync.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-layer-sync.ts) receives the `GeoLibreLayer[]` array from the store and reconciles it with Cesium's scene. The sync engine:
1. Removes Cesium objects for deleted layer IDs
2. Creates new `ImageryLayer`, `GeoJsonDataSource`, or `Cesium3DTileset` instances for additions
3. Updates existing layers in place when possible, triggering full rebuilds only when `needsRebuild` returns true

### Cesium → MapLibre: Pulling 3D Camera Movements

User interactions with the 3D globe—dragging, tilting, or zooming—must propagate back to MapLibre. The hook `useCesiumSync` (in the apps layer) registers a listener on Cesium's native `moveEnd` event:

```typescript
viewer.camera.moveEnd.addEventListener(() => {
  const newView = readMapViewFromCamera(cesiumNamespace, viewer);
  const store = useStore.getState();
  
  if (!isSameView(store.mapView, newView)) {
    store.setMapView(newView);
  }
});

```

The `readMapViewFromCamera` function performs the inverse conversions:
- **Range → Zoom**: `rangeToZoom` derives MapLibre zoom from camera distance
- **Heading → Bearing**: Folded back to MapLibre's -180° to 180° range
- **Pitch → Pitch**: Converted via `cesiumPitchToMapLibreDeg`

### Preventing Feedback Loops with Tolerance Checks

A critical problem in bidirectional sync: updating the store from Cesium triggers `applyMapViewToCamera`, which moves the Cesium camera, which fires `moveEnd`, which updates the store again. GeoLibre breaks this cycle with `isSameView(a, b)` in [`cesium-camera.ts`](https://github.com/opengeos/GeoLibre/blob/main/cesium-camera.ts).

This utility compares two `MapViewState` objects with **floating-point tolerances** for center coordinates, zoom, bearing, and pitch. When Cesium reports a camera position that matches the current store state within epsilon, the update is discarded. The round-trip conversion is verified in [`tests/cesium-camera.test.ts`](https://github.com/opengeos/GeoLibre/blob/main/tests/cesium-camera.test.ts).

## Core Conversion Mathematics

All coordinate transformations live in [`packages/map/src/cesium-camera.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-camera.ts) as **pure functions** with no Cesium runtime imports. This design minimizes bundle size and enables comprehensive unit testing.

### Ground Resolution and Zoom-Range Conversions

```typescript
// Meters per pixel in Web Mercator at given zoom and latitude
function groundResolution(zoom: number, latitude: number): number;

// Convert MapLibre zoom to Cesium camera range (meters)
function zoomToRange(zoom: number, latitude: number): number;

// Inverse: derive zoom from camera distance
function rangeToZoom(range: number, latitude: number): number;

```

### Angular Conventions

MapLibre and Cesium use opposite pitch conventions. GeoLibre handles this with dedicated converters:

| System | Nadir | Horizon | Convention |
|--------|-------|---------|------------|
| MapLibre | 0° | 85° | Pitch increases toward horizon |
| Cesium | -90° | 0° | Pitch increases toward zenith |

The conversion functions:

```typescript
function mapLibrePitchToCesiumDeg(pitch: number): number;
function cesiumPitchToMapLibreDeg(pitch: number): number;

```

## Layer Synchronization Deep Dive

The `CesiumLayerSync` class in [`packages/map/src/cesium-layer-sync.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-layer-sync.ts) maintains an internal **entries map** keyed by layer `id`. Each entry tracks the Cesium object instance and its current configuration hash.

On every `sync(layers)` call, the engine performs three operations in order:

1. **Removal**: Iterate existing entries and destroy Cesium objects whose IDs no longer appear in the input array
2. **Creation**: Instantiate new Cesium objects for layers with unrecognized IDs
3. **Update**: For existing matches, compare configuration hashes to determine whether to call `applyAppearance` (style-only change) or fully rebuild

Opacity changes on GeoJSON layers receive special optimization through `applyGeoJsonStyle`, avoiding expensive data reloads when only visual properties change.

## Complete Working Example

Here's how to initialize and wire the synchronization system:

```typescript
import { CesiumLayerSync } from "@geolibrel/map/src/cesium-layer-sync";
import { 
  applyMapViewToCamera, 
  readMapViewFromCamera,
  isSameView 
} from "@geolibrel/map/src/cesium-camera";
import { Viewer } from "cesium";
import { useStore } from "@geolibrel/core";

// Initialize Cesium viewer
const cesiumNs = await import("cesium");
const viewer: Viewer = new cesiumNs.Viewer("cesiumContainer");

// Create layer sync instance
const layerSync = new CesiumLayerSync(cesiumNs, viewer);

// Subscribe to store changes (MapLibre → Cesium)
useStore.subscribe((state) => state.mapView, (view) => {
  applyMapViewToCamera(cesiumNs, viewer, view);
});

useStore.subscribe((state) => state.layers, (layers) => {
  layerSync.sync(layers);
});

// Listen for Cesium interactions (Cesium → MapLibre)
viewer.camera.moveEnd.addEventListener(() => {
  const pulledView = readMapViewFromCamera(cesiumNs, viewer);
  const currentView = useStore.getState().mapView;
  
  if (!isSameView(currentView, pulledView)) {
    useStore.getState().setMapView(pulledView);
  }
});

```

## Summary

- **Camera sync** uses pure conversion functions in [`packages/map/src/cesium-camera.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-camera.ts) to transform between MapLibre's `MapViewState` and Cesium's camera parameters
- **Layer sync** employs `CesiumLayerSync` in [`packages/map/src/cesium-layer-sync.ts`](https://github.com/opengeos/GeoLibre/blob/main/packages/map/src/cesium-layer-sync.ts) to reconcile `GeoLibreLayer[]` with Cesium scene objects
- **Feedback prevention** relies on `isSameView` tolerance checks to break potential infinite update loops
- **Mathematical foundations** include ground resolution calculations, zoom-range conversions, and angular convention mappings, all unit-tested in [`tests/cesium-camera.test.ts`](https://github.com/opengeos/GeoLibre/blob/main/tests/cesium-camera.test.ts)

## Frequently Asked Questions

### What triggers the Cesium to MapLibre camera sync?

Cesium's native `moveEnd` event fires after any camera movement completes. The `useCesiumSync` hook registers a listener that calls `readMapViewFromCamera`, converts the result, and writes to the shared Zustand store. This only propagates if `isSameView` determines the change exceeds floating-point tolerances.

### How does GeoLibre handle layer opacity changes without rebuilding?

The `CesiumLayerSync` class detects when a layer's configuration changes only in visual properties. For GeoJSON layers, it calls `applyGeoJsonStyle` to update opacity and styling in place rather than destroying and recreating the `GeoJsonDataSource`. This optimization preserves performance during rapid style adjustments.

### Can the conversion functions be used without loading Cesium?

Yes. The core math functions—`zoomToRange`, `rangeToZoom`, `groundResolution`, and the pitch/bearing converters—are pure functions with no Cesium imports. This allows extensive unit testing in [`tests/cesium-camera.test.ts`](https://github.com/opengeos/GeoLibre/blob/main/tests/cesium-camera.test.ts) and keeps bundle sizes minimal when the 3D view isn't needed.

### What prevents the camera from jittering when both views update simultaneously?

The `isSameView` utility implements epsilon-based comparison (typically 1e-6 for coordinates, smaller fractional differences for zoom/bearing/pitch). When Cesium reports a position matching the current store state within tolerance, the update is ignored. This breaks the feedback cycle that would otherwise cause oscillation between the two coordinate systems.