Preserving zoom after rerender with D3 v5 and React - javascript

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

Related

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.

Three.js: orbitcontrols plus camera tweens = offset headaches

I'm being driven mildly insane looking for a working combination of interactions. I basically need to make something like a google earth style setup, where you can:
orbit round an object, highlighting the centre-most location,
click a menu link and animate rotation of the object to a particular 'location' (highlighting the new location).
I'm using orbitcontrols for the first bit, and was hoping to tween the orbitcontrols directly for the menu link bit, but couldn't get the camera to move in the right path. SO I put the camera inside an object, and whilst orbitcontrols handles the camera, the tweening is done on the object ('camHolder') instead.
So there are two moving parts (cam controlled by user's mouse, camHolder tweened into position by link clicks), and when either one moves, the rotational difference between them changes. In order to highlight the right 'point' between these two rotation values, I need to keep track of the offset between the two. Basically (simplified version of the codepen):
// ------- MOUSE/CAMERA INTERACTION ---------
// location of points (in radians):
var pointLongs=[-3,-2,-2.5,-2,-1.5,-1,-0.5,0,1,2,2.5,3];
// most recent point highlighted (by menu click):
var currentPoint = 5;
// get diff (in radians) between camera and current point
var pointDistance = pointLongs[currentPoint] - camera.rotation.y;
// the offset rotation of cam (i.e. whats closest to the front):
var offset = camera.rotation.y + pointDistance;
// find the closest value to offset in pointLongs array:
var closest = pointLongs.reduce(function (prev, curr) {
return (Math.abs(curr - offset) < Math.abs(prev - offset ) ? curr : prev);
});
closestPointIndex = pointLongs.indexOf(closest);
// highlight that point (raise it up):
scene.getObjectByName(pointNames[closestPointIndex]).position.y = 20;
This seems to work as long as pointDistance is above 0, but if not, the tracking of the current 'point' only works on part of the mouse orbiting circle, when it should work all the way round.
Codepen here: http://codepen.io/anon/pen/BNPWya (the Sole tween code is embedded in there so skip the first chunk...). Try rotating the shape with the mouse, and notice that the points aren't raised all the way around. Click the random / next menu buttons, and the 'gap' changes... Sometimes it does go all the way round!
I've tried changing just about all the values (pointLongs all positive values; initial rotation of camera, etc) but my maths is generally terrible, and I've lost the ability to see straight - anyone have any ideas? Please ask if something doesn't make sense!
I'd add the tag 'HelpMeWestLangleyYoureMyOnlyHope' but I don't have enough reputation :D
TLDR; rotation of object and camera won't 'sync', need to either correct the difference, or maybe find a way to tween position/rotation of orbitcontrols?

Nokia HERE Maps canvas drawing speed

I'm trying to draw a grid of rectangles on top of the map tiles using the Javascript API and highlight (switch fillColor for) whichever rectangle is currently under the mouse pointer. I would expect such a small change to be effective almost immediately.
However the speed at which changes take place is unbearable for something like this, as changes seem to trigger with a delay of maybe 100ms or so. This applies even if I save a reference to one of the rectangles on a 2x2 grid and then change its color from the console. So this seems unlikely (but still possible) to be a performance issue but rather feels like the Maps simply won't refresh often enough.
Is there maybe a way for me to tell the Maps to redraw a region immediately, or should I use some other way of drawing which would be more performant? I currently have a workaround of using a floating div as the highlight, but it feels a bit wrong and comes with other issues to hack around.
rect = new nokia.maps.map.Rectangle(boundingBox, opts)
...
// slow, but not a deal breaker
map.objects.add(rect)
...
// too slow to happen on every mouseenter/mouseleave event
rect.set('fillColor', '000000')
I'm using the 2.5 version of the Javascript API and I'm targeting mostly Chrome.
You could try map.update(-1, true); to force a redraw the map.
Alternatively, one possible performance improvement (which has several caveats) would be to use an overlay for the grid and only one rectangle. This could be of use if you are trying to highlight the current square region of a map tile as served from the TMS server.
You could add a 256x256 grid (or 128x128 or 64x64 etc) using the code in the question here, and then merely move one rectangle over the map to show the current highlight:
For a given zoom and coordinate, the current tile CoordinateZoomToXY is:
var longitude = coord.longitude,
latitude = coord.latitude,
tilesPerRow = Math.pow(2, zoom),
column,
row;
longitude /= 360;
longitude += 0.5;
latitude = 0.5 - ((Math.log(Math.tan((Math.PI / 4) + (latitude * Math.PI / 360))) / Math.PI) / 2.0);
column = Math.floor(longitude * tilesPerRow);
row = Math.floor(latitude * tilesPerRow);
hence the reverse operation (XYZtoCoordinate) is:
var tilesPerRow = Math.pow(2, zoom),
longitude = column / tilesPerRow * 360.0 - 180.0,
lat_rad = Math.atan(sinh(Math.PI * (1 - 2 * row / tilesPerRow))),
latitude = lat_rad * 180.0 / Math.PI;
and the current tile is:
nokia.maps.geo.BoundingBox.coverAll([
XYZtoCoordinate(zoom, column , row),
XYZtoCoordinate(zoom, column + 1, row + 1)]));
If you added this to the listener and just moved one rectangle, it may help as you would only need to update one map object each time.

