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.
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.
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.
npm install @amcharts/amcharts5. See Getting started.
The same chart as a JSON config. Chart Maker reads and writes this format, the library's JSON parser turns it into a chart, and the <am5-chart> element puts it on a page with no code."type" is the class to create and "settings" what you would pass to its new() in code; "properties" fill in its parts, lists such as series and axes or templates such as columns.template; "refs" holds objects used in more than one place ("#series-0" stands for the one named series-0); a string starting with "@" takes a value from another object, such as "@series.get('fill')".
A complete page. Save it as an .html file and open it in a browser.
Paste this into an AI coding assistant to build the chart from scratch. More in amCharts & AI.
// 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);
