The Orthographic projection displays the Earth like a 3D object, but it only shows us one side at a time, and only the center is shown accurately. In this section, we will use this projection and the zoom behavior to allow the user to explore the features by rotating and zooming in on the globe.
The code of this example is available in the chapter11/02-rotating
file of the code bundle. We will begin by drawing a globe using the Orthographic projection. As we did in the previous section, we load the TopoJSON data and construct the GeoJSON feature collection that represents the ne_50m_land
object:
d3.json('/chapter11/data/land.json', function(error, data) { // Handle errors getting or parsing the data if (error) { console.error(error); } // Construct the GeoJSON feature collection using TopoJSON var geojson = topojson.feature(data, data.objects.ne_50m_land); // Create the svg container... });
We set the width and height of the svg element and use these dimensions to create and configure an instance of the Orthographic projection. We also select the container div and append the svg container to the map:
// Width and height of the svg element var width = 600, height = 300; // Create an instance of the Orthographic projection var orthographic = d3.geo.orthographic() .scale(height / 2) .translate([width / 2, height / 2]) .clipAngle(90); // Append the svg container and set its size var div = d3.select('#map-orthographic'), svg = div.append('svg') .attr('width', width) .attr('height', height);
We create an instance of the geographic path generator and set its projection to be the Orthographic projection instance:
// Create and configure the geographic path generator var path = d3.geo.path() .projection(orthographic);
We will add features to represent the globe, the land, and lines for the parallels and meridians. We will add a feature to represent the globe in order to have a background for the features. The path generator supports an object of the Sphere
type. An object of this type doesn't have coordinates, since it represents the complete globe. We will also append the GeoJSON object that contains the land features:
// Globe var globe = svg.append('path').datum({type: 'Sphere'}) .attr('class', 'globe') .attr('d', path); // Features var land = svg.append('path').datum(geojson) .attr('class', 'land') .attr('d', path);
We will also add the graticule, which is a set of parallels and meridian lines, in order to give us a more accurate reference for the orientation and rotation of the sphere:
// Create the graticule generator var graticule = d3.geo.graticule(); // Append the parallel and meridian lines var lines = svg.append('path').datum(graticule()) .attr('class', 'graticule') .attr('d', path);
The preceding code will show us the Earth with the same aspect as the previous section. The strategy to add rotation and zoom to the globe will be to add an invisible overlay over the globe and add listeners for the pan and zoom gestures using the zoom behavior. The callback for the zoom
event will update the projection rotation and scale and update the paths of the features using the path generator configured earlier. To keep the state of the zoom behavior and the projection in sync, we will store the current rotation angles and the scale of the projection in the state
variable:
// Store the rotation and scale of the projection var state = {x: 0, y: -45, scale: height / 2};
We will update the configuration of the projection to use the attributes of this variable, just for consistency:
// Create and configure the Orthographic projection var orthographic = d3.geo.orthographic() .scale(state.scale) .translate([width / 2, height / 2]) .clipAngle(90) .rotate([state.x, state.y]);
The zoom and pan should be triggered only when the user performs these gestures over the globe, not outside. We will create an overlay circle of the same size as the globe and set its fill-opacity
attribute to zero. We will bind the state
variable to the overlay in order to modify it in the zoom
callback:
// Append the overlay and set its attributes var overlay = svg.append('circle').datum(state) .attr('r', height / 2) .attr('transform', function() { return 'translate(' + [width / 2, height / 2] + ')'; }) .attr('fill-opacity', 0);
We need to create an instance of the zoom behavior and bind it to the overlay. This will add event listeners for the pan and zoom gestures to the overlay. We will limit the scale extent to between 0.5
and 8
:
// Create and configure the zoom behavior var zoomBehavior = d3.behavior.zoom() .scaleExtent([0.5, 8]) .on('zoom', zoom); // Add event listeners for the zoom gestures to the overlay overlay.call(zoomBehavior);
When the user zooms or pans the overlay, a zoom
event is triggered. The current event is stored in the d3.event
variable. Each event type has its own attributes. In the case of the zoom
event, the zoom translation vector and the scale are accessible through the d3.event.translate
and d3.event.scale
attributes. We need to transform the scale and the translation vector to the appropriate projection scale and rotation:
function zoom(d) { // Compute the projection scale and the constant var scale = d3.event.scale, dx = d3.event.translate[0], dy = d3.event.translate[1]; // Maps the translation vector to rotation angles... }
The zoom
event will be triggered several times when the user performs the drag gesture. The translate
array accumulates the horizontal and vertical translations from the point where the drag gesture originated. If the user drags the globe from the left-hand side to the right-hand side, the globe should be rotated 180 degrees, counterclockwise. We will map the horizontal position of the translation vector to an angle between 0 and 180 degrees:
// Maps the translation vector to rotation angles d.x = 180 / width * dx; // Horizontal rotation d.y = -180 / height * dy; // Vertical rotation
If the user drags the North Pole towards the bottom of the image, we will want the globe to rotate forward. As the latitude is measured from the equator to the poles, we need to rotate the projection by a negative angle in order to have a rotation forward from the point of view of the observer. With the rotation angle computed, we can update the projection's rotate
and scale
attributes. The zoom scale is a relative zoom factor, and we need to multiply it by the original size of the map to obtain the updated scale of the projection:
// Update the projection with the new rotation and scale orthographic .rotate([d.x, d.y]) .scale(scale * d.scale);
To update the image, we need to reproject the features and compute the svg paths. The path has a reference to the projection instance, so we need to just update all the paths in the svg with the updated projection:
// Recompute the paths and the overlay radius svg.selectAll('path').attr('d', path); overlay.attr('r', scale * height / 2);
We also updated the overlay radius so that the dragging area coincides with the globe, even if the user changes the zoom level. The globe can be rotated and zoomed as shown in the following screenshot:
The globe can be rotated and zoomed with mouse or touch gestures, allowing the user to explore every part of the globe in detail. The strategy to add rotation and zoom behaviors can be used with any projection, adapting the mapping between the zoom translation and scale to rotations and scale of the projection. Depending on the level of detail of the features, there can be some performance issues during the rotation. The projection and path generation are being done on each rotation step. This can be avoided by rendering simpler features during the rotation, or even showing just the graticule when rotating and rendering the features when the user releases the mouse.
The rotation of the globe is not perfect, but it's a good approximation of what we would expect. For a better (but more complex) strategy, see the excellent article from Jason Davies at https://www.jasondavies.com/maps/rotate/.
In the next section, we will use the Stereographic projection and the zoom behavior to create an interactive star map.