d3's fitSize distorted by New Zealand geojson - d3.js

I'm trying to expand this example to include Asia plus Oceania (specifically Australia and New Zealand): https://bl.ocks.org/aholachek/700f930820f2704a957c070173327789
So I replaced the json data in the example with Asia + Oceania from https://geojson-maps.ash.ms/ and trimmed out all the tiny pacific islands. When I do this I see the entire map skewed to the right (as below). Whereas by simply removing New Zealand, the map will scale appropriately to the entire canvas.
The relevant area of the code seems to be...
const projection = d3.geoMercator()
// d3's 'fitSize' magically sizes and positions the map for you
.fitSize([width, height], data);
But I can't find any documentation explaining why fitSize might be getting tripped up on New Zealand (and I need to have New Zealand included in the final visualisation).

I'm adding an answer as it might not be possible to remove features to align the map as intended - say for a map of the Bering Sea or Pacific Rim. Or, you might want to include those tiny NZ islands.
By default, most D3 projections are centered at 0°N,0°E, with the anti-meridian at 180°W/E. This means any feature split by the anti-meridian will likely end up on two sides of the map. FitSize/fitExtent will then scale and translate the maps so that both sides of the map are visible, likely with a large empty space in between. As you noted, your features bridge the anti-meridian, so fitSize/fitExtent don't work as needed.
Both projection.fitSize and projection.fitExtent are convenience methods for setting the projection projection.scale and projection.translate. Both scale and translate modify projected coordinates - all they can do is pan and scale the projected data. Consequently fitSize and fitExtent, nor translate or center, modify the antimeridian.
There are two other useful projection methods: projection.center() and projection.rotate(). Projection.center translates the map in geographic coordinates. The geographic coordinate specified by .center() and the pixel coordinate specified by .translate() will align in the map. But, projection.rotate() will apply a rotation to the map prior to projection, this will shift the anti-meridian.
projection.rotate takes an array with two (or three values), the first represents the longitude, the second the latitude. Generally you'll want to leave latitude at 0 - this will change the appearance of the Mercator projection otherwise. Changing the longitude will not alter a Mercator (longitude and projected x values have a linear relationship). In your case, rotating the world 180 degrees would make the prime meridian the anti-meridian, and this would ensure the projection's anti-meridian doesn't intersect your features, which means that fitSize and fitExtent will work as needed and your features will not be on split across two far sides of the map:
var width = 480;
var height = 480;
// feature crossing anti-meridian (180W/E), ensure proper winding direction.
var data = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
150.8203125,
-28.92163128242129
],
[
-121.0625,
-30.145127183376115
],
[
-121.765625,
-60.586967342258674
],
[
145.8984375,
-57.70414723434192
],
[
150.8203125,
-28.92163128242129
]
]
]
}
}
]
}
var svg = d3.select("svg");
var g = svg.append("g");
var projection = d3.geoMercator().rotate([180,0]);
var path = d3.geoPath(projection);
d3.json("https://d3js.org/world-110m.v1.json").then(function(world) {
// Draw the world.
let countries = topojson.feature(world, world.objects.countries).features;
projection.fitSize([width,height],data)
let features = g.selectAll("path")
.data(countries)
.enter()
.append("path")
.attr("d", path)
.style("stroke-width",1);
g.append("path")
.datum(data)
.attr("d",path)
.attr("fill","none")
.attr("stroke","black")
.attr("stroke-width",1);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<svg width="480" height="480"></svg>

apologies - I figured out the issue - there were tiny islands (invisible on my laptop screen) associated in the NZ geodata that were mapped to left of the image. Closing

Related

d3.js v5 : Unable to get data points to show on map of US

I can successfully get a map of the US to render however, my data points do not. (I understand that d3.js made some significant changes with v5 so please note that similar questions previously asked do not apply)
$(document).ready(function () {
var us = d3.json('https://unpkg.com/us-atlas#1/us/10m.json');
var meteoriteData = d3.json('https://raw.githubusercontent.com/FreeCodeCamp/ProjectReferenceData/master/meteorite-strike-data.json');
var svg = d3.select("svg")
.style("width", "1110px")
.style("height", "714px");
var path = d3.geoPath();
Promise.all([us, meteoriteData]).then(function (values) {
var map = values[0];
console.log("map", map);
var meteoriteData = values[1];
console.log("meteoriteData", meteoriteData);
svg.append("g")
.attr("fill", "#ccc")
.selectAll("path")
.data(topojson.feature(map, map.objects.states).features)
.enter().append("path")
.attr("d", path),
svg.append("path")
.datum(topojson.mesh(map, map.objects.states, (a, b) => a !== b))
.attr("fill", "none")
.attr("stroke", "white")
.attr("stroke-linejoin", "round")
.attr("pointer-events", "none")
.attr("d", path),
svg.selectAll("circle")
.data(meteoriteData)
.enter()
.append("circle")
.attr("class", "circles")
.attr("cx", function (d) { return ([d.geometry.coordinates[0], d.geometry.coordinates[1]])[1]; })
.attr("cy", function (d) { return ([d.geometry.coordinates[0], d.geometry.coordinates[1]])[0]; })
.attr("r", "1px");
});
});
And a working copy can be found here.. https://codepen.io/lady-ace/pen/PooORoy
There's a number of issues here:
Passing .data an array
First, when using
svg.selectAll("circle")
.data(meteoriteData)
selectAll().data() requries you to pass a function or an array. In your case you need to pass it the data array - however, meteoriteData is an object. It is a geojson feature collection with the following structure:
{
"type": "FeatureCollection",
"features": [
/* features */
]
}
All the individual geojson features are in an array inside that object. To get the array of features, in this case features representing meteors, we need to use:
svg.selectAll("circle")
.data(meteoriteData.features)
Now we can create one circle for every feature in the feature collection. If you do this, you can find the circles when inspecting the SVG element, but they won't placed correctly.
Positioning Points
If you make the above change, you won't see circles in the right places. You are not positioning the circles correctly here:
.attr("cx", function (d) { return ([d.geometry.coordinates[0], d.geometry.coordinates[1]])[1]; })
This is the same as:
.attr("cx", function(d) { return d.geometry.coordinates[1]; })
Two issues here: One, geojson is [longitude,latitude], or [x,y] (you are getting the y coordinate here, but setting the x value).
But, the bigger concern is you are not projecting your data. This is a raw coordinate from your geojson:
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-113,
54.21667
]
...
You are taking the longitude and directly turning it into a pixel value. But your geojson uses a 3D coordinate space (unprojected points on a 3D globe) with units measures in degrees. If we simply convert this to pixels, cx = -113, your circle will appear off screen to the left of your SVG.
Using a Projection
You need to project your data, to do so we would define a projection function and use something like:
.attr("cx", function(d) { return projection(d.geometry.coordinates)[0] })
This gets both longitude and latitude and passes them to a projection function and then grabs the returned x value and sets it as the value for cx.
A projection takes an unprojected coordinate in 3 dimensional space (points on a globe or long/lat pairs) with units in degrees, and returns a point in 2 dimensional space with units in pixels.
But, this now brings us to the most difficult part:
What projection should you use?
We need to align our points with the US features that have already been drawn, but you don't define a projection for the US states - if you do not supply a projection to d3.geoPath, it uses a null projection, it takes supplied coordinates and plots them on the SVG as though they are pixel coordinates. No transform takes place. I know that your US features are projected though, because Alaska isn't where it is supposed to be, the coordinate values in the topojson exceed +/- 180 degrees in longitude, +/- 90 in latitude, and the map looks like it is projected with an Albers projection.
If the geoPath is not projecting the data with a d3 projection but the data is drawn as though projected, then the data is pre-projected - this topojson stores already projected points.
We have projected data (the US states) and unprojected data (meteor strikes), mixing them is always a challenge.
The challenge here is creating a projection that replicates the projection function used to create the US states dataset. We can replicate that projection, as it is a problematic file that leads to many questions. How to do so is explained here. But this is more complicated than is should be: mixing projected and unprojected data is burdensome, inflexible, and more complicated than need be.
I would suggest you use unprojected data for both the US and the meteors:
var projection = d3.geoAlbersUsa(); // create an Albers USA projection
var path = d3.geoPath().projection(projection); // set the projection for the path
We can draw the paths the same way, provided we find an unprojected topojson/geojson of the US, and we can place points with:
.attr("cx", function(d) { return projection(d.geometry.coordinates)[0]; })
.attr("cy", function(d) { return projection(d.geometry.coordinates)[1]; })
As for finding an unprojected US topojson, here's one.
And here's a working version using the above approach and projecting all data (I also filter the features to get rid of ones without coordinates, those that the USA Albers might break on, as it is a composite projection).

d3.geo.path rectangle wrapping the wrong way

This simple geojson rectangle is displayed correctly with some geojson viewers, I get a rectangle as expected. But when I do it with d3, the rectangle seems to wrap around.
var polygonData = {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[ -100, -20 ],
[ -100, 20 ],
[ 100, 20 ],
[ 100, -20 ],
[ -100, -20 ]
]
]
},
"properties": {}
};
var width = 1000;
var height = 500;
var projection = d3.geo.equirectangular()
.scale(100)
.translate([width / 2, height / 2])
.rotate([0, 0])
.center([0, 0])
.precision(0);
var path = d3.geo.path()
.projection(projection);
var svg = d3.select("body").append("svg")
.attr({
width: width,
height: height
});
svg.append('path')
.datum(polygonData)
.attr({
d: path,
fill: 'orange',
opacity: 0.5
});
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
<style>
</style>
</head>
<body>
</body>
Here is what I get with a geojson viewer:
But here's what I get with the above d3 code:
Reversing the winding order just fills the opposite shapes, it doesn't fix the problem. I guess its an antimeridian cutting issue. One fix is to add some intermediate points to force the path to not wrap around, but I would need to be able to automate this solution with more complex paths.
Any idea how I can use this geojson with d3 and force it to show it like other geojson viewers, as a simple rectangle across the map?
I don't think, there is anything to blame on D3; in my understanding it's those other GeoJSON viewers which are in error. As a human being living on a more or less planar surface one is easily tricked into believing that a polygon with four corners having carefully chosen coordinates like yours should look like a rectangle. On a sufficiently small scale and given a suitable projection this even holds true for spherical geometry. But as your points are almost half the globe apart, this won't be the case.
To shed some light on this, I used an orthographic projection to display some geographic features along with your polygon:
From this view it becomes apparent that the line along the meridian is the first edge connecting points [-100,-20] and [-100,20]. From that point [-100,20] somewhere in Mexico to the northwest is the great arc, i.e. the shortest connection, to the next point [100,20] half way around the globe. The path is then similarly closed around the southern hemisphere. Thus, the outline of the polygon is the shortest path on the globe's surface connecting all of its points in the given order.
Although your polygon is determined by its coordinates, its look will depend on the projection in use. Here is another view of the same polygon using a mercator projection:

