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
zoomToRangeto calculate the camera-to-target distance that produces equivalent ground coverage - Bearing → Heading: Normalized through
normalizeBearingto 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:
- Removes Cesium objects for deleted layer IDs
- Creates new
ImageryLayer,GeoJsonDataSource, orCesium3DTilesetinstances for additions - Updates existing layers in place when possible, triggering full rebuilds only when
needsRebuildreturns 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:
rangeToZoomderives 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:
- Removal: Iterate existing entries and destroy Cesium objects whose IDs no longer appear in the input array
- Creation: Instantiate new Cesium objects for layers with unrecognized IDs
- 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.tsto transform between MapLibre'sMapViewStateand Cesium's camera parameters - Layer sync employs
CesiumLayerSyncinpackages/map/src/cesium-layer-sync.tsto reconcileGeoLibreLayer[]with Cesium scene objects - Feedback prevention relies on
isSameViewtolerance 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →