Issue w/ Programmatic Zoom v4 - javascript

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.

Related

D3 v6: How to zoom stacked bar by selected segment to stretch only selected segment and shrink rest segments (and keep initial chart width)?

I have a problem with my horizontal stacked bar.
The problem: sometimes I got really small values, so one of bands' segment (sub-band ?) has very small width. (Please look below on the picture, rect of each color I call segment):
In some cases I even can't see this segment on the chart. In future I want to show text on each segment (percentage values). But since width of segment can be too small, I need a solution to show text.
Possible solutions: First I thought to set minimal segment width. But it seems the chart will not look OK after this. Also I tried to play around xScale:
const maxX = 1.4; // this value selected experimentally
const xScale = d3.scaleLinear().range([ 0, width ]).domain([ 0, maxX ]);
But for some cases segment still too small (please look at third segment, I marked it with blue color).
So for now I choose solution to zoom stacked bar by selected segment. For example, I want to zoom red segment, all red segments on the chart should be stretched, rest of segments should be shrinked. And total width of bands should be the same (as initial). So width of the chart should be same.
This is my code example: https://jsfiddle.net/8d1te7cb/2/
The problem here that I can't correctly zoom only selected segment. I tried to detect currect group of segments:
const currentNode = d3.select(event.sourceEvent.target).node();
const currentGroup = d3.select(currentNode.parentNode).node();
and then I tried to rescale only segments related to currentGroup:
group.selectAll("rect.segment")
// .attr("transform", event.transform.toString())
.attr("x", (d, i, n) => {
if (n[i].parentNode === currentGroup) {
return xScale(d[0]) + PADDING_TO_SHOW_TEXT;
}
return xScale2(d[0]) + PADDING_TO_SHOW_TEXT;
})
.attr("width", (d, i, n) => {
if (n[i].parentNode === currentGroup) {
return xScale(d[1]) - xScale(d[0])
}
return xScale2(d[1]) - xScale2(d[0])
});
But actually all segments not from currentGroup keep their width and selected group stretched too much, so it moved outside of the axis (and actually after that I got width of the chart changed).
The question: how to fix zoom to allow only selected group stretch and rest of group shrink (and keep initial width of the chart) ?
Extra question: does it exist any another way to show segments proportionally even if for some of them value is too small?
UPDATED: my initial project on typescript, so I forgot to remove some ts hints, this is a bit updated example: https://jsfiddle.net/8d1te7cb/3/ (removed ts from commented code)
Well, finally I implemented what I want.
Result: https://jsfiddle.net/2yqbvrwp/
Details: First of all, I decided to remove second scale, bc I need it only in runtime. So, my zoom function is changed to:
d3.zoom().scaleExtent([ 1, 10 ])
// I am not sure if I need the line below since code works same without it
// I think below is default value
//.translateExtent([ [ 0, 0 ], [ width, height ] ])
.on("zoom", (event) => {
const transform = event.transform;
// the new scale I use for runtime
// the important part here is clamp method. It prevents from moving
// segments outside of axis
const newScaleX = transform.rescaleX(xScale).clamp(true);
// so I just applied new scale to current axis
xAxis.scale(newScaleX)
svg.select("g.axis-x").call(xAxis);
svg.selectAll("rect.segment")
.attr("x", (d) => newScaleX(d[0]))
.attr("width", (d) => newScaleX(d[1]) - newScaleX(d[0]));
})
svg.call(zoom);
Also I removed rect transform from css and added margin left in code. But I think I will return it back since native css is faster.

d3 v5 Axis Scale Change Panning Way Too Much

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.

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.

D3 bar chart - last bar overhangs the end of the X axis

I have a D3 stacked bar chart that is working great except that the last bar overhangs the right side of X axis and the left side of the axis is not touching the Y axis. This is because I had to move the bars so that they would be centered over the tick lines. I thought that if I padded the time scale by a date on either end that would take care of it, but instead the bars just spread out to take up the available space.
Here is a JSFiddle: http://jsfiddle.net/goodspeedj/cvjQq/
Here is the X scale:
var main_x = d3.time.scale().range([0, main_width-axis_offset - 5]);
This is my attempt to pad the X axis by a date on either end:
main_x.domain([
d3.min(data.result, function(d) {
return d.date.setDate(d.date.getDate() - 1);
}),
d3.max(data.result, function(d) {
return d.date.setDate(d.date.getDate() + 1);
}
)]);
The calculation for the x attribute on the bars is:
.attr("x", function(d) { return main_x(d.date) - (main_width/len)/2; })
This is a minor thing, but it is really annoying me. Thanks!
The main problem was that you had a clip path in your SVG that clipped parts of the plotting region. Once that is removed, you can see the full graph, but the first and last bars will still "hang" over the ends of the axis.
To change that, you have to extend the axis, e.g. by adding a day or two either way when computing the minimum and maximum for the scale.

Brushing on ordinal data does not work

I really like this graph and its functionality and it is perfect for what I want/need. The only thing I need to change is I need it to allow ordinal data on the y-axis and I cannot seem to get that to work (I am a beginner).
When I change the y scale from linear to ordinal:
yscale[k] = d3.scale.linear()
.domain(d3.extent(data, function(d) { return +d[k]; }))
.range([h, 0]));
to
yscale[k] = d3.scale.ordinal().rangePoints([h, 0]),
yscale[k].domain(data.map(function(d) { return d[k]; })))
Brushing still shows up and works by itself but it does not filter leaving the selected lines. No lines show up unless I move it to the very top of the axis then, all or mostly all show up. When I stepped through the code with firebug it looked like it was just not getting the lines that were in the brush area but all(?)... and I can't seem to figure out. :(
If anyone could help out with this (especially all the places I have to change and how), I would love to get this working and learn what I am doing wrong :-\
Brushing an ordinal axis returns the pixels, while brushing a quantitative axis returns the domain.
https://github.com/mbostock/d3/wiki/SVG-Controls#wiki-brush_x
The scale is typically defined as a
quantitative scale, in which case the extent is in data space from the
scale's domain; however, it may instead be defined as an ordinal
scale, where the extent is in pixel space from the scale's range
extent.
My guess is that you need to work backwards and translate the pixels to the domain values. I found this question because I'm trying to do the same thing. If I figure it out, I'll let you know.
EDIT: Here's an awesome example to get you started.
http://philau.willbowman.com/2012/digitalInnovation/DevelopmentReferences/LIBS/d3JS/examples/brush/brush-ordinal.html
function brushmove() {
var s = d3.event.target.extent();
symbol.classed("selected", function(d) { return s[0] <= (d = x(d)) && d <= s[1]; });
}
He grabs the selection extent (in pixels), then selects all of the series elements and determines whether they lie within the extent. You can filter elements based on that, and return data keys or what have you to add to your filters.
There is an example of an ordinal scale with brushing here:
http://bl.ocks.org/chrisbrich/4173587
The basic idea is as #gumballhead suggests, you are responsible for projecting the pixel values back onto the input domain. The relevant snippet from the example is:
brushed = function(){var selected = yScale.domain().filter(function(d){return (brush.extent()[0] <= yScale(d)) && (yScale(d) <= brush.extent()[1])});
d3.select(".selected").text(selected.join(","));}

Categories

Resources