d3 mercator, geo, and path methods - d3.js

I am following this tutorial on d3:
In it, I see this code:
var projection = d3.geo.mercator()
.scale(500)
.translate([width / 2, height / 2]);
And likewise the path generator:
var path = d3.geo.path()
.projection(projection);
What are these methods doing exactly? Is there good documentation on these d3 methods? On on the d3 docs. it says:
"# d3.geo.mercator()
"The spherical Mercator projection is commonly used by tiled mapping
libraries (such as OpenLayers and Leaflet). For an example displaying
raster tiles with the Mercator projection, see the d3.geo.tile plugin.
It is conformal; however, it introduces severe area distortion at
world scale and thus is not recommended for choropleths."
So is d3.geo.mercator just a kind of map design?

The map projection just converts points from one system (sphere/ellipsoid based latitude/longitudes) into another system (2d Cartesian plane with x/y values).
Mercator is one (very common) way of doing that. For more about map projections, take a look at http://en.wikipedia.org/wiki/Map_projection.
With the code you've posted, it sets up a Mercator projection, that when passed a [long, lat] point it will return an [x, y] point that corresponds to the x and y position that can be drawn on an svg or canvas. In this case it will be centered at [width/2, height/2].
The path generator is some d3 "magic" that converts a list of points into an svg path string. svg has it's own "language" for paths, you can find out more at http://www.w3.org/TR/SVG/paths.html, but that does get pretty technical.
Since svg talks in pixel co-ordinates, and most geo data is referenced to lat/long, the projection function allows you to easily convert from one to the other and back again.
Since drawing a path on a map is a very common activity, d3 includes the path generator that is projection "aware" and will automatically apply the projection specified to whatever data you pass to the path generator, which will result in the pixel co-ordinates getting returned, which will then be converted into the path "language" mentioned above, which can then be displayed on an svg element.

Related

Let's make a TopoJSON map and view it with D3.js

