Skip to content

Instantly share code, notes, and snippets.

@oliverheilig
Last active January 13, 2023 10:35
Show Gist options
  • Save oliverheilig/29e494c33ef58c6d5839 to your computer and use it in GitHub Desktop.
Save oliverheilig/29e494c33ef58c6d5839 to your computer and use it in GitHub Desktop.
In Defense of Mercator

Note: I'm well aware that i am using "Web Mercator" in this article. Web-Mercator is not exactly conformal and thus not even Mercator. For more infos about this particular issue and a brief history of Web-Mercator read this document. This page explains the general implications different projections have, with focus on software development for logistics applications.

The Vicious Mercator Map Projection

From time to time i am involved in discussions about the pitfalls of the Mercator map projection. Yes, the Mercator projection was invented 1569 for crossing an ocean and not for internet maps. And yes, on a global scale the Mercator projection grotesquely distorts the sizes of nations and continents. You can also argue that this is a discrimination against the southern hemisphere.

<iframe width="950" height="540" src="https://www.youtube.com/embed/vVX-PrBRtTY?start=61" frameborder="0" allowfullscreen></iframe>

But Mercator is great for Developers

Sometimes developers claim they don't make any projection to avoid distortions. What they actually mean is that they render the latitude and longitude angles of WGS84 directly to the screen. But this "unprojected" projection implies you are handling angles on a sphere like they were points on a plane, and this is also a projection, called equirectangular projection (or in french "plate carrée" and german "Plattkarte"). Like any other map projection the equirectangular projection has specific properties, but it lacks one important property: the conformality.

“Many of the most common and most important map projections are conformal or orthomorphic ... in that normally the shape of every small feature of the map is shown correctly... An important result of conformality is that relative angles at each point are correct, and the local scale in every direction around any one point is constant.” John P. Snyder, Map Projections Used by the U. S. Geological Survey, 1983

This is why i always prefer the mercator projection over any non-conformal projection for visualization and computations. The conformality is much more important for logistics applications than comparing the relative size of nations and continents. I also recommend transforming your points to Mercator before doing any geometric computations, because then:

  • Angles are correct
  • Shapes of structures are preserved
  • You can compare distances locally with pythagoras
  • You can approximate the geographic distance when multiplying the pythagoras-distance by cos(lat)

This implies that you can render a geographic circle as a circle on the map canvas. Just divide the mercator radius by cos(center.lat). You see the benefit in the sample on top: The circle of the Karlsruhe "fan" is a circle in the Mercator map, but not in the "unprojected" map.

Plus, you can use 2D geometric algorithms on mercator points, like the computation of voronoi regions.

<iframe width="950" height="540" src="https://oliverheilig.github.io/voronoi-territories/" frameborder="0" allowfullscreen></iframe>

The Formulas

These are the formulas to project a WGS (lng/lat) point to and from a Mercator point. This is the "Web" version, and we neglect the inaccuracy of Web Mercator, as i mentioned before. The earth radius (6378137.0) is the major axis of WGS84. There's also a variation that uses the mean between major and minor axis (6371000.0), as used by my company's web services.

public static Point Wgs2SphereMercator(Point point)
{
    return new Point {
        X = 6378137.0 * point.X * Math.PI / 180.0,
        Y = 6378137.0 * Math.Log(Math.Tan(Math.PI / 4.0 + point.Y * Math.PI / 360.0))
    };
}

public static Point SphereMercator2Wgs(Point point)
{
    return new Point {
        X = (180.0 / Math.PI) * (point.X / 6378137.0),
        Y = (360 / Math.PI) * (Math.Atan(Math.Exp(point.Y / 6378137.0)) - (Math.PI / 4))
    };
}

