Globe with Raised Parallels

The Earth drawn with nothing but its parallels, one every 1.5 degrees. Over the sea they lie flat on the globe; over land they rise by the real height of the ground, 200 times exaggerated, so mountains and ice sheets stand out.

  • Try it:
  • Drag to turn the globe
  • Click, then scroll to zoom
  • Click a legend item to hide it
Get the code JavaScript
About this map

Lines that carry their own heights

Set altitude on a map line, even to 0, and each of its points can carry a height of its own: a third value after longitude and latitude, in metres. Here every parallel is cut into pieces, flat ones over the sea and raised ones over land, with a point every degree at the ground's height. On a globe the raised lines rise past its edge and hide behind it, so the relief reads as 3D. The code leaves out points that add nothing and caps the frame rate, as every raised point is redrawn while the globe turns. The heights come from NOAA's ETOPO1 Global Relief Model (Amante and Eakins, 2009; public domain), averaged over cells 1.5° high and 1° wide.

Good for

  • Elevation, depth or any value measured along lines
  • A striking globe that opens a landing page
  • Profiles along routes, rivers or borders

Think twice when

  • Reading exact heights: a profile chart or a colored map is clearer
  • Slow devices: thousands of raised points are redrawn as the globe turns
  • Values by country: color the countries instead
For developers

Get the code.

Built with amCharts 5, a JavaScript library. The code includes your theme and renderer (canvas or SVG), but not the changes you make with the controls.

// Create root element
// https://www.amcharts.com/docs/v5/getting-started/#Root_element
var root = am5.Root.new("chartdiv");

// Set themes
// https://www.amcharts.com/docs/v5/concepts/themes/
root.setThemes([
  am5themes_Animated.new(root),
  am5themes_Responsive.new(root)
]);

// At most 30 frames a second: plenty for a slow turn, and it leaves the browser time for the rest of the page,
// as every turn redraws thousands of raised points
// https://www.amcharts.com/docs/v5/getting-started/root-element/#Performance
root.fps = 30;

// The land's heights along the parallels, from relief-parallels.js, loaded before this code: NOAA's ETOPO1 relief
// model averaged every 1.5° of latitude and 1° of longitude. rows run north to south, from 84°N to 84°S; each holds
// 360 heights in metres, one per degree of longitude eastward from 180°W, with the sea as 0
var relief = reliefParallels;

// How the lines are drawn
var heightScale = 200; // heights drawn 200 times their real size: true to scale, the Himalayas would be a hair
var speed = 4;         // the globe turns 4° a second, once round in 90 seconds
// degrees between the parallels drawn: every one in the data, 1.5° apart, or every other one on a small screen,
// where they would crowd together
var lineSpacing = root.dom.offsetWidth < 600 ? 3 : 1.5;

// Height bands, highest last: each gets a line of its own, in its own color
var bands = [
  { name: "Up to 500 m", from: 0 },
  { name: "500 to 2,000 m", from: 500 },
  { name: "Over 2,000 m", from: 2000 }
];

// Create the map chart
// https://www.amcharts.com/docs/v5/charts/map-chart/
var chart = root.container.children.push(am5map.MapChart.new(root, {
  projection: am5map.geoOrthographic(), // a globe, seen from space
  panX: "rotateX",                      // a sideways drag spins the globe
  panY: "rotateY",                      // an up or down drag tilts it
  rotationX: -20,                       // start over Europe and Africa, at longitude 20°E...
  rotationY: -25,                       // ...seen from 25°N, as most of the land is in the north
  // a little smaller than the space, for the mountains that rise past the globe's edge
  zoomLevel: 0.85,
  homeZoomLevel: 0.85,
  autoHome: true,                       // go to the home view once the map is fitted
  minZoomLevel: 0.5                     // zoom out to half the fitted size at most
}));

// The whole globe as one polygon, never seen: a map of lines alone has no outline to fit, so the map is fitted
// to this
// https://www.amcharts.com/docs/v5/charts/map-chart/map-polygon-series/#Background_polygon
var globeSeries = chart.series.push(am5map.MapPolygonSeries.new(root, {
  opacity: 0 // invisible
}));

globeSeries.data.push({
  geometry: am5map.getGeoRectangle(90, 180, -90, -180) // north, east, south and west edges: the whole world
});

// The sea: where a parallel crosses water, it lies flat on the globe. All of it is one line, in a grey between
// the background and the text color, so it suits light and dark themes
// https://www.amcharts.com/docs/v5/charts/map-chart/map-line-series/
var seaColor = am5.Color.interpolate(0.2, root.interfaceColors.get("background"), root.interfaceColors.get("alternativeBackground"));

var seaSeries = chart.series.push(am5map.MapLineSeries.new(root, {
  name: "Sea",
  fill: seaColor,       // the legend's marker...
  stroke: seaColor,     // ...and the line
  affectsBounds: false  // the lines take no part in fitting the map
}));

// The land: a line per height band, in the theme's colors, raised off the globe by the heights its points carry
var colors = am5.ColorSet.new(root, {});

var landSeries = bands.map(function(band, i) {
  var color = colors.getIndex(i * 4); // every fourth theme color, so neighbouring bands differ clearly
  var series = chart.series.push(am5map.MapLineSeries.new(root, {
    name: band.name,
    fill: color,          // the legend's marker...
    stroke: color,        // ...and the line
    affectsBounds: false  // the lines take no part in fitting the map
  }));
  // altitude 0: the line has no arc of its own, and each point's third value, [longitude, latitude, metres],
  // raises it there. Lines without altitude stay on the surface, whatever their points hold
  series.mapLines.template.set("altitude", 0);
  return series;
});

