Declaratively Rendering Earth in 3D, Part 2: Controlling Cesium with React
This is a post in the Declaratively Rendering Earth in 3D series.
Intro 🔗︎
In Part 1 of this series, we started a new React project, updated it to load the Cesium 3D globe library with Webpack, and optimized the production build using Webpack's DllPlugin. This time, we'll use React components to declaratively control Cesium's imperative API, including rendering of images and vectors, mouse interaction, and camera control.
As a reminder, Cesium.js is a powerful Javascript library for rendering a 3D globe. It can display a wide variety of geospatial visualizations, including icons and text labels, vector geometries, 3D models, and much more, all on top of a fully-viewable 3D Earth with digital 3D terrain and imagery layers.
This series assumes some familiarity with Cesium, React, and Webpack, and isn't intended to teach the basics for them.
The code for the sample project accompanying this series is on Github at github.com/markerikson/cesium-react-webpack-demo. The commits I made for this post can be seen in PR #2: Use React to render Cesium contents I'll be linking to many of the commits as I go through the post, as well as specific files in those commits. I won't paste every changed file in here or show every single changed line, to save space, but rather try to show the most relevant changes for each commit as appropriate.
Update, 2020 🔗︎
While the techniques shown here are still usable, I've since learned and would recommend newer approaches. In particular, I recommend:
- Using function components with hooks instead of class components
- Using the Resium library for working with Cesium in React, which provides prebuilt React components that wrap Cesium APIs
Table of Contents 🔗︎
- Encapsulating Cesium Setup
- Declarative Cesium Rendering with React
- Interacting with Cesium
- Final Thoughts
- Further Information
Encapsulating Cesium Setup 🔗︎
Picking up where we left off, we simply have a Cesium Viewer widget being created by our root <App>
component. Let's move the process of initializing the Cesium Viewer into a separate component, called <CesiumGlobe>
. As we do so, we're going to make some changes to how we initialize the Viewer.
First, the Cesium Viewer widget has a number of configurable parts that can be enabled or disabled. This includes the timeline, the imagery layer picker, and several others. We're going to turn those off to reduce the clutter.
Second, Cesium can use many different sources of imagery and terrain data. By default, it uses the Bing Maps globe imagery servers. However, use of Bing Maps requires an API key, and it's a bad idea to continue using Cesium's API key in our own application. So, I've created my own API key per the instructions from Microsoft, and we'll configure Cesium to use that when it displays imagery. If you're following along with this tutorial, you should create your own Bing Maps API key too. We're also going to configure Cesium to use the "STK Terrain" dataset that is publicly hosted for non-commercial use by AGI, which will give us high-quality terrain visualization if we zoom in.
Commit 944084f: Add a separate CesiumGlobe component to render Cesium
import React, {Component} from "react";
import Viewer from "cesium/Source/Widgets/Viewer/Viewer";
import BingMapsImageryProvider from "cesium/Source/Scene/BingMapsImageryProvider";
import CesiumTerrainProvider from "cesium/Source/Core/CesiumTerrainProvider";
const BING_MAPS_URL = "//dev.virtualearth.net";
const BING_MAPS_KEY = "ABCDEFGH12345678";
const STK_TERRAIN_URL = "//assets.agi.com/stk-terrain/world";
export default class CesiumGlobe extends Component {
state = {viewerLoaded : false}
componentDidMount() {
const imageryProvider = new BingMapsImageryProvider({
url : BING_MAPS_URL,
key : BING_MAPS_KEY,
});
const terrainProvider = new CesiumTerrainProvider({
url : STK_TERRAIN_URL
});
this.viewer = new Viewer(this.cesiumContainer, {
animation : false,
baseLayerPicker : false,
fullscreenButton : false,
geocoder : false,
homeButton : false,
infoBox : false,
sceneModePicker : false,
selectionIndicator : true,
timeline : false,
navigationHelpButton : false,
scene3DOnly : true,
imageryProvider,
terrainProvider,
});
}
componentWillUnmount() {
if(this.viewer) {
this.viewer.destroy();
}
}
render() {
const containerStyle = {
width: '100%',
height: '100%',
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'fixed',
display : "flex",
alignItems : "stretch",
};
const widgetStyle = {
flexGrow : 2
}
return (
<div className="cesiumGlobeWrapper" style={containerStyle}>
<div
className="cesiumWidget"
ref={ element => this.cesiumContainer = element }
style={widgetStyle}
/>
</div>
);
}
}
The new <CesiumGlobe>
component follows the same basic pattern as before for creating the Viewer instance, but we're disabling all the other built-in widgets and passing in the specifically-configured imagery and terrain providers. We're also now applying layout styles to force the Cesium container to fill the entire page.
That wraps up the basic application setup. Let's move on to using React to interact with Cesium.
Declarative Cesium Rendering with React 🔗︎
React is primarily used for declarative rendering of specific UI elements, whether they're <div>
s in standard React, <View>
s in React Native, or something similar. However, one of React's strengths is the ability to create declarative components that wrap up imperative APIs, using React's component lifecycle methods. This allows React to control things like jQuery plugins, canvas libraries, and much more. We're going to apply this pattern to drive our Cesium display.
Rendering Initial Content 🔗︎
Right now, our <CesiumGlobe>
component only creates the Cesium viewer. We're going to need it to also act as the parent component for all of our Cesium-related React components. To do that, we need to split the rendering logic into two parts. The first time <CesiumGlobe>
renders, we want it to only render the container element for the Cesium widget. Then, after Cesium is loaded, we need to re-render and include all of our Cesium-related components, so they can make use of the Cesium Viewer's properties.
Commit f02144e: Add logic for rendering contents in CesiumGlobe
componentDidMount() {
// Skip Viewer init
+ // Force immediate re-render now that the Cesium viewer is created
+ this.setState({viewerLoaded : true});
}
+ renderContents() {
+ const {viewerLoaded} = this.state;
+ let contents = null;
+
+ if(viewerLoaded) {
+ contents = (
+ <span>
+ </span>
+ );
+ }
+ return contents;
+ }
// In render()
+ const contents = this.renderContents()
+
return (
<div className="cesiumGlobeWrapper" style={containerStyle}>
<div
className="cesiumWidget"
ref={ element => this.cesiumContainer = element }
style={widgetStyle}
- />
+ >
+ {contents}
+ </div>
</div>
);
As we start defining our Cesium-based React components, we can insert them into the contents <span>
, and React will render them properly.
With that in place, it's time to see our first example of actually controlling Cesium's API in a React component. We're going to create a small component that renders the React logo as a Cesium billboard. Let's jump right into the code and see what's going on:
src/cesium/CesiumBillboardExample.jsx
import React, {Component} from "react";
import Cartesian3 from "cesium/Source/Core/Cartesian3";
import BillboardCollection from "cesium/Source/Scene/BillboardCollection";
import logo from "../logo.svg";
export default class CesiumBillboardExample extends Component {
constructor(props) {
super(props);
this.billboards = new BillboardCollection();
const {scene} = props;
if(scene) {
scene.primitives.add(this.billboards);
}
}
componentWillUnmount() {
const {billboards} = this;
if(!billboards.isDestroyed()) {
billboards.destroy();
}
const {scene} = this.props;
if(scene && !scene.isDestroyed() && scene.primitives) {
scene.primitives.remove(billboards);
}
}
componentDidMount() {
const lat = 37.484505, lon = -122.147877;
const position = Cartesian3.fromDegrees(lon, lat);
this.billboard = this.billboards.add({
position,
image : logo,
});
}
render() {
return null;
}
}
There's several important things to see here. First, our render
method simply returns null
. This component doesn't render any normal UI output. React does require that render
exists, though, so we just tell React this component isn't rendering anything.
Second, we create a Cesium BillboardCollection in the constructor, and very carefully clean it up in componentWillUnmount
. The component also expects to get a Cesium Scene instance as a prop, and if it exists, will tell the Scene to render all Billboards in the BillboardCollection it created.
Third, when the component mounts, we create a Cesium Billboard instance, and set its position by converting a lat/lon pair into Cartesian XYZ coordinates, which is what Cesium requires.
If we load the app, here's what we should see:
Another big step in the right direction, but we can do more.
Updating APIs From Props 🔗︎
That React logo is currently sitting in a fixed location. Since applications are dynamic, we should be able to update what we're displaying. Normally we'd just re-render our app with new state and let React take care of updating the UI, but things are just a bit different here. Let's see if we can make the logo jump to a new location when a button is clicked.
Before we can show a button on screen, we'll need to make a few tweaks to the styling and layout in the page. Right now the Cesium container div is filling the entire screen directly, and any other HTML content we try to show will wind up behind the Cesium div. We'll have to shuffle things around a bit so that we can overlay some HTML content on top of Cesium.
Commit 81d8d50: Rework layout styling to show content over the globe
Next, let's tackle making the logo billboard move. This is where React's lifecycle methods come into play. In particular, componentWillReceiveProps
and componentDidUpdate
are good places to compare previous prop values with new prop values to see if anything's changed, and do something in response. We'll start by adding logic to our <CesiumBillboardExample>
to accept some coordinates as a prop, and set the billboard's position any time those change:
Commit 145e67e: Implement logic to move the logo on button click
src/cesium/CesiumBillboardExample.js
+ componentDidUpdate(prevProps) {
+ if(prevProps.logoCoords !== this.props.logoCoords && this.props.logoCoords) {
+ this.updateIcon();
+ }
+ }
+
+ updateIcon() {
+ const {logoCoords} = this.props;
+ const {lat, lon} = logoCoords;
+
+ if(this.billboard) {
+ const newPosition = Cartesian3.fromDegrees(lon, lat);
+
+ this.billboard.position = newPosition;
+ }
+ }
Whenever the component has received props, we diff the logoCoords
prop to see if it changed, and if so, call the new updateIcon()
method. That simply takes whatever the lat/lon coordinates are from props, and applies those to the billboard. This is almost identical to normal React rendering in concept - we don't care what the actual values are, we just take the current values and use them for the output.
We also need to pass this new logoCoords
prop down the component hierarchy:
if(viewerLoaded) {
const {scene} = this.viewer;
+ const {logoCoords} = this.props;
contents = (
<span>
- <CesiumBillboardExample scene={scene} />
+ <CesiumBillboardExample scene={scene} logoCoords={logoCoords} />
</span>
);
}
And we'll update the <App>
component to have logo coordinates in state, and update them when we click a button:
class App extends Component {
+ state = {logoCoords : null}
+ onMoveLogoClicked = () => {
+ const logoCoords = {lat : 39.097465, lon : -84.50703};
+ this.setState({logoCoords});
+ }
render() {
+ const {logoCoords} = this.state;
return (
<div style={containerStyle}>
- <CesiumGlobe/>
+ <CesiumGlobe logoCoords={logoCoords} />
<div style={{position : "fixed", top : 0}}>
<div style={{color : "white", fontSize: 40, }}>
Text Over the Globe
</div>
+ <button
+ onClick={this.onMoveLogoClicked}
+ style={{fontSize: 40}}
+ >
+ Move Logo
+ </button>
</div>
</div>
If we reload and click the "Move Logo" button, we should see the React logo jump from San Francisco to Cincinnati:
(There's a bit of asymmetry here in that the original logo coordinates are still coming from componentDidMount
, but the updated values are coming from the <App>
component as the logoCoords
prop. I probably should have moved the original coordinates up into <App>
's initial state, but I didn't, and don't feel like going back to rewrite things.)
Rendering Multiple Billboards 🔗︎
Rendering one billboard is great, but what happens when we want to render many billboards? We need to make this process repeatable. Let's extract the core logic for rendering a billboard out into its own component:
src/cesium/primitives/CesiumBillboard.jsx
import {Component} from "react";
import Cartesian3 from "cesium/Source/Core/Cartesian3";
import HorizontalOrigin from "cesium/Source/Scene/HorizontalOrigin";
import VerticalOrigin from "cesium/Source/Scene/VerticalOrigin";
import {shallowEqual} from "utils/utils";
export default class CesiumBillboard extends Component {
componentDidMount() {
const {billboards} = this.props;
if(billboards) {
this.billboard = billboards.add({
eyeOffset : new Cartesian3(0.0, 0.0, 0.0),
horizontalOrigin : HorizontalOrigin.CENTER,
verticalOrigin : VerticalOrigin.CENTER,
});
}
this.updateIcon();
}
componentDidUpdate(prevProps) {
if(!shallowEqual(this.props, prevProps)) {
this.updateIcon();
}
}
updateIcon() {
const {image, selected, scale = 1.0, lat, lon, alt, show = true, width} = this.props;
if(this.billboard) {
const newLocation = Cartesian3.fromDegrees(lon, lat, alt);
this.billboard.position = newLocation;
if(image) {
this.billboard.image = image;
}
this.billboard.show = show;
this.billboard.scale = scale;
if(width) {
this.billboard.width = width;
}
}
}
componentWillUnmount() {
const {billboards} = this.props;
if(billboards && !billboards.isDestroyed() && this.billboard) {
billboards.remove(this.billboard);
}
}
render() {
return null;
}
}
It's the same basic approach we used in the previous example, but cleaned up and made more generic. Now, our <CesiumBillboard>
component actually receives a BillboardCollection instance as a prop. It takes several props that match Billboard's options, and just removes its billboard from the collection when unmounted.
Next, we'll create a <CesiumProjectContents>
component that can render the <CesiumBillboard>
instances:
Commit 0207702: Add a CesiumContents component to render many billboards
src/cesium/CesiumProjectContents.jsx
import React, {Component} from "react";
import BillboardCollection from "cesium/Source/Scene/BillboardCollection";
import CesiumBillboard from "./primitives/CesiumBillboard";
export class CesiumProjectContents extends Component {
constructor(props) {
super(props);
this.billboards = new BillboardCollection();
const {scene} = props;
if(scene) {
scene.primitives.add(this.billboards);
}
}
componentWillUnmount() {
const {billboards} = this;
if(!billboards.isDestroyed()) {
billboards.destroy();
}
const {scene} = this.props;
if(scene && !scene.isDestroyed() && scene.primitives) {
scene.primitives.remove(billboards);
}
}
render() {
const {icons = []} = this.props;
const renderedBillboards = icons.map( (icon, index) =>
<CesiumBillboard
{...icon}
billboards={this.billboards}
key={index}
/>
);
return (
<span>
{renderedBillboards}
</span>
);
}
}
export default CesiumProjectContents;
Again, same basic pattern: receive a Cesium Scene as a prop, create a BillboardCollection and attach it to the Scene, clean up on unmount. This time, though, we're also receiving an array of "icons" as props, and turning those into <CesiumBillboard>
components. This is normal React rendering in action.
Finally, we'll remove the prior "Move Logo" button from <App>
, and instead have it render two different logos: the React logo over San Francisco, and the Cincinnati Reds logo over the Great American Ballpark in Cincinnati:
+import reactLogo from "logo.svg";
+import redsLogo from "./redsLogo.png";
class App extends Component {
+ state = {
+ reactLogo : {lat : 37.484505, lon : -122.147877, image : reactLogo},
+ redsLogo : { lat : 39.097465, lon : -84.50703, image : redsLogo, scale : 0.3}
+ }
+ render() {
- const {logoCoords} = this.state;
+ const {reactLogo, redsLogo} = this.state;
+ const icons = [reactLogo, redsLogo];
return (
<div style={containerStyle}>
- <CesiumGlobe logoCoords={logoCoords} />
+ <CesiumGlobe icons={icons} />
If we reload the application and zoom in a bit, here's what it looks like:
Rendering More Cesium Primitives 🔗︎
Now that we have a good pattern set up for rendering Cesium Billboards, we can do the same thing for Labels and Polylines. We'll skip the source code for now, but here's the commits:
Commit e264e9f: Add a CesiumLabel component
Commit 74f81c3: Add rendering of labels
Here's another look at the updated globe after adding an example label and polyline:
Pretty sweet!
Interacting with Cesium 🔗︎
We now have the ability to render multiple types of visual primitives inside of Cesium. However, we don't yet have a way to interact with the globe from the application, other than the basic mouse-based camera controls that are built into Cesium itself.
Our next task will be to add some interaction capabilities. We need to hook into Cesium's event APIs so that we can handle mouse events, and we need to be able to update the Cesium camera position programmatically.
Adding Mouse Interaction 🔗︎
Cesium's mouse events API revolves around the ScreenSpaceEventHandler
class, as seen in Cesium's "Picking" demo. Application code creates a ScreenSpaceEventHandler
instance and passes it the scene.canvas
element that Cesium is rendering into, then adds callbacks for various mouse events. We're going to create a component that just listens for mouse left click events.
src/cesium/CesiumClickHandler.jsx
import {Component} from "react";
import ScreenSpaceEventHandler from "cesium/Source/Core/ScreenSpaceEventHandler";
import SSET from "cesium/Source/Core/ScreenSpaceEventType";
import CesiumMath from "cesium/Source/Core/Math";
import {noop} from "utils/utils";
export default class CesiumClickHandler extends Component {
static defaultProps = {
onLeftClick : noop
}
componentDidMount() {
const {scene} = this.props;
if(scene && scene.canvas) {
this.screenEvents = new ScreenSpaceEventHandler(scene.canvas);
this.createInputHandlers();
}
}
componentWillUnmount() {
if(this.screenEvents && !this.screenEvents.isDestroyed()) {
this.screenEvents.destroy();
}
}
createInputHandlers() {
this.screenEvents.setInputAction(this.onMouseLeftClick, SSET.LEFT_CLICK);
}
onMouseLeftClick = (e) => {
const {position : clientPos} = e;
const mapCoordsRadians = this.pickMapCoordinates(clientPos);
if(mapCoordsRadians) {
const mapCoordsDegrees = {
lat : CesiumMath.toDegrees(mapCoordsRadians.latitude),
lon : CesiumMath.toDegrees(mapCoordsRadians.longitude),
};
this.props.onLeftClick(mapCoordsDegrees);
}
}
pickMapCoordinates(screenPos) {
const {scene} = this.props;
let mapCoords;
if(scene) {
const cartesianPos = scene.camera.pickEllipsoid(screenPos);
if(cartesianPos) {
mapCoords = scene.globe.ellipsoid.cartesianToCartographic(cartesianPos);
}
}
return mapCoords;
}
render() {
return null;
}
}
This component uses the same pattern of creating Cesium API objects in the constructor and cleaning them up in componentWillUnmount
. In this specific example, we subscribe to Cesium's ScreenSpaceEventType.LEFT_CLICK
event, and pass a class method as the callback function.
Cesium has a couple different event object formats for different mouse event types. For LEFT_CLICK
, the event contains a field called position
, which has the screen X/Y coordinates of the mouse click. We want to convert those screen coordinates into the lat/lon coordinates on the globe where the mouse click occurred. Cesium provides the lat/lon coordinates in radians, so we also convert those into degrees. Finally, we call this.props.onLeftClick(mapCoordsDegrees)
to pass the click coordinates upwards to any component that might have given us a click callback, and use defaultProps
to ensure that the onLeftClick
prop is always a function.
Now, we just need to hook up the <CesiumClickHandler>
component to the rest of the application, and pass down a click handler:
import CesiumProjectContents from "./CesiumProjectContents";
+import CesiumClickHandler from "./CesiumClickHandler";
// Skip ahead
if(viewerLoaded) {
const {scene} = this.viewer;
- const {icons, labels, polylines} = this.props;
+ const {icons, labels, polylines, onLeftClick} = this.props;
contents = (
<span>
<CesiumProjectContents
scene={scene}
icons={icons}
labels={labels}
polylines={polylines}
/>
+ <CesiumClickHandler
+ scene={scene}
+ onLeftClick={onLeftClick}
+ />
</span>
);
}
+ handleLeftClick = (coords) => {
+ console.log("Left mouse clicked at: ", coords)
+ }
// Skip to rendering
- <CesiumGlobe icons={icons} labels={labels} polylines={polylines} />
+ <CesiumGlobe
+ icons={icons}
+ labels={labels}
+ polylines={polylines}
+ onLeftClick={this.handleLeftClick}
+ />
If we click on the globe, we should see output similar to this:
Moving the Cesium Camera Location 🔗︎
Cesium has a Camera
class that provides methods for moving the camera around. The main method is camera.flyTo()
, which is a very imperative method for changing the camera location.
We can wrap this API using roughly the same approach as our other components, but with a bit of a difference. Thus far, we've always applied the current prop values to the Cesium primitives, such as image URL and lat/lon coordinates. That's great for mimicking the same kind of "stateless rendering" approach that React normally uses.
However, if we follow the same pattern for the camera, we're going to force the camera to jump to the given coordinates every time the application re-renders. That's not going to be very user-friendly. What we really want is to only update the camera position when the request camera coordinates change from the previous requested values. So, what we'll do this time is compare the previous and current coordinate props in componentDidUpdate
, and make the camera update logic conditional.
src/cesium/CesiumCameraManager
import {Component} from "react";
import CesiumMath from "cesium/Source/Core/Math";
import Cartesian3 from "cesium/Source/Core/Cartesian3";
import {isUndefined} from "utils/utils";
export default class CesiumCameraManager extends Component {
componentDidMount() {
const {camera} = this.props;
if(camera) {
this.handleUpdatedCameraProps({}, this.props.globe, camera);
}
}
componentDidUpdate(prevProps) {
const {flyToLocation, camera} = this.props;
if(prevProps.flyToLocation !== flyToLocation) {
this.handleUpdatedCameraProps(prevProps.flyToLocation, flyToLocation, camera);
}
}
handleUpdatedCameraProps(oldFlyTo, flyToLocation, camera) {
let newLocationObject = null;
if(flyToLocation && oldFlyTo !== flyToLocation) {
newLocationObject = flyToLocation;
}
if(newLocationObject) {
let {lat, lon, alt = undefined, heading, pitch, roll} = newLocationObject;
const {delay = 0} = newLocationObject;
let orientation = undefined;
if(lat === 0.0 && lon === 0.0) {
// Nobody _really_ wants a closeup of the ocean off west Africa
lat = 35.0;
lon = -117.0;
alt = 2500000;
}
if(!isUndefined(heading)) {
orientation = {
heading : CesiumMath.toRadians(heading),
pitch : CesiumMath.toRadians(pitch),
roll : CesiumMath.toRadians(roll),
};
}
camera.flyTo({
destination : Cartesian3.fromDegrees(lon, lat, alt),
duration : delay,
orientation,
});
}
}
render() {
return null;
}
}
There's some handling for various edge cases in there, but the basic idea is pretty simple. If the flyToLocation
prop has changed, we try to extract the new coordinates from it, and call camera.flyTo()
with the results.
Let's hook this component into the application, and add a button that will jump the camera to San Diego's harbor:
import CesiumClickHandler from "./CesiumClickHandler";
+import CesiumCameraManager from "./CesiumCameraManager";
// Skip ahead
if(viewerLoaded) {
const {scene} = this.viewer;
- const {icons, labels, polylines, onLeftClick} = this.props;
+ const {icons, labels, polylines, onLeftClick, flyToLocation} = this.props;
// Skip to rendering
+ <CesiumCameraManager
+ camera={scene.camera}
+ flyToLocation={flyToLocation}
+ />
</span>
class App extends Component {
state = {
reactLogo : {lat : 37.484505, lon : -122.147877, image : reactLogo},
redsLogo : { lat : 39.097465, lon : -84.50703, image : redsLogo, scale : 0.3},
label : {lat : 35.0, lon : -100.0, text : "Catch phrase here"},
line : [
{lat : 47.5, lon : -122.3, alt : 20000 },
{lat : 36.2, lon : -115.0, alt : 20000 },
{lat : 39.0, lon : -94.6, alt : 20000 },
{lat : 30.4, lon : -81.6, alt : 20000 },
],
+ flyToLocation : null,
}
+ handleFlyToClicked = () => {
+ this.setState({
+ flyToLocation : {lat : 32.6925, lon : -117.1587, alt : 100000}
+ });
+ }
render() {
- const {reactLogo, redsLogo, label, line} = this.state;
+ const {reactLogo, redsLogo, label, line, flyToLocation} = this.state;
// Skip to rendering
<div style={containerStyle}>
<CesiumGlobe
icons={icons}
labels={labels}
polylines={polylines}
onLeftClick={this.handleLeftClick}
+ flyToLocation={flyToLocation}
/>
<div style={{position : "fixed", top : 0}}>
<div style={{color : "white", fontSize: 40, }}>
Text Over the Globe
</div>
+ <button style={{fontSize : 40}} onClick={this.handleFlyToClicked}>
+ Jump Camera Location
+ </button>
</div>
</div>
And if we click the "Jump Camera Location" button, we should see the camera jump to show us an aerial overview of San Diego:
Wrapping Up Cesium Interaction 🔗︎
These examples should give you a solid foundation for using Cesium with React. You should be able to take the basic principles and approaches you've seen, and adapt or expand them as needed in your own application.
One thing I didn't demonstrate in this post is testing React components that rely on Cesium. This is another area where Cesium's architecture presents some difficulties. Because Cesium's source files are written as AMD modules, you can't just import them directly when running under Node.js. They need to be converted or loaded in some fashion.
In my "real" app at work, I'm using the mocha-webpack tool to run my tests. Because it runs all source files through a full Webpack+Babel compilation process before executing tests, I can successfully run tests that reference Cesium. Here's one example of what such a test might look like:
describe("CesiumPolyline", () => {
it("Updates values on property change", () => {
const polylines = new PolylineCollection();
const coords = [];
const wrapper = mount(<CesiumPolyline polylines={polylines} coords={coords} selected={false} loop={false} />);
const instance = wrapper.instance();
const {polyline} = instance;
const oldPositions = [...polyline.positions];
const {loop : oldLoop} = polyline;
const {color : oldColor} = polyline.material.uniforms;
const newCoords = [{latitude : 35.0, longitude : -117.0, altitude : 150000}];
wrapper.setProps({coords : newCoords, loop : true, selected : true});
expect(oldPositions).to.not.deep.equal(polyline.positions);
expect(oldLoop).to.not.equal(polyline.loop);
expect(oldColor).to.not.equal(polyline.material.uniforms.color);
});
});
I had hoped to show how to run the same kinds of test under Jest, but ran into problems getting Jest to load AMD modules. After browsing some issues, it doesn't seem to be a supported scenario (per Jest issue #17). That issue does reference a Babel plugin called babel-plugin-transform-amd-to-commonjs
that's supposed to help, but I was unable to get it to work in a short period of time.
Another limitation is that Cesium's Scene class requires WebGL, which doesn't work under Node. It's absolutely possible to test code where the Cesium classes don't rely on WebGL, such as the polyline example above, but testing the <CesiumGlobe>
component would be difficult since it creates a Viewer
instance. (There was a recent Cesium PR that supposedly added "no-op stubs" for all of its WebGL behavior, which might make something like this feasible, but I haven't looked into that further.)
Overall, testing React components that use Cesium is possible, it may just require some specific build+test configuration in order to make it work.
Final Thoughts 🔗︎
The idea of wrapping imperative APIs into declarative components using lifecycle methods is a powerful concept. It lets us encapsulate almost any external logic, and use it as just another component in our React application. That includes jQuery plugins, canvas libraries, timers, and so much more.
One other important thing to point out: anything we can control from React state is also something we can control from Redux. It's absolutely possible to have data in a Redux store that drives a React-based HTML list of lat/lon points of interest, and have those same points being displayed on the globe via Redux-driven React components. (I can vouch for this, because this is exactly what my own app does.)
Hopefully, these examples serve as a great starting point for your own applications, whether you use Cesium, or just React!
Further Information 🔗︎
- Wrapping Imperative APIs with React Components
- Cesium Documentation
This is a post in the Declaratively Rendering Earth in 3D series. Other posts in this series:
- Mar 07, 2017 - Declaratively Rendering Earth in 3D, Part 2: Controlling Cesium with React
- Mar 07, 2017 - Declaratively Rendering Earth in 3D, Part 1: Building a Cesium + React App with Webpack