I made the following TopoJSON file: https://gofile.io/d/CKBGhF
I want to view it in my browser with a basic D3.js script. From https://bost.ocks.org/mike/map/ I found a small tutorial about the script. But because my map is made from a vector converted into TopoJSON it has no real coordinates. With QGIS I saved it first as a GeoJSON and with mapshaper I saved it as TopoJSON.
How should I view my map? Are the coordinates or scaling completely wrong? Or is my TopoJSON not good?
My HTML/Javascript code (I only changed the filename):
<!DOCTYPE html>
<meta charset="utf-8">
<style>
/* CSS goes here. */
</style>
<body>
<script src="//d3js.org/d3.v3.min.js" charset="utf-8"></script></script>
<script src="//d3js.org/topojson.v1.min.js"></script>
<script>
var width = 960,
height = 1160;
var projection = d3.geo.mercator()
.scale(500)
.translate([width / 2, height / 2]);
var path = d3.geo.path()
.projection(projection);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
d3.json("topojson_mapshaper.json", function(error, uk) {
svg.append("path")
.datum(topojson.feature(uk, uk.objects.subunits))
.attr("d", path);
});
</script>
In my response I'm upgrading all code to d3v5 due to the inclusion of new features since d3v4 that aid with drawing this data (such as d3.geoIdentity, projection.fitSize). Since D3 v4 there are some changes to D3 namespace (d3.geo.path and d3.geo.projectionName are now d3.geoPath and d3.geoProjectionName). And in D3v5, d3.json now returns promise.
There are a few things at play here.
Topojson.feature
First, D3 only draws geojson objects with d3.geoPath, D3 does not draw topojson directly. So your data, while stored as topojson, is converted to geojson here:
topojson.feature(uk, uk.objects.subunits)
But, your topojson data uk, doesn't have a subunits property that contains features. You instead have a sub unit property called test:
...463908,4.10162]},"objects":{"test":{"type":"GeometryCollection"...
Normally platforms such as mapshaper apply the filename of the source file as the sub property name, so I'm guessing your source file, exported from QGIS, was test.geojson or something to that effect.
If we log:
console.log(topojson.feature(uk, uk.objects.test))
We see valid geojson. Now that we have geojson, we can draw it.
Coordinates
As for coordinate systems, topojson preserves the original coordinate system by default. When converting back to geojson, your coordinates will be the same as originally. So this statement "it has no real coordinates" isn't true unless your source data has no real coordinates.
However, you can project or reproject points from the command line, or with mapshaper, while producing topojson. It appears you have applied a projection on your points because when I convert your topojson to geojson, I see coordinates that look like pixel values (units that are unlikely to be the original coordinates exported from QGIS).
If using projected coordinates (Cartesian points, not lat long pairs, whether units are meters or pixels), we cannot use a D3 geoProjection: these take 3D points and project them to a plane.
Since your values look like pixel values, we can pass your data directly to a null projection:
var path = d3.geoPath()
Or, more explicitly:
var path = d3.geoPath(null);
var path = d3.geoPath().projection(null);
In D3v3 and earlier, this needs to be explicitly set
This applies no transform to the coordinates in the geojson. It treats each geojson coordinate as a pixel coordinate and draws your features accordingly (example)
However, that option isn't useful if the projected extent of our features doesn't match the SVG/Canvas extent. Instead, we could use a geoTransform or geoIdentity to apply an appropriate transform.
The geoIdentity option is the easiest as it provides the convenient fitSize method that lets us automagically size the geojson to our SVG/Canvas dimensions (fitExtent allows specification of a margin, while fitSize assumes no margin). All of these options can be passed to d3.geoPath as a projection (example, full screen).
The last example should show you how to draw the data. You mostly have line strings, which will make filling features difficult, unless you only wish to show borders.
Further Reading
Based on previous experience, there are a few related questions that come to mind:
Preprojected Geometry vs Projecting on the Fly
This question addresses possible quesions on pre-projected geometry as it appears you have preprojected your geometry for a screen size. This introduces alignment challenges, the trade off is quicker rendering time.
Scaling a Map to Fit SVG
This question deals with preprojected geometry and fitting the features to the screen. It speaks more in depth to fitSize, fitExtent, and geoTransform.

How to translate MKT expression to D3 options on Albers projection?

This is the standard MKT expression (here also translated to Proj.4 string) of Albers conicEqualArea for official Statistical Grid of Brazil:
PROJCS["Conica_Equivalente_de_Albers_Brasil",
GEOGCS["GCS_SIRGAS2000",
DATUM["D_SIRGAS2000",
SPHEROID["Geodetic_Reference_System_of_1980",6378137,298.2572221009113]],
PRIMEM["Greenwich",0],
UNIT["Degree",0.017453292519943295]],
PROJECTION["Albers"],
PARAMETER["standard_parallel_1",-2],
PARAMETER["standard_parallel_2",-22],
PARAMETER["latitude_of_origin",-12],
PARAMETER["central_meridian",-54],
PARAMETER["false_easting",5000000],
PARAMETER["false_northing",10000000],
UNIT["Meter",1]]
The DATUM is the WGS 84 ("SIRGAS2000" is a alias for it).
How to translate all details to the D3.js v5 parametrization?
I try the obvious, as center and parallels, but it was not sufficient
var projection = d3.geoConicEqualArea()
.parallels([-2,-22]) // IS IT?
.scale(815)
//.rotate([??,??]) // HERE THE PROBLEM...
.center([-54, -12]) // IS IT?
PS: where the D3 documentation for it? The D3 source-code of geoConicEqualArea() have no clues.
The parts that translate to a d3 Albers projection are as follows:
PROJECTION["Albers"],
PARAMETER["standard_parallel_1",-2],
PARAMETER["standard_parallel_2",-22],
PARAMETER["latitude_of_origin",-12],
PARAMETER["central_meridian",-54],
You have the parallels, now you need to rotate. Also note, for any D3 projection, the rotation is applied to the centering coordinates. Generally, you'll want to rotate on the x and center on the y:
d3.geoAlbers()
.parallels([-2,-22])
.center([0,-12])
.rotate([54,0])
.translate([width/2,height/2])
.scale(k)
I've rotated in the opposite direction along the x axis (rotated the earth under me so that I'm overtop of the central meridian, hence my rotation by -x). I've then centered on the y. Lastly I translate so that the intersection of the central longitude and meridian is centered in the map and apply a scale value that is appropriate.
If I want to center on a different area but keep the projection the same, I can modify projection.center(), but keep in mind that the coordinates provided here are relative to the rotation. I can also use projection.fitSize() or projection.fitExtent(), both of which set 'translate' and 'scale' values for the projection. None of center/scale/translate change the distortion in the D3 projection.
Of course this isn't a true replication of your projection as the coordinate space units are pixels, you will remain unable to measure distances in meters directly without some extra work.
See also

Fit D3 map in SVG

In this jsfiddle I have a D3 map that I took from here, but I'm trying to fit it in an svg that is half the original size. For that, I changed:
var width = 480;
var height = 300;
.....
var path = d3.geoPath(d3.geoIdentity().translate([width/2, height/2]).scale(height*.5));
But it's not working. How to make the map fit the svg?
D3's geoIdentity exposes almost all the standard methods of a d3 projection (off the top of my head, only rotation is not possible as the identity assumes cartesian data). Most importantly here, it exposes the fitSize and fitExtent methods. These methods set translate and scale based on the coordinate extent of displayed geojson data and the pixel extent of the svg/canvas.
To scale your features with a geo identity you can use:
d3.geoIdentity().fitSize([width,height],geojsonObject)
Note that an array of geojson features won't work, but a geojson feature collection or any individual feature/geometry object works too. The width and height are of the svg/canvas.
If you want to apply a margin, you can use:
d3.geoIdentity().fitExtent([[margin,margin],[width-margin,height-margin]],geojsonObject)
The margin doesn't need to be uniform, the format is [[left,top],[right,bottom]],geojsonObject
If using fitSize or fitExtent there is no need to set center, translate or scale manually and setting these afterwards will recenter or rescale the map.

D3 not rendering map data in JSON file

I am working with D3 JS libraries to display maps. Loading the U.S. states map is working fine, but if I attempt to load county maps for individual states, it isn't working properly. The file I am using for all states is found at https://raw.githubusercontent.com/d3/d3.github.com/master/us-10m.v1.json. The state map I am currently using is https://raw.githubusercontent.com/deldersveld/topojson/master/countries/us-states/MI-26-michigan-counties.json, though other states have the same behavior.
Using the Chrome developer tools, I noticed that the states (the one that works) renders with one "path" element with one "d" element underneath it for the entire map:
<path d="M558.8236946374037,348.30360060633L559.3534721355569,348.2802219377176L567.3201262115587,347.9178525742255L571.2684679430778,347.7775805625511L572.0381446856777,347.7542018939387L577.0160540079474…
The counties render one "g" element with multiple "path" elements:
<g class="counties"><path d="M-86.2371554117112,44.517643343110294L-85.81834245759008,44.51265840274941L-85.82187674412275,44.16371257748768L-86.04276965241448,44.16620504766812L-86.38912973261591,44.178667398570326L-86.35201972402291,44.22851680217914L-86.26896399050521,44.344416665569646L-86.25305970110821,44.40049724462957L-86.24952541457554,44.48274876058412Z"></path><path d="M-84.13072063824123,42.42521462663013L-83.66596195919543,42.43144580208123L-83.55286479015007…
The D3 code I use to render them is the same, just pointing to the different data sources. This one points to the (working) states code:
d3.json("us-10m.v1.json", function (error, us) {
svg.append("g")
.attr("class", "counties")
.selectAll("path")
.data(topojson.feature(us, us.objects.states).features)
.enter()
.append("path")
.attr("d", path).append("svg:title");
The files themselves seem to be identically formatted, with (of course) different data, so I am at a loss as to why the large differences in the way the svg code is generated. The two differences I see are that the U.S. states file has a "bbox" element that the state files lack, and the Michigan file has "properties" elements inside of the geometries collection.
You problem is the opposite of this one. The US topojson is already projected (to a 960x600 pixel area) - the underlying coordinates in the topojson represent pixel coordinates. Maps that use pre-projected geographic features don't use d3 geographic projections. If drawing with a path generator, this looks like:
var path = d3.geoPath(); // or
var path = d3.geoPath().projection(null);
However, the Michigan data contains latitude/longitude pairs. If we convert your Michigan topojson to geojson (so we have human readable coordinates), we get values such as: [-87.86292721673836,45.35386708864823]. If we use a null projection and treat these as pixel values, the features will be to the left of the viewable SVG as the x values are negative. So we need a projection for our path:
var path = d3.geoPath().projection(someProjection);
I'm not sure what projection you would want - the pre-projected US data uses an Albers projection, so if we wanted to replicate this and scale and center Michigan, we could use:
var projection = d3.geoAlbers()
.fitSize([width,height],geojsonObject); // width/height of SVG/canvas
var path = d3.geoPath().projection(projection);
With an Albers it is tailored to the US, if using fitSize or fitExtent on other parts of the world, results may not be as expected, projection rotation must be set to account for different locations.
There are other ways that can be used to center/scale the map but this is covered in many questions/answers. But, to display geographic data, you need to project it from spherical coordinates to planar, and any d3 geoProjection can do this.

d3 mercator geo bounds to pixel bounds

I was wondering whether there is a D3-API call that translates a d3.geo.bounds() array to a path.bounds pixel array given a width and height of the map?
I was unable to find such a call in the API documentation. I know that there are projections that should to the trick, but I failed in getting to the actual pixel bound from a geo bound.
Also, is there an API implementation of computing the lat/long centroid of a series of features?
I appreciate any help!
Example
Assume the user wants to zoom Germany and Poland to fullscreen. My computed lat-long boundary is:
lat:47.27148371456806 long: 5.850585058505857
(1321.6041604160416,246.58117844670664)
lat:55.06843452896922 long: 24.140414041404142
(1451.6651665166517,157.87336991900156)
Checking with google-maps, the lat-long coordinates are correct. However, the computed pixel values (using the approach from https://stackoverflow.com/a/14457180/974815) seem weird to me. The screen resolution is 2560x1258.
I want to translate the lat-long boundaries to pixel boundaries in order to compute the offset for the mercator projection:
var offset = [
this.width - (bounds[0][0] + bounds[1][0])/2,
this.height - (bounds[0][1] + bounds[1][1])/2
];
return d3.geo.path().projection(
d3.geo.mercator()
.center(this.__computeCentroid(features))
.scale(scale)
.translate(offset)
);
Best,
Sebastian
The geographic coordinates of features are translated into pixel (projection) coordinates using one of the projections available. You can either use the projection to translate the geographic bounds into projected bounds, or use path.bounds() directly to get that information. That is, instead of passing the feature to d3.geo.bounds(), you pass it to the .bounds() method of the d3.geo.path() that uses your projection.
There is no function to compute the centroid of a series of features. You can either combine the features into one (preferably using a GIS program) and get the centroid of that, or get the centroids of all the features and then get the centroid of the centroids by averaging the coordinates.

Resources