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

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 and a reconciliation engine in 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 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:

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

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.

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.

Core Conversion Mathematics

All coordinate transformations live in 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

// 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:

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

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 to transform between MapLibre's MapViewState and Cesium's camera parameters
  • Layer sync employs CesiumLayerSync in 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

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 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.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →