D3 polygon projection is wrong - d3.js

I have a working world projection using D3 geoOrthographic and topoJSON. It rotates and everything.
I wanted to place a hexagon shape at a coordinate and projected according to its place on the globe, which works... except my shape is really weird. I get a 5-sided shape, like one of the points is just missing, that rotates properly with the globe.
And then also a circle around the edge of the globe that does not rotate.
I have a function that throws out hexagon coordinates, I've tried with several scales and offsets, always the exact same behavior.
let hex = svgOrbit.append("path")
.datum({"type":"GeometryCollection","geometries":[{"type":"Polygon","coordinates":[[[6.732,6],[5,7],[3.268,6],[3.268,4],[5,3],[6.732,4]]]}]})
.attr("d", myGeoOrthographicProjection);
The circle looks like that no matter how I rotate, the trying-to-hexagon orients as desired sans that missing point.
The path does show a d attr with these two separate polygons.
I just plain don't understand what's happening here. There aren't even any weird numbers, like a zero or NaN or anything in the coordinates. The entire planet projects correctly, but a hexagon throws it for a loop?

The outer circle indicates that you have an inverted polygon: you are drawing a feature of the world minus the intended feature. As d3 uses spherical math in calculating projections, winding order matters, as opposed to most geographic tools which treat spherical coordinates as Cartesian (even when projecting). The first map below in red shows this by applying a fill.
The missing point is a bit odd, normally D3 won't render invalid geojson syntax and it won't throw an error or warning in not rendering anything. The issue here is that the last point in your coordinate array should be the first coordinate. I've forgotten where in the spec this is, and haven't looked as to why D3 renders it like this at all. When attempting to take a look at your geojson at geojson.io I noticed it didn't render at all with the missing end point.
I've rewound the coordinates (lazily with .reverse()) and added the extra point in the map on the right.
let hex = {"type":"GeometryCollection","geometries":[{"type":"Polygon","coordinates":[[[6.732,6],[5,7],[3.268,6],[3.268,4],[5,3],[6.732,4]]]}]};
let hex2 = {"type":"GeometryCollection","geometries":[{"type":"Polygon","coordinates":[[[6.732,6],[5,7],[3.268,6],[3.268,4],[5,3],[6.732,4],[6.732,6]].reverse()]}]};
let projection = d3.geoOrthographic().scale(125).translate([125,125]);
let path = d3.geoPath(projection);
let svg = d3.select("body")
.append("svg")
.attr("width", 500)
.attr("height", 250);
svg
.append("path")
.datum(hex)
.attr("d", path)
.attr("fill", "crimson");
svg.append("g")
.attr("transform","translate(250,0)")
.append("path")
.datum(hex2)
.attr("d", path)
.attr("fill","steelblue");
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

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

Map not rendering, but data is correct

I am a newbie, attempting to copy someone else's choropleth code and use my data.
My data matches their data structure and I can get the correct data to console.log() and my tooltips display correct data.
But my map is just a big block of colour with no paths.
Here is a codepen, but this is the code that renders the map features:
var path = d3.geo.path();
svg.append("g")
.attr("class", "county")
.selectAll("path")
.data(topojson.feature(us, us.objects.counties).features)
.enter().append("path")
.attr("d", path)
.style("fill", function (d) {
return color(pairFipsWithId[d.id]);
})
The result looks like this:
⚠️This question and answer both use d3v3 - d3v4+ geo paths are slightly different, see this question/answer for d3v4+.
If you are making a D3 choropleth, you should be looking for a recent version of D3 - d3v3 is a bit dated now. However, as v3 has some differences, this isn't quite a duplicate of this question, but the problem is the same:
The example map you use has unprojected geographic data
Your geographic data is projected.
In other words, your example's geographic data uses a 3 dimensional coordinate system measured in degrees latitude and longitude, while your geographic data uses a coordinate system where the units are pixels. But, you don't specify this change and D3 does not know to correct for it.
In D3v3, the default projection for a d3.geo.path() is d3.geo.albersUsa(), all D3 projections assume your data is recorded in latitudes and longitudes, otherwise we would need to specify an additional parameter to projections to indicate what coordinate space the input data uses.
A sign that your data is already projected can come from mapshaper.org, if you drag your topojson into the window your map is upside down. Why? Geographic coordinates increase as one moves north (typically up) while pixel coordinates increase as one moves down. This is your data in mapshaper.org:
We cannot "unproject" the data as we don't know what projection was used to create it. But we could assign a null projection to the geo.path:
var path = d3.geo.path().projection(null)
The null projection simply takes each coordinate in the data and converts it to a pixel coordinate with no transform - input values are treated as pixel values. As this map was designed for a web map it doesn't need any scaling or centering (depending on the size of your SVG).
Here's an updated codepen.
In d3v4+, the default projection of a geoPath is a null projection off the bat, it also comes with selection.raise() and selection.lower() methods that can simplify your code a bit, as well as better options for fitting any dataset to a SVG/Canvas of a given size. Here's a d3v4 codePen with a few modifications, d3v5 requires a bit more in updating d3.json

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, transitioning a scatterplot into a bar graph

Looking at the d3 showreel (http://bl.ocks.org/mbostock/1256572), the transitions are very smooth and beautiful. I'm trying to emulate this style to transition between a scatterplot and a bar graph.
I can do something like this:
svg.selectAll(".dot")
.transition()
.duration(duration)
.delay(delay)
.remove();
plot_bar_graph();
which removes the scatterplot and then plots the bar graph, but I would really like some sort of animation where the circles "turn into" rectangle elements, but am not sure how to do this.
My current idea is just to draw the circles in the scatterplot as rectangles, but give them an rx and ry attribute so they look like circles, then change them to rectangles.
Something like this:
d3.select(".bar")
.transition()
.duration(duration)
.delay(delay)
.attr("rx",0)
.attr("ry",0);
but this seems rather unwieldy.
That's the only way you can do it, short of writing your own tween and drawing with path elements (which is much harder). It's harder to set up, because rectangles draw from the top-left and not the center like circles, but it will work.
Tweens are hard but not impossible:
https://github.com/mbostock/d3/wiki/Transitions#attrTween
If you're interested in getting into tweening, you can see an extreme example here with Superformula (which includes rectangle-like and circle-like forms):
http://bl.ocks.org/mbostock/1020902

Resources