d3 v5 Axis Scale Change Panning Way Too Much - javascript

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.

Related

Preserving zoom after rerender with D3 v5 and React

I'm building an app containing a Force Layout tree that's supposed to re-render every time the user clicks on a node (this is not negotiable).
I've implemented the zoom and it works as expected.
For the tree's first run, I am manually calculating its coordinates and rescaling it so everything is visible without zooming out.
After the first render, I'm saving the coordinates of the tree after every zoom event and reapplying it when it's rebuilt. Everything works just fine, AS LONG AS scaling is 1.
When it's different, the coordinates get messed up and I'm not able to keep the tree in the same position as before the re-render.
How would I be able to keep the exact same translation / scaling as before?
One thing I observed is that the coordinates seem to have been multiplied by the scale, hence the wrongful change of position.
Here's the code:
// Adds the zoom event listener and saves the last coordinates / scale
const zoom = d3.zoom().on('zoom', () => {
const newTransform = d3.event.transform;
SVGGroup.attr('transform', newTransform);
setPreviousTransform(newTransform);
});
// Runs after every render to apply either the initial position/scale calculated by me or the existing one
let transform = null;
if (previousTransform) {
transform = d3.zoomIdentity
.scale(previousTransform.k)
.translate(previousTransform.x, previousTransform.y);
} else {
transform = d3.zoomIdentity.scale(ratio).translate(x, y);
}
The coordinates were being multiplied by the current zoom, so the results would be slightly off if the zoom were low or astronomically far from expected if the zoom was higher. Dividing those values by the zoom scale eliminates the problem.
const transform = previousTransform
? d3.zoomIdentity
.scale(previousTransform.k)
.translate(
previousTransform.x / previousTransform.k,
previousTransform.y / previousTransform.k
)
: d3.zoomIdentity.translate(x, y);

D3 Map Projection Not Showing Map

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)

Issue w/ Programmatic Zoom v4

I'm using d3.js v4. I'm currently implementing programmatic zoom. This Panning and Zooming Tutorial has helped tremendously. My zoom works with scrolling wheel, but I want to create buttons to zoom. I know what necessary for zooming and panning is a translation [tx, ty] and a scale factor k. I'm using timescale for my x-Axis. I've managed to get tx and scale factor of k, by getting the pixel value of p1 (point 1) and p2(point 2) on the x-axis and then using those values to get a k (Scale factor). Like such:
var k = 500 / (xScale(p2) - xScale(p1)); //500 is desired pixel diff. between p1 and p2, and xScale is my d3.scaleTime() accessor function.
// for this zoom i just want the first value and last value to be at scale difference of the entire width.
Then I calculate tx by this:
var tx = 0 - k * p1;
Then feeding it into a d3.zoomIdentity() and rescaling my xdomain. I created a button to zoom back out. The issue is when I zoom in and then try to use the button to zoom out, it zooms out, but shrinks the x-axis. I can't seem to findout why its shrinking the x-axis instead of zooming back out correctly.
My JSFiddle
https://jsfiddle.net/codekhalifah/Lmdfrho7/2/
What I've Tried
Read zoom Documentation
Read through chapter on zoom in D3.js in action
My Code
After wheel zoom is applied I run this function:
function zoomed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
var t = d3.event.transform;
console.log(t);
console.log(xScale.domain());
xScale.domain(t.rescaleX(x2).domain());
usageAreaPath.attr("d", area);
usageLinePath.attr('d',line);
weatherAreaPath.attr('d',weatherChart.area);
focus.select(".axis--x").call(xAxis);
focus.selectAll('.circle')
.attr('cx', function(d) { return xScale(getDate(d)); })
.attr('cy', function(d) { return yScale(d.kWh); })
.attr("transform", "translate(0," + 80 + ")");
... other non related items
}
The zoom works properly, but after zooming in and then manually attempting to zoom back normal position I want.
My manual zoom button function
function programmaticZoom(){
var currDataSet = usageLinePath.data()[0], //current data set
currDataSetLength = currDataSet.length,//current data set length
x1 = currDataSet[0].usageTime, //getting first data item
x2 = currDataSet[currDataSetLength-1].usageTime, //2nd data item
x1px = xScale(moment(x1)), //Get current point 1
x2px = xScale(moment(x2)); // Get current point 2
// calculate scale factor
var k = width / (x2px - x1px); // get scale factor
var tx = 0 - k * x1px; // get tx
var t = d3.zoomIdentity.translate(tx).scale(k); //create zoom identity
xScale.domain(t.rescaleX(window.x2).domain());
usageAreaPath.attr("d", area);
usageLinePath.attr('d',line);
weatherAreaPath.attr('d',weatherChart.area);
focus.select(".axis--x").call(xAxis);
focus.selectAll('.circle')
.attr('cx', function(d) { return xScale(getDate(d)); })
.attr('cy', function(d) { return yScale(d.kWh); })
.attr("transform", "translate(0," + 80 + ")");
}
I've been looking at this for a little while now, and I got it sort of working, there's one thing I can't figure out but you might be able to since you seem more familiar with d3 than I am. In your programmaticZoom() function, I noticed that tx is the offset for where the start of the graph is, and k is the scale. Using this, I changed the block:
var k = width / (x2px - x1px); // get scale
var tx = 0 - k * x1px; // get tx
var t = d3.zoomIdentity.translate(tx).scale(k);
to
var k = 1;
var t = d3.zoomIdentity.scale(k);
First, when k = 1.0, the graph will fit in the window perfectly. The reason I believe setting k to its former value was wrong is that, when you increase the value of k so that k > 1.0, it stretches the width of the graph past the screen. The content on the graph has to be readjusted so that it can take up as much space on the graph as possible, while still being within the bounds of the screen. With k < 1, which is what happens with width / (x2px - x1px);, the graph shrinks to be less than the size of the screen. Readjusting like this will only make the graph's content fit to take up the maximum that it can within the graph, but since the graph is smaller than the screen, it will be readjusted to fit the shrunken graph and appear smaller than the screen.
I got rid of tx entirely because it offsets where the graph starts at. When the graph is zoomed in, it's width is stretched past the screen. It makes sense to offset here because you need to have your offset equal to where you want to begin viewing the graph, and allow the parts you don't need to remain off of the screen. In the case where you're zooming out all the way, the graph is the size of the screen, so offsetting is going to cause your graph to not start at the beginning of the screen and instead push part of it off of the screen. In your case, with k already shrinking the graph, the offset causes the shrunken graph to appear in the middle of the screen. However if the graph was not shrunken, the offset would push part of the graph off of the screen.
By changing that, the zoom out button appears to work, however there is still a problem. In the zoomed() function, you set var t = d3.event.transform;. The d3.event.transform contains values of k, x, and y that need to be 1, 0, and 0 respectively when completely zoomed out. I cannot change these values in the programmaticZoom() function though, because d3.event.transform only exists after an event was fired, specifically the mouse wheel. If you are able to get these values to be k = 1, x = 0, y = 0 only when the zoom button is clicked, the issue should be completely fixed.
Hopefully that helped some, I'm not very familiar with d3 but this should solve most of your problem and hopefully give you an idea of what was going wrong.