// The band a height falls in
function bandOf(height) {
  var n = 0;
  while (n < bands.length - 1 && height >= bands[n + 1].from) {
    n++;
  }
  return n;
}

// Leave out the points of a raised piece that add nothing, as the globe redraws every point each time it turns.
// A point stays where its drawn height is more than 30 km off the straight run between the points kept on either
// side (the Douglas-Peucker method), about a pixel on this globe; and no two points are more than 5° apart, as
// the line runs the shortest way between them, not along the parallel
var tolerance = 30000; // metres, as drawn
function simplify(points) {
  var keep = points.map(function() { return false; });
  keep[0] = keep[points.length - 1] = true; // the ends always stay

  // the points between a and b: keep the one farthest off the straight run, and look again on each side of it
  function split(a, b) {
    if (b - a < 2) {
      return;
    }
    var farthest = -1, at = a;
    for (var j = a + 1; j < b; j++) {
      var straight = points[a][2] + (points[b][2] - points[a][2]) * (j - a) / (b - a);
      var off = Math.abs(points[j][2] - straight);
      if (off > farthest) {
        farthest = off;
        at = j;
      }
    }
    if (farthest <= tolerance) {
      if (b - a <= 5) {
        return; // close enough to straight, and short enough
      }
      at = Math.floor((a + b) / 2); // straight but long: halve it
    }
    keep[at] = true;
    split(a, at);
    split(at, b);
  }

  split(0, points.length - 1);
  return points.filter(function(point, j) { return keep[j]; });
}

// Draw the parallels. Each one is cut where it goes from sea to land or from one height band to the next, and
// the pieces go to the line of their kind. A leg, one degree of longitude, is sea when both its ends are;
// otherwise it takes the band of its higher end
function drawLines() {
  var sea = [];
  var land = bands.map(function() { return []; });
  var equator = relief.north / relief.step;         // the row of the equator
  var every = Math.round(lineSpacing / relief.step); // 1: every row, 2: every other row...

  relief.rows.forEach(function(row, i) {
    // the rows lineSpacing apart, counted from the equator, so the equator is always drawn
    if ((i - equator) % every !== 0) {
      return;
    }
    var latitude = relief.north - i * relief.step;

    // what each leg is: -1 for sea, or the land's height band. The last leg ends at 180°, where the first began
    var legs = [];
    for (var k = 0; k < 360; k++) {
      var a = row[k], b = row[(k + 1) % 360];
      legs.push(a || b ? bandOf(Math.max(a, b)) : -1);
    }

    // a point of the parallel, k degrees east of 180°W: on land with its height, drawn heightScale times taller
    function point(k, kind) {
      return kind < 0 ? [k - 180, latitude] : [k - 180, latitude, row[k % 360] * heightScale];
    }

    var piece;
    for (k = 0; k < 360; k++) {
      // a new piece where the kind changes
      if (k === 0 || legs[k] !== legs[k - 1]) {
        piece = [point(k, legs[k])];
        (legs[k] < 0 ? sea : land[legs[k]]).push(piece);
      }
      // the leg's end. Land gets a point every degree, for its shape; flat sea only every 5° and where it ends,
      // which keeps it on the parallel, as the line runs the shortest way between its points
      if (legs[k] >= 0 || (k + 1) % 5 === 0 || legs[k + 1] !== legs[k]) {
        piece.push(point(k + 1, legs[k]));
      }
    }
  });

  // each series is one line of many pieces; the raised ones lose the points they don't need
  seaSeries.data.setAll([{ geometry: { type: "MultiLineString", coordinates: sea } }]);
  landSeries.forEach(function(series, n) {
    series.data.setAll([{ geometry: { type: "MultiLineString", coordinates: land[n].map(simplify) } }]);
  });
}

drawLines();

// The globe turns on its own: rotationX goes once round in 360 / speed seconds, again and again
// https://www.amcharts.com/docs/v5/concepts/animations/
var spinAnimation;

function spin() {
  if (spinAnimation) {
    spinAnimation.stop();
  }
  if (speed > 0) {
    var from = chart.get("rotationX", 0);
    spinAnimation = chart.animate({ key: "rotationX", from: from, to: from + 360, duration: 360000 / speed, loops: Infinity });
  }
}

spin();

// A drag turns the globe itself, which stops the spin: let go, and it turns on from there
chart.chartContainer.events.on("globalpointerup", spin);

// Add legend, under the globe: the line colors, from the sea up. A click on one hides its lines
// https://www.amcharts.com/docs/v5/concepts/legend/
root.container.set("layout", root.verticalLayout); // the globe, then the legend, then the credit, one under the other

var legend = root.container.children.push(am5.Legend.new(root, {
  useDefaultMarker: true, // plain squares in the lines' colors
  x: am5.p50,             // the legend's middle...
  centerX: am5.p50,       // ...at the middle of the chart
  marginTop: 10           // 10px under the globe
}));

legend.valueLabels.template.set("forceHidden", true); // no value labels: the items sit closer together

legend.data.setAll([seaSeries].concat(landSeries));

// Credit the heights' source, in small print under the legend
root.container.children.push(am5.Label.new(root, {
  text: "Heights: NOAA ETOPO1 Global Relief Model",
  fontSize: 11,     // small print...
  fillOpacity: 0.6, // ...a little faded
  x: am5.p50,       // the label's middle...
  centerX: am5.p50  // ...at the middle of the chart
}));

// Make stuff animate on load
// https://www.amcharts.com/docs/v5/concepts/animations/
chart.appear(1000, 100);

More Maps demos.

More map tools.