I am following Bostock's map tutorial with my own data. I understand that a projection is a function that takes a 3D lat/long coordinate and returns a 2D x/y coordinate. I also understand that d3.geo.path handles the messy business of taking GeoJSON and converting it to SVG. I get how you pass a projection function as a parameter to d3.geo.path to tell it how to project 3D lat/long described by GeoJSON into 2D SVG. So far so good.
However, I am having a lot of trouble "zooming in" on my map with my projection. The map is centered at the proper latitude and longitude using the projection's center property. I am using projection's translate property to put the point at the center of the SVG element. However, no matter how number I use for scale, the map never gets larger or smaller. I have checked bonehead mistakes: I am saving the changes properly and they are getting served up properly. The error has to with how I am using scale.
How do I zoom in?
window.onload = function() {
//Width and height
var width = 960,
height = 1160;
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
//setting scale to numbers as high as 30000 and as low as 3 does not change my map
var projection = d3.geo.transverseMercator().translate([width / 2, height / 2]).scale(3).center(-90.088, 29.957);
var path = d3.geo.path(projection);
d3.json("orleans.json", function(json) {
svg.selectAll("path")
.data(json.features)
.enter()
.append("path")
.attr("d", path);
});
};
d3.geo.path() doesn't take any arguments. To set the projection for a path, use the following code:
var path = d3.geo.path().projection(projection);
Another option to consider is using the "preserveAspectRatio" and "viewBox" properties in the svg tag (If your map uses one):
svg preserveAspectRatio="xMidYMid meet" viewBox="0 0 500 200" id="mapsvg"
By raising or lowering the viewbox numbers you can shrink or enlarge the map.
Related
I am trying to visualize russians regions. I got data from here, validate here and all was well - picture.
But when I try to draw it, I receive only one big black rectangle.
var width = 700, height = 400;
var svg = d3.select(".graph").append("svg")
.attr("viewBox", "0 0 " + (width) + " " + (height))
.style("max-width", "700px")
.style("margin", "10px auto");
d3.json("83.json", function (error, mapData) {
var features = mapData.features;
var path = d3.geoPath().projection(d3.geoMercator());
svg.append("g")
.attr("class", "region")
.selectAll("path")
.data(features)
.enter()
.append("path")
.attr("d", path)
});
Example - http://ustnv.ru/d3/index.html
Geojson file - http://ustnv.ru/d3/83.json
The issue is the winding order of the coordinates (see this block). Most tools/utilities/libraries/validators don't really care about winding order because they treat geoJSON as containing Cartesian coordinates. Not so with D3 - D3 uses ellipsoidal math - benefits of this is include being able to cross the antimeridian easily and being able to select an inverted polygon.
The consequence of using ellipsoidal coordinates is the wrong winding order will create a feature of everything on the planet that is not your target (inverted polygon). Your polygons actually contain a combination of both winding orders. You can see this by inspecting the svg paths:
Here one path appears to be accurately drawn, while another path on top of it covers the entire planet - except for the portion it is supposed to (the space it is supposed to occupy covered by other paths that cover the whole world).
This can be simple to fix - you just need to reorder the coordinates - but as you have features that contain both windings in the same collection, it'll be easier to use a library such as turf.js to create a new array of properly wound features:
var fixed = features.map(function(feature) {
return turf.rewind(feature,{reverse:true});
})
Note the reverse winding order - through an odd quirk, D3, which is probably the most widespread platform where winding order matters actually doesn't follow the geoJSON spec (RFC 7946) on winding order, it uses the opposite winding order, see this comment by Mike Bostock:
I’m disappointed that RFC 7946 standardizes the opposite winding order
to D3, Shapefiles and PostGIS. And I don’t see an easy way for D3 to
change its behavior, since it would break all existing (spherical)
GeoJSON used by D3. (source)
By rewinding each polygon we get a slightly more useful map:
An improvement, but the features are a bit small with these projection settings.
By adding a fitSize method to scale and translate we get a much better looking map (see block here):
Here's a quick fix to your problem, projection needs a little tuning, also path has fill:#000 by default and stroke: #FFF could make it more legible.
var width = 700, height = 400;
var svg = d3.select(".graph").append("svg")
.attr("viewBox", "0 0 " + (width) + " " + (height))
.style("max-width", "700px")
.style("margin", "10px auto");
d3.json("mercator_files/83.json", function (error, mapData) {
var features = mapData.features;
var center = d3.geoCentroid(mapData);
//arbitrary
var scale = 7000;
var offset = [width/2, height/2];
var projection = d3.geoMercator().scale(scale).center(center)
.translate(offset);
var path = d3.geoPath().projection(projection);
svg.append("g")
.attr("class", "region")
.selectAll("path")
.data(features)
.enter()
.append("path")
.attr("d", path)
});
I want to create a US map based on county data. I'm using this JSON topology data to create the graph: https://cdn.freecodecamp.org/testable-projects-fcc/data/choropleth_map/counties.json
In the first step, I created the map like this, and it works fine:
var path = d3.geoPath();
svgContainer.selectAll("path")
.data(topojson.feature(countyData, countyData.objects.counties).features)
.enter()
.append("path")
.attr("d", path)
Picture: US map renders OK but too large
However, it is too large for my purpose so I'm trying to scale it down. I tried projections which I saw in several other projects (for example here: https://www.d3-graph-gallery.com/graph/choropleth_basic.html). Unfortunately it just renders a black rectangle. I also tried geoAlbersUsa() and some other projections, but it did not help. How can I get the map data to scale?
var projection = d3.geoAlbersUsa() // geoMercator() also does not work
.scale(200)
.translate([width / 2, height / 2]);
var path = d3.geoPath().projection(projection);
svgContainer.selectAll("path")
.data(topojson.feature(countyData, countyData.objects.counties).features)
.enter()
.append("path")
.attr("d", path)
Picture: projection renders black rectangle
What am I doing wrong here?
Everything looks good in the second block of code (using d3.geoAlbersUSA()) except I think you are zoomed in too close with .scale(200) and only seeing the middle of a county. As explained in this post, if you zoom out with smaller scale value you may start to see more of your map.(What does it mean to scale a projection in d3?)
You may be better off using .fitSize() instead of .scale since you seem to be trying to fit the whole topojson data set inside an area rather than zooming into part of it. Updated your example below using a variable margin.
var margin = 20; //amount of whitespace you want around the map
var projection = d3.geoAlbersUsa()
.translate([width / 2, height / 2]);
var path = d3.geoPath().projection(projection);
var countiesFeatureCollection = topojson.feature(countyData, countyData.objects.counties);
//make the map projection fit into size of screen minus margin on all sides
projection.fitSize([width - margin*2, height - margin*2], countiesFeatureCollection);
svgContainer.selectAll("path")
.data(countiesFeatureCollection.features)
.enter()
.append("path")
.attr("d", path)
I am trying to make this map of the us scale smaller. Either to my SVG, or even manually.
This is my code in its simplest from:
function initializeMapDifferent(){
var svg = d3.select("#map").append("svg")
.attr("width", 1000)
.attr("height", 500);
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
svg.append("g")
.attr("class", "states")
.selectAll("path")
.data(topojson.feature(us, us.objects.states).features)
.enter().append("path")
.attr("fill", "gray")
.attr("d", d3.geoPath());
});
}
I have tried something like:
var path = d3.geoPath()
.projection(d3.geoConicConformal()
.parallels([33, 45])
.rotate([96, -39])
.fitSize([width, height], conus));
but every time I add anything to my path variable I get NAN errors from the internal parts of D3. Thanks for any help!
Why the data doesn't project properly
The key issue is that your data is already projected. D3 geoProjections use data that is unprojected, or in lat long pairs. Data in the WGS84 datum. Essentially a d3 geoProjection takes spherical coordinates and translates them into planar cartesian x,y coordinates.
Your data does not conform to this - it is already planar. You can see most evidently because Alaska is not where it should be (unless someone changed the lat long pairs of Alaska, which is unlikely). Other signs and symptoms of already projected data may be a feature that covers the entire planet, and NaN errors.
That this is a composite projection makes it hard to unproject, but you can display already projected data in d3.js.
"Projecting" already projected data
Null Projection:
Most simply, you can define your projection as null:
var path = d3.geoPath(null);
This will take the x,y data from the geojson geometries and display it as x,y data. However, if your x,y coordinates exceed the width and height of your svg, the map will not be contained within your svg (as you found in your example with .attr("d", d3.geoPath());).
The particular file in this question is pre-projected to fit a 960x600 map, so this is ideal for a null projection - it was designed with the dimensions in mind. Its units are pixels and all coordinates fall within the desired dimensions. However, most projected geometries use coordinate systems with units such as meters, so that the bounding box of the feature's coordinates may be millions of units across. In these cases the null projection won't work - it'll convert a map unit value to a pixel value with no scaling.
With d3, A null projection is commonly used with geojson/topojson that is preprojected to fit a specified viewport using a d3 projection. See command line cartography for an example (the example uses unprojected source files - the same issues that arise from using a d3 projection on projected data apply in both browser and command line). The primary advantage of preprojecting a file for use with a null projection is performance.
geoIdentity
If all you need is to scale and center the features, you can use a geoIdentity. This is implements a geoTransform but with standard projection methods such as scale, translate, and most importantly - fitSize/fitExtent. So, we can set the projection to a geoIdentity:
var projection = d3.geoIdentity();
This currently does the same as the null projection used above, it takes x,y data from the geojson geometries and displays it as x,y data with no transform - treating each coordinate in the geojson as a pixel coordinate. But, we can apply fitSize to this (or fitExtent) which will automatically scale and translate the data into the specified bounding box:
var projection = d3.geoIdentity()
.fitSize([width,height],geojsonObject);
or
var projection = d3.geoIdentity()
.fitExtent([[left,top],[right,bottom]], geojsonObject);
Keep in mind, most projected data uses geographic conventions, y=0 is at the bottom, with y values increasing as one moves north. In svg/canvas coordinate space, y=0 is at the top, with y values increasing as one moves down. So, we will often need to flip the y axis:
var projection = d3.geoIdentity()
.fitExtent([width,height],geojsonObject)
.reflectY(true);
This particular dataset: https://d3js.org/us-10m.v1.json was projected with a d3 projection, so its y axis has already been flipped as d3 projections project to a svg or canvas coordinate space.
geoIdentity Demo
var width = 600;
var height = 300;
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
var featureCollection = topojson.feature(us, us.objects.states);
var projection = d3.geoIdentity()
.fitExtent([[50,50],[600-50,300-50]], featureCollection)
var path = d3.geoPath().projection(projection)
svg.append("g")
.attr("class", "states")
.selectAll("path")
.data(featureCollection.features)
.enter().append("path")
.attr("fill", "gray")
.attr("d", path);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.6.0/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/2.2.0/topojson.js"></script>
geoTransform
If you want a little more control over how that data is displayed you can use a geoTransform.
From Mike Bostock:
But what if your geometry is already planar? That is, what if you just
want to take projected geometry, but still translate or scale it to
fit the viewport?
You can implement a custom geometry transform to gain complete control
over the projection process.
To use a geoTransform is relatively straightforward assuming that you do not want to change the type of projection. For example, if you want to scale the data you could implement a short function for scaling with geoTransform:
function scale (scaleFactor) {
return d3.geoTransform({
point: function(x, y) {
this.stream.point(x * scaleFactor, y * scaleFactor);
}
});
}
var path = d3.geoPath().projection(scale(0.2));
Though, this will scale everything into the top left corner as you zoom out. To keep things centered, you could add some code to center the projection:
function scale (scaleFactor,width,height) {
return d3.geoTransform({
point: function(x, y) {
this.stream.point( (x - width/2) * scaleFactor + width/2 , (y - height/2) * scaleFactor + height/2);
}
});
}
var path = d3.geoPath().projection(scale(0.2,width,height))
geoTransform Demo:
Here is an example using your file and a geoTransform:
var width = 600;
var height = 300;
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
function scale (scaleFactor,width,height) {
return d3.geoTransform({
point: function(x, y) {
this.stream.point( (x - width/2) * scaleFactor + width/2 , (y - height/2) * scaleFactor + height/2);
}
});
}
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
var path = d3.geoPath().projection(scale(0.2,width,height))
svg.append("g")
.attr("class", "states")
.selectAll("path")
.data(topojson.feature(us, us.objects.states).features)
.enter().append("path")
.attr("fill", "gray")
.attr("d", path);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.6.0/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/2.2.0/topojson.js"></script>
Unproject the data
This method is useful under certain circumstances. But it requires you to know the projection that was used to create your data. Using QGIS/ArcGIS or even mapshaper you can change the data's projection so that it is "projected" as WGS84 (aka EPSG 4326). Once converted you have unprojected data.
In Mapshaper this is pretty easy with shapefiles, drag in the .dbf, .shp, and .prj files of a shapefile into the window. Open the console in mapshaper and type proj wgs84.
If you don't know the projection used to create the data, you can't unproject it - you don't know what transformation was applied and with what parameters.
Once unprojected, you can use regular d3 projections as normal as you have coordinates in the correct coordinate space: longitude latitude pairs.
Unprojecting is useful if you also have unprojected data and want to mix both in the same map. Alternatively you could project the unprojected data so that both use the same coordinate system. Combining unmatched coordinate systems in a map with d3 is not easy and d3 is likely not the correct vehicle for this. If you really want to replicate a specific projection with d3 to match features that are already projected with unprojected features, then this question may be useful.
How can you tell if your data is projected already?
You could do check to see that the geometry of your features respect the limits of latitude and longitude. For example, if you were to log:
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
console.log(topojson.feature(us, us.objects.states).features);
});
You will quickly see that values are in excess of +/- 90 degrees N/S and +/- 180 degrees E/W. Unlikely to be lat long pairs.
Alternatively, you could import your data to an online service such as mapshaper.org and compare against another topojson/geojson that you know is unprojected (or 'projected' using WGS84).
If dealing with geojson, you may be lucky enough to see a property that defines the projection, such as: "name": "urn:ogc:def:crs:OGC:1.3:CRS84" (CRS stands for coordinate reference system) or an EPSG number: EPSG:4326 (EPSG stands for European Petroleum Survey Group).
Also, if your data projects with a null projection but not a standard projection (scaled/zoomed out to ensure you aren't looking in the wrong area), you might be dealing with projected data. Likewise if your viewport is entirely covered by one feature (and you aren't zoomed in). NaN coordinates are also a potential indicator. However, these last indicators of projected data can also mean other problems.
Lastly, the data source may also indicate data is already projected either in meta data or how it is used: Looking at this block, we can see that no projection was used when the geoPath is defined.
I have a topoJSON data of San Francisco that I am plotting using geoAlbers projection. However, the resulting SVG is pretty small, like a tiny spec. I have used scale and translate. Either the svg stays the same, or it disappears from the page. I already checked out this link: Center a map in d3 given a geoJSON object
Can someone tell me what I am doing wrong?
Here is my code:
let width = 1050,
height = 900;
const svg = d3.select('body').append('svg')
.attr('width', width)
.attr('height', height);
d3.json('/sfmaps/sfarteriesTopo.json', (error, topology) => {
const b = topology.bbox;
const streets = topojson.feature(topology, topology.objects.arteries).features;
var projection = d3.geoAlbersUsa();
var path = d3.geoPath().projection(projection);
svg.selectAll('.streets')
.data(streets)
.enter().append('path')
.attr('class', 'streets')
.attr('d', path);
});
Here is what I have tried:
projection.scale(700): shifts position of the tiny SVG, but size remains same.
scale(700).translate([width/2, height/2]): shifts position again and size is still the same.
scale(2): SVG becomes smaller.
I am trying out d3 for the first time, so any pointers appreciated.
Thank you!
I found out that I was using a very small scale. To fix this, I used a scale of 16000 and it worked well. I calculated scale from the bounds of the geoJSON object.
I'm trying to render a map of Switzerland with D3.js and TopoJSON. The underlying JSON can be found here. First I tried to follow this tutorial and after I couldn't render anything looking remotely like a map, I found this question on here with a link to a working example. From where I took this code I'm currently using:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="http://d3js.org/topojson.v1.min.js"></script>
<style>
path {
fill: #ccc;
}
</style>
</head>
<body>
<h1>topojson simplified Switzerland</h1>
<script>
var width = window.innerWidth,
height = window.innerHeight;
var svg = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height);
var projection = d3.geo.mercator()
.scale(500)
.translate([-900, 0]);
var path = d3.geo.path()
.projection(projection);
d3.json("ch-cantons.json", function(error, topology) {
if (error) throw error;
svg.selectAll("path")
.data(topojson.feature(topology, topology.objects.cantons).features).enter().append("path").attr("d", path);
});
</script>
</body>
</html>
Since the JSON looks ok (checked on some online platforms that renders it properly after copy/pasting) and there is only little code that could go wrong, I assume the error is in the projection parameters. Fiddled around a bit couldn't make it work. So any help would be very much appreciated!
You are right, the error is in the projection.
But, the error depends on if your data is projected or unprojected (lat long pairs).
Unprojected Data
If you have data that is in WGS84 - that is to say lat long pairs, then you have this problem:
Using your projection, but changing only the data source I get something like this (I shaved off the empty ocean on the right):
To center a Mercator properly, you need to know the center coordinate of your area of interest. This generally can be fairly general, for Switzerland I might try 47N 8.25E.
Once you have this coordinate you need to place it in the middle. One way is to rotate on the x axis and center on the y:
var projection = d3.geo.mercator()
.scale(3500)
.rotate([-8.25,0])
.center([0,47])
.translate([width/2,height/2])
Note that the x rotation is negative, you are spinning the globe underneath the projection.
The other option is to rotate on both x and y, this is likely the preferred option as you approach the poles - as Mercator distortion becomes unworkable at high latitudes. This approach would look like:
var projection = d3.geo.mercator()
.scale(4000)
.rotate([-8.25,-47])
.center([0,0])
.translate([width/2,height/2])
Note again the negative rotation values. The second option requires a higher scale value as this method essentially treats Switzerland as though it were at zero,zero on a Mercator projection - and along the equator land sizes are minimized.
Using the second of these projections, I get:
So you'll have to dial in the scale a bit, but now you should be able to see your data (assuming your data is in proper lat long pairs).
Projected Data
Based on the comment below, which includes a linked json file, we can see that this is your problem.
There are two potential solutions to this:
Convert the data to lat long pairs
Use a geoTransform
Option one is the easiest, you'll unproject the data - which requires knowing the current projection. In GIS software this will generally be projecting it as WGS84, which is arguably not really a projection but a datum. Once you have your lat long pairs, you follow the steps above for unprojected data.
Option two skips a d3.geoProjection altogether. Instead, we'll create a transform function that will convert the projected coordinates to the desired SVG coordinates.
A geo projection looks like:
function scale (scaleFactor) {
return d3.geo.transform({
point: function(x, y) {
this.stream.point(x * scaleFactor, (y * scaleFactor);
}
});
}
And is used like a projection:
var path = d3.geo.path().projection(scale(0.1));
It simply takes a stream of x,y coordinates that are already cartesian and transforms them in a specified manner.
To translate the map so it is centered you'll need to know the center coordinate. You can find this with path.bounds:
var bounds = path.bounds(features);
var centerX = (bounds[0][0] + bounds[1][0])/2;
var centerY = (bounds[0][1] + bounds[1][1])/2;
Path.bounds returns the top left corner and bottom right corner of a feature. This is the key part, you can make an autoscaling function, there are plenty of examples out there, but I like manually scaling often. If the map is centered, this is easy. For your map, your geoTransform might look like:
function scale (scaleFactor,cx,cy,width,height) {
return d3.geo.transform({
point: function(x, y) {
this.stream.point((x-cx) * scaleFactor + width/2, (y-cy) * scaleFactor +height/2);
}
});
}
Here cx and cy refer to the middle of your feature and the width and height refer to the width and height of the svg element - we don't want features clustered at SVG point [0,0].
Altogether, that gives us something like (with a scale factor of 0.002):
Here's an updated JSbin: http://jsbin.com/wolamuzeze/edit?html,output
Keep in mind that scale is dependent on window size as your width/height are relative to window size in your case. This might be best addressed with automatically setting the zoom level, though this can create problems if you have labels (for example).
This answer might help as well: Scaling d3 v4 map to fit SVG (or at all)