Constraining map panning with zoom.translateExtent in D3 v4

I'm trying to display a map of a single state, with zooming and panning constrained to the boundaries of the state. It's mostly working, except for the panning constraint when the state path is scaled to fit a smaller container. I think this comes down to me not understanding what arguments to use for zoom.translateExtent (although I'm very new to this, so it could be something else).
Live example on bl.ocks.org, with links to prior art.
One notable thing is that I'm using a null projection for d3.geoPath, because I used ogr2ogr to generate a shapefile in projected coordinates for each state. That's why I used a zoom transform to fit the map to its container.
#McGiogen's solution is almost correct but misses that MIN needs to vary depending on the zoom scale factor transform.k.
I drew a diagram to see how I needed to constrain my svg to always be contained inside the zoomed view (depicted in my drawing as the LARGER of the boxes, only a portion of which is visible to the user):
(since the constraint x+kw >= w is equivalent to x >= (1-k)w, with a similar argument for y)
thus assuming your svg container size [w, h]:
function zoomed() {
var t = d3.event.transform;
t.x = d3.min([t.x, 0]);
t.y = d3.min([t.y, 0]);
t.x = d3.max([t.x, (1-t.k) * w]);
t.y = d3.max([t.y, (1-t.k) * h]);
svg.attr("transform", t);
}
I'm facing the same problem today and I've done some tests.
I've noticed that it's the same weird behaviour happening when you have a translateExtent box smaller than the content's elements.
In your (and mine) code the same behaviour is triggered by zooming out: it doesn't matter if you have the translateExtent box correctly set with no zoom, if you zoom out the box is reduced at higher rate than the elements and at some point you will have translateExtent box smaller than the content (and the weird behaviour).
I temporary solved this as said here
D3 pan+ zoom constraints
var MIN = {x: 0, y: -500}, //top-left corner
MAX = {x: 2000, y: 500}; //bottom-right corner
function zoomed() {
var transform = d3.event.transform;
// limiting tranformation by MIN and MAX bounds
transform.x = d3.max([transform.x, MIN.x]);
transform.y = d3.max([transform.y, MIN.y]);
transform.x = d3.min([transform.x, MAX.x]);
transform.y = d3.min([transform.y, MAX.y]);
container.attr("transform", transform);
}
I'm still a d3 newbie but I think that this is a bug in translateExtent code.

What does it mean to scale a projection in d3?

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).

Categories

Resources