I know that in D3, scales are mathematical maps from input data values (domain) to output data values (range). I know that I can set up a scale that will map input from the domain onto a range, like this:
var scale = d3.scale.linear().domain([100, 500])
.range([10, 350]);
scale(100); //Returns 10
I know that once you setup a scale, you can use it to scale attributes, like this:
.attr("cx", function(d) {
return scale(d[0]); //Returns scaled value
})
However, on Bostock's mapping tutorial scale is used a little differently. Bostock calls scale on whatever is returned by the mercator projection:
var projection = d3.geo.mercator()
.scale(500)
.translate([width / 2, height / 2]);
I am trying to understand that line of code and having a little trouble. mercator() returns something -- which is not so clear from the API -- and then the scale method is called on that object with an input value of 500.
What does it mean to call "scale" on a projection like this? How does this relate to scale() as a way to transform 100 in 10 -- as in the example above.
More immediately, if I add this to my code, my geojson map disappears! How do I make it scale properly?
var projection = d3.geo.mercator()
.scale(500)
.translate([width / 2, height / 2]);
var path = d3.geo.path()
.projection(projection); //if I add this little bit to the path, my map disappears!
Here is the GeoJSON I am using: http://geojson.io/#id=gist:AbeHandler/9d28239c592c6b552212&map=10/29.9912/-89.9320
In this case, scale is used in the general sense of the size of something. It's not directly related to the d3 scale functions, but is related to the scale transforms you can apply to an SVG element to make it bigger or smaller. However, instead of scaling a flat drawing, it scales the complex projection.
The projection returned by d3.geo.mercator() is a function. Specifically, it's a function that converts from a longitude/latitude point to an x/y point.
Similarly, the function returned by d3.geo.path() converts GeoJSON data into SVG path definitions. When you assign a modify this function by assigning it a specific projection function, it will use that projection to figure out the position of each point on the path it creates.
Now, why are you not seeing anything at all when you assign a scaled-up projection to your path? It is probably simply that you are so zoomed-in that there is nothing to see: you're lost in empty ocean. The default scale factor on a projection is 150, so a scale of 500 is more than three times zoomed in. Try different scale factors, some bigger and some smaller than 150, to zoom in and out of the map.
Of course, the translation could also be throwing off your map. The translation parameter sets the x/y value for the center (latitude/longitude) point of your map. If you don't specify what you want to use as a center point, the projection uses 0 degrees latitude and 0 degrees longitude. If the geography you're trying to draw is nowhere near that point (which is in the Gulf of Guinea, off the coast of Ghana), then once again nothing is going to show up unless you zoom out considerably (i.e., use a very small scale number).
Related
I'm using D3 to draw geo data on top of a static map.
Before drawing the data, I'm using d3's fitSize method to set the projection so that the bounds of the map (as spherical lat lng pairs) fit the full extent of the canvas.
const size = {
width: 1048,
height: 447
};
const bounds = [
-13.098400070906848,
51.44391918744,
17.18913007090731,
42.66553377959798
];
const geojson = turf.bboxPolygon([bounds[0], bounds[1], bounds[2], bounds[3]])
const projection = d3.geoMercator().fitSize([size.width, size.height], geojson);
d3.geoPath().projection(projection);
In this example, the projection doesn't fit the extent, the scale is off by a large margin.
I should note that I get the bounds from the map beforehand so the issue shouldn't be due to a proportions difference between the canvas size and the bounds.
I've put together an example here where the projection doesn't fit. It shows where the projection draws the bounds compared to the extent.
As you can see in the logs, the ratios of the bounds dimensions and the canvas dimensions are almost identical, meaning that we should be able to scale the bounds to the full extent of the canvas.
I'm confused as to why that is and how to fix it.
Any help would be greatly appreciated! Thanks!
Hi i am trying to create a map of the city of Birmingham, though i can see the paths have been generated and the data is being loaded. I do not see anything on the html page.
I have seen people use the projection function without setting center or translate and it would visual their maps but this has not worked for me.
I have looked into possible solutions and found centering your projection by the city you are interested in should help with getting the map to display properly but this did not helped. I also tried to play around with the scale but this also did not help.
Essentially my expected results was a map of Birmingham to be displayed in the middle of my svg object.
var w= 1400;
var h = 700;
var svg = d3.select("body").append("svg").attr("width",w).attr("height",h );
var projection = d3.geoMercator().translate([w/2, h/2]).scale(100).center([1.8904,52.4862]);
var path = d3.geoPath().projection(projection);
var ukmap = d3.json("https://martinjc.github.io/UK-GeoJSON/json/eng/wpc_by_lad/topo_E08000025.json");
// draw map
Promise.all([ukmap]).then(function(values){
var map = topojson.feature(values[0],values[0].objects.E08000025).features
console.log(map);
svg.selectAll("path")
.data(map)
.enter()
.append("path")
.attr("class","continent")
.attr("d", path)
.style("fill", "#f0e4dd") //steelblue
});
```
It looks like the path is in the middle of your svg:
It's just really small.
With a d3 Mercator projection scale of 100 you are displaying 360 degrees of longitude across 100 pixels. So, with an svg that is 1400 pixels across, you could be showing 14 earths. Not ideal for a city. If you up the scale value to 10000 you'll at least see your feature, but it's not quite centered and it's still pretty small, try values for center and scale like so:
.center([-1.9025,52.4862])
.scale(100000)
(keeping the translate the same)
Now we're getting somewhere:
But this is still tedium, we can simply use projection.fitExtent or projection.fitSize to do the scaling automagically:
Promise.all([ukmap]).then(function(values){
var map = topojson.feature(values[0],values[0].objects.E08000025)
projection.fitSize([w,h],map);
var features = map.features;
svg.selectAll("path")
.data(features)
.enter()
...
This stretches the feature to fill the specified dimensions (it takes a geojson object, not an array, hence my slight restructuring). We can also specify a margin like so:
projection.fitExtent([[100,100],[w-100,h-100]],map);
This provides a 100 pixel margin around the map so it doesn't touch the edge of the SVG.
Both of these methods, fitSize and fitExtent, automatically set the projeciton translate and scale, so we can actually skip setting the scale, center, and translate manually (translate and scale essentially do the same thing: one after projection and one before, respectively. It's usually easier to use both though when setting the projection parameters manually)
I have a simple chart with time as the X axis. The intended behavior is that while dragging in the graph, the X axis only will pan to show other parts of the data.
For convenience, since my X axis is in a react component, the function that creates my chart sets the X scale, the x axis, and the element it is attached to as this.xScale, this.xAxis, and this.gX, respectively.
If I set this as the content of my zoom method, everything works fine:
this.gX.call(this.xAxis.scale(d3.event.transform.rescaleX(this.xScale)))
The X axis moves smoothly with touch input. However, this doesn't work for me, because later when I update the chart (moving data points in response to the change of the axis), I need this.xAxis to be changed so the points will map to different locations.
So, I then set the content of my zoom method to this:
this.xScale = d3.event.transform.rescaleX(this.xScale);
this.xAxis = this.xAxis.scale(this.xScale);
this.gX.call(this.xAxis);
As far as I can tell, this should function EXACTLY the same way. However, when I use this code, even without running my updateChart() function (updating the data points), the X axis scales erratically when panning, way more than normal. My X axis is based on time, so suddenly a time domain from 2014 to 2018 includes the early 1920s.
What am I doing wrong?
Problem
When you use scale.rescaleX you are modifying a scale's domain based on a current zoom transform (based on translate and scale).
But, the transform returned from d3.event.transfrom isn't the change from the previous zoom transform, it represents the cumulative transformation. We want to apply this transform on our original scale as the transform represents the change from the original state. However, you are applying this cumulative transform on a scale that was modified by previous zoom transforms:
this.xScale = d3.event.transform.rescaleX(this.xScale);
Let's work through what this does during a translate event such as panning:
Pan right 10 units
Shift the domain of the scale 10 units.
That works, but if we pan again:
Pan right 10 more units
Shift the domain of the scale an additional 20 units.
Why? Because the zoom transform is keeping track of the zoom state relative to the initial state, but you want to update the scale with only the change in state, not the cumulative change to the zoom transform. Consequently, at this point the domain has shifted 30 units, but the user has only panned 20.
The same thing happens with scale:
Zoom in by 2x on the center of the graph (zoom transform scale = 2)
Rescale the scale so that it has half the domain (is twice as detailed)
Zoom in again by 2x on the center of the graph (zoom transform scale = 4)
Rescale the scale so that it has one one fourth the domain that it currently has (which is already one half of the original, so we are now zoomed in 8x: 2x4).
At step four, d3.event.transform.k == 4, and rescaleX is now scaling the scale by a factor of four, it doesn't "know" that the scale has already been scaled by a factor of two.
It gets even worse if we continue to apply zooms, for example if we zoom out from k=4 to k=2, d3.event.transform.k == 2, we are still zooming in 2x despite trying to zoom out, now we are at 16x: 2x4x2. If instead we zoom in, we get 64x (2x4x8)
This effect is particularly bad on a translate - the zoom even is triggered constantly throughout a pan event, so the scale is cumulatively reapplied on a scale that already has cumulatively applied the zoom transform. A pan can easily trigger dozens of zoom events. In the comparison snippet below, panning just a bit can easily pull you into the 1920s despite a starting domain of 2014-2018.
Solution
The easiest way to correct this (and the canonical way) is very similar to the approach you use in your code that works for panning (but not updating):
this.gX.call(this.xAxis.scale(d3.event.transform.rescaleX(this.xScale)))
What are we doing here? We are creating a new scale while keeping the original the same - d3.event.transform.rescaleX(this.xScale). We supply the new scale to the axis. But, as you note, when updating the graph you run into problems, xScale isn't the scale used by the axis, as we now have two disparate scales.
The solution then is to use, what I call, a reference scale and a working scale. The reference scale will be used to update a working scale based on the current zoom transform. The working scale will be used whenever creating/updating axes or points. At the beginning, both scales will probably be the same so we can create the scale as so:
var xScale = d3.scaleLinear().domain(...).range(...) // working
var xScaleReference = xScale.copy(); // reference
We can update or place elements with xScale, as usual.
On zoom, we can update xScale (and the axis) with:
xScale = d3.event.transform.rescaleX(xScaleReference)
xAxis.scale(xScale);
selection.call(xAxis);
Here's a comparison, it has the same domain as you note, but it doesn't take long to get to the 1920s on the upper scale (which uses one scale). The bottom is much more as expected (and makes use of a working and reference scale):
var svg = d3.select("body")
.append("svg")
.attr("width", 400)
.attr("height", 200);
var parseTime = d3.timeParse("%Y")
var start = parseTime("2014");
var end = parseTime("2018");
///////////////////
// Single scale updated by zoom transform:
var a = d3.scaleTime()
.domain([start,end])
.range([20,380])
var aAxis = d3.axisBottom(a).ticks(5);
var aAxisG = svg.append("g")
.attr("transform","translate(0,30)")
.call(aAxis);
/////////////////
// Reference and working scale:
var b = d3.scaleTime()
.domain([start,end])
.range([20,380])
var bReference = b.copy();
var bAxis = d3.axisBottom(b).ticks(5);
var bAxisG = svg.append("g")
.attr("transform","translate(0,80)")
.call(bAxis);
/////////////////
// Zoom:
var zoom = d3.zoom()
.on("zoom", function() {
a = d3.event.transform.rescaleX(a);
b = d3.event.transform.rescaleX(bReference);
aAxisG.call(aAxis.scale(a));
bAxisG.call(bAxis.scale(b));
})
svg.call(zoom);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
We can see the same approach taken with Mike Bostock's examples such as this brush and zoom, where x2 and y2 represent the reference scales and x and y represent the working scales.
I downloaded .geojson files from mapzen metro extracts that is supposed to show the outline of a neighborhood. However, when I run the javascript code that I have written, nothing is appended to the "g" element and thus nothing shows up.
Here is the code that I have now:
var canvas = d3.select("body").append("svg")
.attr("width", 760)
.attr("height", 700);
d3.json("wayland.geojson", function (data){
console.log(data);
var nb = canvas.append("g")
.attr("class","nb");
var group = nb.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("d", d3.geoPath());
});
The geojson file in question is valid and is a Feature, so I was just wondering how to map such a file correctly.
Object {id: 85854865, type: "Feature", properties: Object, bbox: Array(4), geometry: Object}
You need to define a projection for d3.geoPath(), if you do not specify a projection, d3 defaults to a null projection. A null projection takes geographic coordinates and simply turns them into svg/canvas coordinates with no transformation at all. Thus, if you have lat/long pairs in your geojson, only points from 0,0 to 90,180 will show, so anything in the western or sourthern hemispheres will not work. However, no errors will be produced because the null projection and the geoPath are working as expected.
Note, d3 projections take coordinates that use the WGS84 datum, that is latitude longitude pairs using the WGS84 ellipsoid, generally most lat long pairs will use this datum (GPS, google earth, etc). If your data is projected already, then d3 geoProjections are not what you need, you'll need geoTransforms
Instead of using a null projection, try to define a projection. There are a few ways to do this, one is the fitExtent method (as you are using d3 v4):
var projection = d3.geoMercator()
.fitExtent([[40,40],[width-40,height-40]], geojson);
This will take a set of geojson features (not topojson) and place a 40 pixel buffer around the features using a mercator map projection.
Another option is to look at your bbox coordinates and find the center of your area of interest (or use Google Earth etc) and set the projection manually:
var projection = d3.geoAlbers()
.center([0,y])
.rotate([-x,0])
.scale(10000);
Scale values increase as you zoom in, so starting with a low value is always useful to ensure you are looking in the right area. Neighborhood level details will be very zoomed in though.
Projection type won't be too important at a neighborhood level, but setting the parameters correctly will be.
Lastly, you'll need to make sure your geoPath uses your projection:
geoPath.projection(projection);
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.