How can you keep rotated draggable objects inside the Raphael paper?

I have an application with many draggable objects that can also be rotated in 90 degree increments. I'm trying to figure out how to stop the user from dragging the objects outside the Raphael paper (canvas).
This is fairly simple for unrotated objects. I can simply see if the current x and y coordinates are less than 0 and set them to 0 instead. I can adjust similarly by checking if they are outside the canvas width and height.
However, a problem arises when the object is rotated because for some odd reason the coordinate plane rotates as well. Is there an easy way to keep objects inside the canvas? Or is there an example of some this somewhere?
I have spent many hours fiddling with this and I can't seem to make sense of the rotated coordinate plane in order to adjust my calculations. Even when debugging the current coordinates, they seem to shift oddly if I drag an object, release it, and then drag the object again.
Any help is greatly appreciated.
Thanks,
Ryan
I had a similar problem, I needed to move a shape within the boundaries of another shape, so what I did was:
element.drag(onstart, onmove, onend);
...
onStart: function(x,y,e){
// Initialize values so it doesn't recalculate per iteration
// this allows to resume dragging from the point it were left
App.oldX = 0;
App.oldY = 0;
App.currentCircleX = App.fingerPath.attr('cx');
App.currentCircleY = App.fingerPath.attr('cy');
},
onMove: function(dx,dy,x,y,e){
App.setDirection(dx,dy);
},
onEnd: function(e){
// nothing to do here for now
},
// this function tells the element to move only if it's within the bound area
setDirection: function(dx, dy){
var isXYinside;
this.newX = this.currentCircleX - (this.oldX - dx);
this.newY = this.currentCircleY - (this.oldY - dy);
// HERE is the key, this method receives your bounding path and evaluates the positions given and then returns true or false
isXYinside = Raphael.isPointInsidePath(this.viewportPath, this.newX, this.newY);
this.oldX = dx;
this.oldY = dy;
// so if it is within the bound area, will move, otherwise will just stay there
if (isXYinside) {
this.fingerPath.attr({
"cx": this.newX,
"cy": this.newY
});
this.currentCircleX = this.newX;
this.currentCircleY = this.newY;
}
}
I know this is an old one, but I stumbled upon this question when trying to figure out a way to do it. So here's my 2 cents in case someone has this problem.
Reference:
Raphael.isPointInsidePath
Have you tried Element.getBBox()
There Are 2 flavones which give the result before rotation and after rotation
You should toggle the Boolean argument and test it

Categories

Resources