References

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">
<html>
<head>
<title>Mercator vs Equirectangular</title>
<meta content="yes" name="apple-mobile-web-app-capable">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.0-beta.2.rc.2/leaflet.css" />
<style>
body {
padding: 0;
margin: 0;
}
html, body, table,
#map, #map2 {
height: 100%;
}
</style>
</head>
<body>
<table width="100%" border="0">
<tr>
<td width="50%" align="center">
<h3>Mercator</h3>
</td>
<td colspan="2" align="center">
<input id="radiusInput" type="range" name="points" min="0" max="1000">
<div id="radiusText"></div>
</td>
<td width="50%" align="center">
<h3>Equirectangular</h3>
</td>
</tr>
<tr>
<td colspan="2" id="map1" width="50" />
<td colspan="2" id="map2" width="50" />
</tr>
</table>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.0-beta.2.rc.2/leaflet.js"></script>
<script src="./L.Map.Sync.js"></script>
<script>
// using the xserver-internet WMS adapter
var xMapWmsUrl = 'https://api-test.cloud.ptvgroup.com/WMS/WMS';
var xMapAttribution = '<a target="_blank" href="https://www.ptvgroup.com">PTV<\/a>, TOMTOM';
var layerOptions = {
maxZoom: 19, minZoom: 0, opacity: 1.0, noWrap: false,
layers: 'xmap-gravelpit-bg', format: 'image/png', transparent: false, attribution: xMapAttribution
};
// center and radius for our geographic circle
var center = new L.LatLng(49.01405, 8.4044);
var radius = 435.0;
// init map2 - mercator
var map1 = new L.Map('map1', {
crs:L.CRS.EPSG3857
}).setView(center, 14);
new L.TileLayer.WMS(xMapWmsUrl, layerOptions).addTo(map1);
// init map2 - equirectangular
var map2 = new L.Map('map2', {
crs:L.CRS.EPSG4326
}).setView(center, 14);
new L.TileLayer.WMS(xMapWmsUrl, layerOptions).addTo(map2);
// add the geographic radius
var c1 = L.circle( center, radius).addTo(map1);
var c2 = L.circle( center, radius).addTo(map2);
// sync the map viewports
map1.sync(map2);
map2.sync(map1);
// setting the radius slider
var ri = document.getElementById("radiusInput");
var rt = document.getElementById("radiusText");
ri.value = Math.sqrt(radius);
rt.innerHTML = radius + " m";
ri['onchange'] = ri['oninput'] = function() {
radius = ri.value * ri.value;
rt.innerHTML = radius + " m";
c1.setRadius(radius);
c2.setRadius(radius);
};
// reset center on click
map1.on('click', function(e) {centerCircles(e.latlng);});
map2.on('click', function(e) {centerCircles(e.latlng);});
function centerCircles(latlng)
{
c1.setLatLng(latlng);
c2.setLatLng(latlng);
}
</script>
</body>
</html>
/*
* Extends L.Map to synchronize the interaction on one map to one or more other maps.
* oliverheilig: had to modify it heavily to work for maps with different projections.
*/
(function () {
'use strict';
L.Map = L.Map.extend({
sync: function (map, options) {
this._initSync();
options = options || {};
// prevent double-syncing the map:
var present = false;
this._syncMaps.forEach(function (other) {
if (map === other) {
present = true;
}
});
if (!present) {
this._syncMaps.push(map);
}
if (!options.noInitialSync) {
map.setView(this.getCenter(), this.getZoom(), {
animate: false,
reset: true
});
}
return this;
},
// unsync maps from each other
unsync: function (map) {
var self = this;
if (this._syncMaps) {
this._syncMaps.forEach(function (synced, id) {
if (map === synced) {
self._syncMaps.splice(id, 1);
}
});
}
return this;
},
// Checks if the maps is synced with anything
isSynced: function () {
return (this.hasOwnProperty('_syncMaps') && Object.keys(this._syncMaps).length > 0);
},
// overload methods on originalMap to replay on _syncMaps;
_initSync: function () {
if (this._syncMaps) {
return;
}
var originalMap = this;
this._syncMaps = [];
L.extend(originalMap, {
setView: function (center, zoom, options, sync) {
if (!sync) {
originalMap._syncMaps.forEach(function (toSync) {
toSync.setView(center, zoom, options, true);
});
}
return L.Map.prototype.setView.call(this, center, zoom, options);
}
});
originalMap.on('zoomend', function () {
originalMap._syncMaps.forEach(function (toSync) {
toSync.setView(originalMap.getCenter(), originalMap.getZoom(), {
animate: false,
reset: false
}, true);
});
}, this);
originalMap.dragging._draggable._updatePosition = function () {
L.Draggable.prototype._updatePosition.call(this);
var self = this;
originalMap._syncMaps.forEach(function (toSync) {
toSync.setView(originalMap.getCenter(), originalMap.getZoom(), {
animate: false,
reset: false
}, true);
});
};
}
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment