The Problem:
I have been trying, to no avail, to find a general solution for simultaneously rotating and translating an SVG with d3.js v4.2.2.
I can get the SVG starting and ending in the correct positions, but the easing in-between is all wrong. Below is a JSFiddle where I have done a rotate+translate on many instances of the same SVG pulled from the DOM, each to a different amount rotation according to the datapoint.
A key thing to note is that I need this solution to work when translating from (100,100) to (500,500) just as well as when I start from (0,0).
https://jsfiddle.net/3jbv23em/
var serializer = new XMLSerializer();
var svgNode = serializer.serializeToString(document.querySelector('#svgs svg defs svg'));
var dataset = [1,8,15,22,29,36,43,50,57,64,71,78,85,92,99,106,113,120,127,134,141,148,155,162,169,176,183,190,197,204,211,218,225,232,239,246,253,260,267,274,281,288,295,302,309,316,323,330,337,344,351,358]
var wrapper = d3
.select('#game svg')
.attr('width', window.innerWidth)
.attr('height', window.innerHeight)
.selectAll('g')
.data(dataset)
.enter()
.append('g')
.html(function(d) { return svgNode; })
.transition()
.duration(1500)
.attr('transform', function(d) {
return 'translate(500,300)' +
'rotate(' + d * 1.8 + ', 63, 54.77)';
});
(Unfortunately, I wasn't able to get D3 v4 working in JSFiddle, so JSFiddle is using v3 here. However, the problem is the same).
My expected behaviour: All of the chickens rotate around the same center, never extending past the dimensions of the circle seen at the end of the animation.
The actual result: All chickens rotate around the translated original position of the top left corner before returning to the correct position at the end.
Solutions That Didn't Work for Me:
How to rotate an object around the center in d3.js
This is about rotating around a fixed point further away, not around a point on the object one is trying to rotate
SVG rotation anchorpoint
This might work, but completely takes me out of the D3 ecosystem
How do I rotate or scale (transform) an SVG path relative to its center point?
I need the solution to start from an arbitrary point, not always the origin # (0,0)
In Conclusion:
I'm pretty bewildered at the moment, and any help is greatly appreciated. Thanks for your time.
Because D3 transitions applies both the translate and the rotate together you get some awkward looking animations even though the end result is what you are looking for.
D3 provides a d3.interpolateString which handles executing the animation the way you would like. d3.interpolateString requires a starting and ending transform. d3.interpolateString is used within attrTween.
Replace
.attr('transform', function(d) {
return 'translate(500,300)' +
'rotate(' + d * 1.8 + ', 63, 54.77)';
});
With
.attrTween('transform', function(d, i, a) {
return d3.interpolateString('translate(0,0) rotate(0)',
'translate(500,300)' +
'rotate(' + d * 1.8 + ', 63, 54.77)');
});
Here is an updated jsfiddle with a tweak to the translation for better viewability for smaller screens.
https://jsfiddle.net/3jbv23em/16/
A helpful link with the same issue is "D3.js animate rotation".
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 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'm a newcomer to D3 and I'm trying to make a world globe with some points ("pins") on it. Demo here: http://bl.ocks.org/nltesown/66eee134d6fd3babb716
Quite commonly, the projection is defined as:
var proj = d3.geo.orthographic()
.center([0, 0])
.rotate([50, -20, 0])
.scale(250)
.clipAngle(90)
.translate([(width / 2), (height / 2)]);
the clipAngle works well for the svg paths, but not the pins (which are svg circles). As you can see on the demo, the pin that sits between Iceland and Greenland should be hidden (it's Taiwan).
So I suppose the problem comes from these lines, but I can't understand why:
.attr("transform", function(d) {
return "translate(" + proj([ d.lng, d.lat ]) + ")";
});
It is not sufficient to just set the clipping radius via clipAngle() to get the desired behavior. The projection alone will not do the clipping, but just calculate the projected coordinates without taking into account any clipping. That is the reason, why Taiwan gets rendered, although you expected it to be hidden.
But, thanks to D3, salvation is near. You just need to re-think the way you are inserting your circles representing places. D3 has the mighty concept of geo path generators which will take care of the majority of the work needed. When fed a projection having a clipping angle set, the path generator will take this into account when calculating which features to actually render. In fact, you have already set up a proper path generator as your variable path. You are even correctly applying it for the globe, the land and the arcs.
The path generator will operate on GeoJSON data, so all you need to do is convert your places to valid GeoJSON features of type Point. This could be done with a little helper function similar to that used for the arcs:
function geoPlaces(places) {
return places.map(function(d) {
return {
type: "Point",
coordinates: [d.lng, d.lat]
};
});
}
With only minor changes you are then able to bind these GeoJSON data objects to make them available for the path generator which in turn takes care of the clipping:
svg.selectAll(".pin") // Places
.data(geoPlaces(places))
.enter().append("path")
.attr("class", "pin")
.attr("d", path);
Have a look at my fork of your example for a working demo.
I'm a complete noop to D3 and partly SVG, so I got a few basic questions.
First off, my code in question can be viewed at http://dotnetcarpenter.github.io/d3-test/ and I've used Simple Pie Chart example with D3.js and Pie Chart Update, II as examples to get a running start.
As you can see, the animation gets skewed in the end when the low path values switch to the higher values. This is obviously not what I want. I think I'm getting the order of calculations wrong but I'm not sure what to do. I'm using the code from the last example:
function change() {
//...
path.transition().duration(750).attrTween("d", arcTween); // redraw the arcs
}
// where arcTween is
function arcTween(a) {
var i = d3.interpolate(this._current, a);
this._current = i(0);
return function(t) {
return arc(i(t));
};
}
Another issue is placing labels on the sectors. I've put the update stuff in the change function and is able to read out and only render them if the value is between 0 and 100. I can't however place them in any way. Looking at the first example, I figure that I could do something like this:
text.data(data)
.text(setText)
.attr("transform", function (d) {
// we have to make sure to set these before calling arc.centroid
d.innerRadius = 0;
d.outerRadius = radius;
return "translate(" + arc.centroid(d) + ")";
})
.attr("text-anchor", "middle") //center the text on it's origin
Where text is a d3 selection and arc is: d3.svg.arc().outerRadius(radius)
But I get "Unexpected value translate(NaN,NaN) parsing transform attribute." warning in Firefox and the labels are written on top of each other.
I appreciate any help and hints. Thanks!
I finally figured it out.
Maintain sector order throughout an animation.
You'd be forgiven for thinking that object contancy had something do with it. I did. But it turns out to be much simpler than that.
Every pie chart is by default sorted by value. If you don't want to sort by value but e.g. by data list order, you just have to disable sorting.
var pie = d3.layout.pie() // get a pie object structure
.value(function(d) { // define how to get your data value
return d.value; // (based on your data set)
})
.sort(null); // disable sort-by-value
Positioning labels according to your chart
Basically, you need to calculate your label positions depending on the type of chart or graph, your trying to connect them to. In my case, it's a pie chart. So if I want d3 to help with the calculations, I need to tell centroid the inner and outer radius and, most importantly to my issue, the start and end angles. The latter was missing from my code. Getting these values is as simple as, calling our pie layout above with our dataset and then do a transform.
Note that you don't have to call .data() again if you created the SVG with d3 and already supplied your data wrapped in .pie() structure. That is, that you didn't select any existing SVG from your page.
var svg = d3.select("svg")
// do stuff with your svg
var pie = d3.layout.pie()
// set stuff on your layout
var text = svg.selectAll("text")
.data(pie(dataset)) // where dataset contains your data
.attr("transform", function(d) {
return "translate(" + arc.centroid(d) + ")";
});
I have to give credit to Philip Pedruco for helping me along the way.
Bonus info
Use viewBox if you want to position your SVG cross browser, not transform/translate.
// move pie to center
d3.select("svg").attr("viewBox", -radius + ","+ -radius +"," + size + "," + size)
I'm very new to d3.js (and SVG in general), and I want to do something simple: a tree/dendrogram with angled connectors.
I have cannibalised the d3 example from here:http://mbostock.github.com/d3/ex/cluster.html
and I want to make it more like the protovis examples here:
http://mbostock.github.com/protovis/ex/indent.html
http://mbostock.github.com/protovis/ex/dendrogram.html
I have made a start here: http://jsbin.com/ugacud/2/edit#javascript,html and I think it's the following snippet that's wrong:
var diagonal = d3.svg.diagonal()
.projection(function(d) { return [d.y, d.x]; });
However there's no obvious replacement, I could use d3.svg.line, but I don't know how to integrate it properly, and ideally I'd like an elbow connector....although I am wondering if I am using the wrong library for this, as a lot of the d3 examples I've seen are using the gravitational force to do graphs of objects instead of trees.
Replace the diagonal function with a custom path generator, using SVG's "H" and "V" path commands.
function elbow(d, i) {
return "M" + d.source.y + "," + d.source.x
+ "V" + d.target.x + "H" + d.target.y;
}
Note that the source and target's coordinates (x and y) are swapped. This example displays the layout with a horizontal orientation, however the layout always uses the same coordinate system: x is the breadth of the tree, and y is the depth of the tree. So, if you want to display the tree with the leaf (bottommost) nodes on the right edge, then you need to swap x and y. That's what the diagonal's projection function does, but in the above elbow implementation I just hard-coded the behavior rather than using a configurable function.
As in:
svg.selectAll("path.link")
.data(cluster.links(nodes))
.enter().append("path")
.attr("class", "link")
.attr("d", elbow);
And a working example:
http://bl.ocks.org/d/2429963/