How does D3's projection function work for paths and points?

My understanding of D3's projection functions are that they do the same thing as the scale functions. They map GPS coordinates to pixel coordinates. This is the projection I'm currently using. (I don't understand all the variables in detail but I fiddled around with them until the map showed up)
var projection = d3.geo.albers()
.center([-122.436269,37.798107])
.rotate([0, 0, 0])
.parallels([36, 38])
.scale(300000);
This draws the map fine:
.attr("d", d3.geo.path().projection(projection))
When I try to plot points though the numbers are crazy.
.attr("cx",function(d) {
return projection([d._lon,d._lat])[0];
})
.attr("cy",function(d) {
return projection([d._lon,d._lat])[1];
});
How do I properly do this?
Here are some examples of the points I'm getting through the projection function:
[5175.3799972560955, 1808.5108650794136]
[5158.315547249338, 1823.564395627589]
[5143.958532762888, 1831.9879789081751]
On a 1280x800 screen these are way off. Even if I scale them by dividing by 100, they'll still mostly just stack on top of each other. I have a 700*700 svg positioned using twitter bootstrap. Not sure how those are taken into account. I just assumed that if the map if fine, then the same projection should work for the points.

How to add a rectangle to specified axis in D3

I have a zoomable area plot done in D3, which works well. Now I am trying to add a rectangle to the specified location along x-axis in the middle of the plot. However, I can't seem to figure out how to do that. "rect" element is specified using absolute (x,y) of the plot and so when using zooms it stays in the same position.
So I was wondering if there is a way to tie "rect" to the axis when plotting, so that it benefits from all the zoom and translate behaviour or do I need to manually edit the x,y,width and length of the rectangle according to translation as well as figuring out where the corresponding x and y coordinates are on the graph? I am trying to use "rect" because it seems the most flexible element to use.
Thanks
Alex
I'm not sure how you are doing the zooming, but I am guessing you are changing the parameters of the scales you use with your axis? You should be able to use the same scales to place your rectangle.
If you are starting with plot coordinates then maybe using the invert function on the scale will help (available at least for quantitive scales), e.g. https://github.com/mbostock/d3/wiki/Quantitative-Scales#wiki-linear_invert
You should be able to take initial plot coordinates and invert them to determine data coordinates that can then move with changes in the scale.
If the scale is linear you can probably invert the length and width too, but you will have to compute offsets if your domain does not include 0. Easiest is to compute the rectangle's end points, something like:
var dataX0 = xScale.invert(rect.x);
var dataX1 = xScale.invert(rect.x + rect.width);
var dataWidth = dataX1 - dataX0;
If you have the data in axes coordinates already you should be able to do something like:
var rectData = [{x: 'April 1, 1999', y: 10000, width: 100, height:100}];
svg.selectAll('rect.boxy')
.data(rectData)
.enter().append('rect').classed('boxy', true)
.style('fill','black');
svg.selectAll('rect.boxy')
.attr('x', function(d) { return x(new Date(d.x));} )
.attr('y', function(d) { return y(d.y);})
.attr('width', function(d) { return d.width;} )
.attr('height', function(d) { return d.height;} );
Based on the example you shared where x and y (as functions) are the scales the axes are based on.

d3js scale, transform and translate

I've created nycMap, a project that uses angularJS (MVC), yeoman (build), d3 (mapping) and geoJSON (geo data).
Everything works very nicely, but I did have to spend quite some time getting the right scale and translation. I was wondering how I can automatically figure out at what scale the map will show its best and what x and y values go into the translation?
'use strict';
japanAndCo2App.controller('MainCtrl', function($scope) {
function makeJapanAll(){
var path, vis, xy;
xy = d3.geo.mercator().scale(16000).translate([-5600,2200]);
path = d3.geo.path().projection(xy);
vis = d3.select("#japanAll").append("svg:svg").attr("width", 1024).attr("height", 700);
d3.json("data/JPN_geo4.json", function(json) {
return vis.append("svg:g")
.attr("class", "tracts")
.selectAll("path")
.data(json.features).enter()
.append("svg:path")
.attr("d", path)
.attr("fill",function(d,i){ return d.properties.color || "transparent"});
});
}
makeJapanAll();
});
(If you are interested in the code, it's all on github. The code for the map is in scripts/controllers/main.js which is the same as shown above.)
I've had the same problems. But it is very easy to do when you have a bounding box, which can be determined from the GeoJSON (like meetamit said), or while creating the GeoJson. And the width of the wanted SVG.
I'll start with the variables lattop, lonleft, lonright, width and height for the bounding box of the geojson and the dimensions of the image. I haven't yet occupied myself with calculating a good height from the difference in latutude. So the height is just estimated to be big enough to fit the image. The rest should be clear from the code:
var xym = d3.geo.mercator();
// Coordinates of Flanders
var lattop = 51.6;
var lonleft = 2.4;
var lonright = 7.7;
var width = 1500;
var height =1000;
// make the scale so that the difference of longitude is
// exactly the width of the image
var scale = 360*width/(lonright-lonleft);
xym.scale(scale);
// translate the origin of the map to [0,0] as a start,
// not to the now meaningless default of [480,250]
xym.translate([0,0]);
// check where your top left coordinate is projected
var trans = xym([lonleft,lattop]);
// translate your map in the negative direction of that result
xym.translate([-1*trans[0],-1*trans[1]]);
var path = d3.geo.path().projection(xym);
var svg = d3.select("body").append("svg").attr("width",width).attr("height",height);
Note, if you go over the date line (180 degrees), you will have to take the overflow into account.
Given this:
xy = d3.geo.mercator().scale(someScale).translate([0, 0]);
someScale is the pixel width of the entire world when projected using the mercator projection. So, if your json data had outlines for the whole world – spanning from lat/lng -180,90 to latLng 180,-90 – and if someScale was 1024, then the world would be drawn such that it exactly fits within a 1024x1024-pixel square. That's what you see on in this Google Maps view (well... sort of... not quite... read on...).
That's not enough though. When the world is drawn at 1024px, without any translation, lat/lng 0,0 (i.e. the "middle" of the world) will sit at the 0,0 pixel of the projected map (i.e. the top left). Under these conditions, the whole northern hemisphere and western hemisphere have negative x or y values, and therefore fall outside the drawn region. Also, under these conditions, the bottom right of the world (i.e. lat/lng -90, 180) would sit at the exact middle of the 1024x1024 square (i.e. at pixel 512,512).
So, in order to center the world in the square described here, you need to translate the map by half its width in the X and Y directions. I.e. you need
xy = d3.geo.mercator().scale(1024).translate([512, 512]);
That'll give you exactly the Google Map view I linked to.
If your json data only has part of the world (like, nyc or NY state) drawing it with this xy projection will render the outlines in the correct geographic position relative to the entire 1024x1024 world-spanning region. So it would appear rather small, with lots of whitespace.
The challenge is how to scale and translate the projection such that the area in question fills up the 1024x1024 square. And... so far I haven't answered this question, but I hope that this explanation points you in the right direction towards figuring out this math. I'll also try to continue the answer later, when I have more time. :/
There's an example here that gets the bounds of countries from geojson and then scales and translates the map to that country. The code is a bit ugly; there're however efforts to make this easier in the future (see this and this issue).

Resources