Tracking d3.zoom transform across page refresh - d3.js

I'm having a problem with tracking Transforms across page loads, any help much appreciated.
'workspaceDiv' is a full page outer div
'squaregroup' is a g that contains all page elements and can be moved around
For this example I've added a single circle to the squaregroup
workspaceDiv = d3.select("#workspaceDiv")
squaregroup = workspaceDiv.append("g")
.attr("id", "squaregroup")
squaregroup.append("circle").attr("cx", 20).attr("cy", 20).attr("r", 10);
To allow the user to move the g around the page I've attached a d3.zoom.
workspaceDiv.call(zoom);
var zoom = d3.zoom()
.on("zoom", zoomed)
function zoomed(){
squaregroup.attr("transform", d3.event.transform)
}
You might have noticed that I want to transform squaregroup but I have attached the d3.zoom to the workspaceDiv. This is so you can transform it by clicking anywhere on the page (and not only by clicking in the small squaregroup).
On initial page load, this works perfect. Any transforms are also saved as a string in the URL successfully.
On a page reload, the transform is taken from the URL and applied to the sqauregroup:
squaregroup.attr("transform", d3.zoomIdentity.translate(url.x,url.y).scale(url.scale))
Chrome devtools showing the custom transform applied after page reload
[2]: https://i.stack.imgur.com/H93Nf.png
The problem
After a page reload, squaregroup is transformed (see image above), but the d3.event.transform of workspaceDiv is reset, meaning the first drag (of 1 pixel), resets transform (to 0,0) and not with the transform I've applied (200,400).
So the 2nd+ drag is fine, but the first drag throws all data off the page meaning you have to drag around until you find it.
Approaches
Attaching ".call(zoom)" on g means the draggable area is too small, and completely changes the behaviour for the user
I can't find a way to force update the tracking of a .event to be in sync after a page reload
I'm not sure if my approach is wrong, or if there is a function of d3.zoom I just can't find. Any input welcomed!
Many Thanks

Here is the solution (see it in a fiddle)
Make sure you are using zoomIdentity according to the D3 V5 and higher (the sample you tried to use is probably done for the previous versions):
const svg = d3.select('svg');
const group = svg.append('g');
group.append('circle').attr('r', 20);
const zoom = d3.zoom();
const onZoom = () => group.attr('transform', d3.event.transform);
zoom.on("zoom", onZoom);
svg.call(zoom);
const transform = d3.zoomIdentity;
transform.x = 100;
transform.y = 50;
group.call(zoom.transform, transform);

Related

d3 synchronizing 2 separate zoom behaviors

I have the following d3/d3fc chart
https://codepen.io/parliament718/pen/BaNQPXx
The chart has a zoom behavior for the main area and a separate zoom behavior for the y-axis.
The y-axis can be dragged to rescale.
The problem I'm having trouble solving is that after dragging the y-axis to rescale and then subsequently panning the chart, there is a "jump" in the chart.
Obviously the 2 zoom behaviors have a disconnect and need to be synchronized but I'm racking my brain trying to fix this.
const mainZoom = zoom()
.on('zoom', () => {
xScale.domain(t.rescaleX(x2).domain());
yScale.domain(t.rescaleY(y2).domain());
});
const yAxisZoom = zoom()
.on('zoom', () => {
const t = event.transform;
yScale.domain(t.rescaleY(y2).domain());
render();
});
const yAxisDrag = drag()
.on('drag', (args) => {
const factor = Math.pow(2, -event.dy * 0.01);
plotArea.call(yAxisZoom.scaleBy, factor);
});
The desired behavior is for zooming, panning, and/or rescaling the axis to always apply the transformation from wherever the previous action finished, without any "jumps".
OK, so I've had another go at this - as mentioned in my previous answer, the biggest issue you need to overcome is that the d3-zoom only permits symmetrical scaling. This is something that has been widely discussed, and I believe Mike Bostock is addressing this in the next release.
So, in order to overcome the issue, you need to use multiple zoom behaviour. I have created a chart that has three, one for each axis and one for the plot area. The X & Y zoom behaviours are used to scale the axes. Whenever a zoom event is raised by the X & Y zoom behaviours, their translation values are copied across to the plot area. Likewise, when a translation occurs on the plot area, the x & y components are copied to the respective axis behaviours.
Scaling on the plot area is a little more complicated as we need to maintain the aspect ratio. In order to achieve this I store the previous zoom transform and use the scale delta to work out a suitable scale to apply to the X & Y zoom behaviours.
For convenience, I've wrapped all of this up into a chart component:
const interactiveChart = (xScale, yScale) => {
const zoom = d3.zoom();
const xZoom = d3.zoom();
const yZoom = d3.zoom();
const chart = fc.chartCartesian(xScale, yScale).decorate(sel => {
const plotAreaNode = sel.select(".plot-area").node();
const xAxisNode = sel.select(".x-axis").node();
const yAxisNode = sel.select(".y-axis").node();
const applyTransform = () => {
// apply the zoom transform from the x-scale
xScale.domain(
d3
.zoomTransform(xAxisNode)
.rescaleX(xScaleOriginal)
.domain()
);
// apply the zoom transform from the y-scale
yScale.domain(
d3
.zoomTransform(yAxisNode)
.rescaleY(yScaleOriginal)
.domain()
);
sel.node().requestRedraw();
};
zoom.on("zoom", () => {
// compute how much the user has zoomed since the last event
const factor = (plotAreaNode.__zoom.k - plotAreaNode.__zoomOld.k) / plotAreaNode.__zoomOld.k;
plotAreaNode.__zoomOld = plotAreaNode.__zoom;
// apply scale to the x & y axis, maintaining their aspect ratio
xAxisNode.__zoom.k = xAxisNode.__zoom.k * (1 + factor);
yAxisNode.__zoom.k = yAxisNode.__zoom.k * (1 + factor);
// apply transform
xAxisNode.__zoom.x = d3.zoomTransform(plotAreaNode).x;
yAxisNode.__zoom.y = d3.zoomTransform(plotAreaNode).y;
applyTransform();
});
xZoom.on("zoom", () => {
plotAreaNode.__zoom.x = d3.zoomTransform(xAxisNode).x;
applyTransform();
});
yZoom.on("zoom", () => {
plotAreaNode.__zoom.y = d3.zoomTransform(yAxisNode).y;
applyTransform();
});
sel
.enter()
.select(".plot-area")
.on("measure.range", () => {
xScaleOriginal.range([0, d3.event.detail.width]);
yScaleOriginal.range([d3.event.detail.height, 0]);
})
.call(zoom);
plotAreaNode.__zoomOld = plotAreaNode.__zoom;
// cannot use enter selection as this pulls data through
sel.selectAll(".y-axis").call(yZoom);
sel.selectAll(".x-axis").call(xZoom);
decorate(sel);
});
let xScaleOriginal = xScale.copy(),
yScaleOriginal = yScale.copy();
let decorate = () => {};
const instance = selection => chart(selection);
// property setters not show
return instance;
};
Here's a pen with the working example:
https://codepen.io/colineberhardt-the-bashful/pen/qBOEEGJ
There are a couple of issues with your code, one which is easy to solve, and one which is not ...
Firstly, the d3-zoom works by storing a transform on the selected DOM element(s) - you can see this via the __zoom property. When the user interacts with the DOM element, this transform is updated and events emitted. Therefore, if you have to different zoom behaviours both of which are controlling the pan / zoom of a single element, you need to keep these transforms synchronised.
You can copy the transform as follows:
selection.call(zoom.transform, d3.event.transform);
However, this will also cause zoom events to be fired from the target behaviour also.
An alternative is to copy directly to the 'stashed' transform property:
selection.node().__zoom = d3.event.transform;
However, there is a bigger problem with what you are trying to achieve. The d3-zoom transform is stored as 3 components of a transformation matrix:
https://github.com/d3/d3-zoom#zoomTransform
As a result, the zoom can only represent a symmetrical scaling together with a translation. Your asymmetrical zoom as a applied to the x-axis cannot be faithfully represented by this transform and re-applied to the plot-area.
This is an upcoming feature, as already noted by #ColinE. The original code is always doing a "temporal zoom" that is un-synced from the transform matrix.
The best workaround is to tweak the xExtent range so that the graph believes that there are additional candles on the sides. This can be achieved by adding pads to the sides. The accessors, instead of being,
[d => d.date]
becomes,
[
() => new Date(taken[0].date.addDays(-xZoom)), // Left pad
d => d.date,
() => new Date(taken[taken.length - 1].date.addDays(xZoom)) // Right pad
]
Sidenote: Note that there is a pad function that should do that but for some reason it works only once and never updates again that's why it is added as an accessors.
Sidenote 2: Function addDays added as a prototype (not the best thing to do) just for simplicity.
Now the zoom event modifies our X zoom factor, xZoom,
zoomFactor = Math.sign(d3.event.sourceEvent.wheelDelta) * -5;
if (zoomFactor) xZoom += zoomFactor;
It is important to read the differential directly from wheelDelta. This is where the unsupported feature is: We can't read from t.x as it will change even if you drag the Y axis.
Finally, recalculate chart.xDomain(xExtent(data.series)); so that the new extent is available.
See the working demo without the jump here: https://codepen.io/adelriosantiago/pen/QWjwRXa?editors=0011
Fixed: Zoom reversing, improved behaviour on trackpad.
Technically you could also tweak yExtent by adding extra d.high and d.low's. Or even both xExtent and yExtent to avoid using the transform matrix at all.
A solution is given here https://observablehq.com/#d3/x-y-zoom
It uses a main zoom behavior that gets the gestures, and two ancillary zooms that store the transforms.

d3.brush does not work in Firefox or Edge when embedded

I'm using the Swiper API. When there's any d3 Chart embedded, the brush receives senseless mouse coordinates, in any case not relative to the container where the click occurs. (That's in fact at least the surrounded svg.)
I'm trying to find a solution but I don't know how I can force d3.brushX to use mouse coordinates which are really relative.
I don't know whether this is a bug or not, it has probably not really to do with the brush itself, rather how the browser pass mouse clicks top-down the DIV's until the SVG will be reached.
Here's the Fiddle.
(just for the annoying code rule:)
// Add brushing
var brush = d3.brushX()
The second slide contains an embedded d3 line chart example, taken from here.
The fiddle works only in Chrome 75+.
Not in Firefox 68+ nor in Edge 44+.
Running the chart example standalone, it works in all available browsers. So I designate this post for Swiper and D3 hopefully to get a hint for an solution.
According to the problem here I found out, that I can change the behavior in the point.js routines an an workaround.
If a D3 chart makes use of an brush **and ** the SVG of the chart element is embedded by a surrounding DIV with an explicite width, mouse clicks will not be interpreted correctly in Firefox or Edge. In Chrome it works perfectly.
I changed the code like this to achieve that it works in FF and Edge, but lose functionality with Chrome:
function reverseTraversal(node,targetTagName) {
var p = node;
while(p.tagName != targetTagName) p = p.parentNode;
return p;
}
function point(node, event) {
var svg = node.ownerSVGElement || node;
if (svg.createSVGPoint) {
var point = svg.createSVGPoint();
var p = reverseTraversal(node,"DIV");
var rect = p.getBoundingClientRect();
point.x = event.clientX + rect.width, point.y = event.clientY;
point = point.matrixTransform(node.getScreenCTM().inverse());
return [point.x, point.y];
}
var rect = node.getBoundingClientRect();
return [event.clientX - rect.left - node.clientLeft, event.clientY - rect.top - node.clientTop];
}
As you can see, I have to traverse backwards until the closest DIV will be reached, get the bounding and add its width to the clientX coordinate.
Without adding the fixed width, the brush is unusable in the particular case.
To get working with all the browsers, maybe a switch is necessary.
It's not a perfect solution, just a workaround for d3.brushX behavior.

How to apply brushing on a dynamically growing dataset?

I have a dynamically growing timeseries I need to display in a zoomable/panable chart.
Try it out here (in fact: my first jsFiddle ever :) ) :
https://jsfiddle.net/Herkules001/L12k5zwx/29/
I tried to do it the same way as described here: https://dc-js.github.io/dc.js/examples/replacing-data.html
However, each time the chart updates, the zoom and filter are lost on the focus chart. (The brush is preserved on the range chart however.)
How can I add data without resetting the views and losing the zoom?
var chart = dc.lineChart("#test");
var zoom = dc.lineChart("#zoom");
//d3.csv("morley.csv", function(error, experiments) {
var experiments = d3.csvParse(d3.select('pre#data').text());
experiments.forEach(function(x) {
x.Speed = +x.Speed;
});
var ndx = crossfilter(experiments),
runDimension = ndx.dimension(function(d) {return +d.Run;}),
speedSumGroup = runDimension.group().reduceSum(function(d) {return d.Speed * d.Run / 1000;});
chart
.width(768)
.height(400)
.x(d3.scaleLinear().domain([6,20]))
.brushOn(false)
.yAxisLabel("This is the Y Axis!")
.dimension(runDimension)
.group(speedSumGroup)
.rangeChart(zoom);
zoom
.width(768)
.height(80)
.x(d3.scaleLinear().domain([6,20]))
.brushOn(true)
.yAxisLabel("")
.dimension(runDimension)
.group(speedSumGroup);
zoom.render();
chart.render();
var run = 21;
setInterval(
() => {
var chartfilter = chart.filters();
var zoomfilter = zoom.filters();
chart.filter(null);
zoom.filter(null);
ndx.add([{Expt: 6, Run: run++, Speed: 100 + 5 * run}]);
chart.x(d3.scaleLinear().domain([6,run]));
zoom.x(d3.scaleLinear().domain([6,run]));
chart.filter([chartfilter]);
zoom.filter([zoomfilter]);
chart.render();
zoom.render();
},
1000);
//});
In this case, if you are just adding data, you don't need to do the complicated clearing and restoring of filters demonstrated in the example you cited.
That part is only necessary because crossfilter.remove() originally would remove the data that matched the current filters. An awkward interface, almost never what you want.
If you're only adding data, you don't have to worry about any of that:
setInterval(
() => {
ndx.add([{Expt: 6, Run: run++, Speed: 5000 + 5 * run}]);
chart.redraw();
zoom.redraw();
},
5000);
Note that you'll get less flicker, and decent animated transitions, by using redraw instead of render. I also added evadeDomainFilter to avoid lines being clipped before the edge of the chart.
Fork of your fiddle
Removing data
If you use the predicate form of crossfilter.remove() you don't have to worry about saving and restoring filters:
ndx.remove(d => d.Run < run-20);
However, this does expose other bugs in dc.js. Seems like elasticY does not work, similar to what's described in this issue. And you get some weird animations.
Here's a demo with remove enabled.
In the end, dc.js has some pretty neat features, and there is usually a way to get it to do what you want, but it sure is quirky. It's a very complicated domain and in my experience you are going to find some of these quirks in any fully featured charting library.
Update: I fixed the replacing data example, that one is just ndx.remove(() => true) now.
zooming issues
As Joerg pointed out in the comments,
when the chart is not zoomed, it would be nice to have it also grow to show new data as it arrives
the X domain was clipped or even reversed if the focus reached outside the original domain of the chart
We can address these issues by adding a preRedraw event handler. That's the ideal place to adjust the domain; for example you can implement elasticX manually if you need to. (As you'll see in a second, we do!)
First, a naive attempt that's easy to understand:
chart.on('preRedraw', () => {
chart.elasticX(!zoom.filters().length);
});
We can turn elasticX on and off based on whether the range chart has an active filter.
This works and it's nice and simple, but why does the chart get so confused when you try to focus on a domain that wasn't in the original chart?
Welp, it records the original domain (source). So that it can restore to that domain if the focus is cleared, and also to stop you from zooming or panning past the edge of the graph.
But notice from the source link above that we have an escape hatch. It records the original domain when the X scale is set. So, instead of setting elasticX, we can calculate the extent of the data, set the domain of the scale, and tell the chart that the scale is new:
chart.on('preRedraw', () => {
if(!zoom.filters().length) {
var xExtent = d3.extent(speedSumGroup.all(), kv => kv.key);
chart.x(chart.x().domain(xExtent));
}
});
New fiddle with zooming issues fixed.
There is still one glitch which Joerg points out: if you are moving the brush while data comes in, the brush handles occasionally will occasionally stray from the ends of the brush. In my experience, these kinds of glitches are pretty common in D3 (and dynamic charting in general), because it's difficult to think about data changing during user interaction. It probably could be fixed inside the library (perhaps an interrupted transition?) but I'm not going to get into that here.

How to reset/remove zoom transform stored in an element in d3 v4?

I am trying to implement both zooming and brushing in my d3 (v4) chart.
I have got them both working separately, but my problem comes when I try to implement both features on the same chart.
My scenario is the following:
1. The user uses the brush to show a specific region of the chart.
2. They then zoom/pan, but this causes the view to jump back to the old location, because the stored zoom transform is not aware of the changes made by the brushing.
My understanding is that the current zoom transform (scale+translation) is stored inside the DOM element in an internal __zoom attribute. The zoom plugin automatically adjusts this whenever you interact with the element (e.g. by scrolling the mouse wheel).
I see that you can use d3.zoomTransform to get the current zoom transform for an element.
How can I reset/remove the stored zoom transform (e.g. after panning, so that any subsequent zooming carries on from where the brushing left off)?
Note: I don't want to have to change the zoom, but rather just update the stored zoom transform to treat that new scale as the "identity". This is important because I want to be able to smoothly transition from one scale to another when brushing etc.
The way I got around this in the end is:
in the zoom handler, use transform.rescaleX() to get a new transformed scale
Update the main scale's domain based on the transformed scale
Update the x-axis based on the scale
Reset the transform on the element to d3.zoomIdentity.
The key thing here is that after the scale has been updated, the stored transform on the DOM element is always put back to identity (i.e. scale=1, translate=0,0).
That means that we don't need to worry about brushing/zooming or any programatic changes to the scale on different elements won't conflict or have different values from each other. We effectively just keep applying very small scale factors to the element.
In terms of a code example, here are the relevant parts from my working chart:
// class contains:
// this.xScale - stored scale for x-axis
// this.xAxis - a d3 Axis
// this.xAxisElement - a d3 selection for the element on which the x-axis is drawn
// this.zoomX - a d3 ZoomBehavior
// this.chartElement - a d3 selection for the element on which the zooming is added
protected setupZooming(): void {
this.zoomX = d3.zoom().on('zoom', () => { this.onZoomX(); });
this.zoomXElement = this.xAxisElement
.append('rect')
.attr('fill', 'none')
.style('pointer-events', 'all')
.attr('width', this.width)
.attr('height', this.margin.bottom)
.call(this.zoomX);
}
onZoomX(): void {
const transform: d3.ZoomTransform = d3.event.transform;
if (transform.k === 1 && transform.x === 0 && transform.y === 0) {
return;
}
const transformedXScale = transform.rescaleX<any>(this.xScale);
const from = transformedXScale.domain()[0];
const to = transformedXScale.domain()[1];
this.zoomXTo(from, to, false);
this.chartElement.call(this.zoomX.transform, d3.zoomIdentity);
}
zoomXTo(x0: Date, x1: Date, animate: boolean): void {
const transitionSpeed = animate ? 750 : 0;
this.xScale.domain([x0, x1]);
this.xAxisElement.transition().duration(transitionSpeed).call(this.xAxis);
this.updateData(transitionSpeed);
}
updateData(transitionSpeed: number): void {
// ...
}
Apologies if this extract isn't easy to follow outside of the context of the rest of my code, but hopefully it is still helpful.

Draw Element's Contents onto a Canvas Element / Capture Website as image using (?) language

I asked a question on SO about compiling an image file from HTML. Michaƫl Witrant responded and told me about the canvas element and html5.
I'm looked on the net and SO, but i haven't found anything regarding drawing a misc element's contents onto a canvas. Is this possible?
For example, say i have a div with a background image. Is there a way to get this element and it's background image 'onto' the canvas? I ask because i found a script that allows one to save the canvas element as a PNG, but what i really want to do is save a collection of DOM elements as an image.
EDIT
It doesn't matter what language, if it could work, i'm willing to attempt it.
For the record, drawWindow only works in Firefox.
This code will only work locally and not on the internet, using drawWindow with an external element creates a security exception.
You'll have to provide us with a lot more context before we can answer anything else.
http://cutycapt.sourceforge.net/
CutyCapt is a command line utility that uses Webkit to render HTML into PNG, PDF, SVG, etc. You would need to interface with it somehow (such as a shell_exec in PHP), but it is pretty robust. Sites render exactly as they do in Webkit browsers.
I've not used CutyCapt specifically, but it came to me highly recommended. And I have used a similar product called WkHtmlToPdf, which has been awesome in my personal experience.
After many attempts using drawWindow parameters, that were drawing wrong parts or the element, I managed to do it with a two steps processing : first capture the whole page in a canvas, then draw a part of this canvas in another one.
This was done in a XUL extension. drawWindow will not work in other browsers, and may not work in a non-privileged context due to security reasons.
function nodeScreenshot(aSaveLocation, aFileName, aDocument, aCSSSelector) {
var doc = aDocument;
var win = doc.defaultView;
var body = doc.body;
var html = doc.documentElement;
var selection = aCSSSelector
? Array.prototype.slice.call(doc.querySelectorAll(aCSSSelector))
: [];
var coords = {
top: 0,
left: 0,
width: Math.max(body.scrollWidth, body.offsetWidth,
html.clientWidth, html.scrollWidth, html.offsetWidth),
height: Math.max(body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight)
var canvas = document.createElement("canvas");
canvas.width = coords.width;
canvas.height = coords.height;
var context = canvas.getContext("2d");
// Draw the whole page
// coords.top and left are 0 here, I tried to pass the result of
// getBoundingClientRect() here but drawWindow was drawing another part,
// maybe because of a margin/padding/position ? Didn't solve it.
context.drawWindow(win, coords.top, coords.left,
coords.width, coords.height, 'rgb(255,255,255)');
if (selection.length) {
var nodeCoords = selection[0].getBoundingClientRect();
var tempCanvas = document.createElement("canvas");
var tempContext = tempCanvas.getContext("2d");
tempCanvas.width = nodeCoords.width;
tempCanvas.height = nodeCoords.height;
// Draw the node part from the whole page canvas into another canvas
// void ctx.drawImage(image, sx, sy, sLargeur, sHauteur,
dx, dy, dLargeur, dHauteur)
tempContext.drawImage(canvas,
nodeCoords.left, nodeCoords.top, nodeCoords.width, nodeCoords.height,
0, 0, nodeCoords.width, nodeCoords.height);
canvas = tempCanvas;
context = tempContext;
}
var dataURL = canvas.toDataURL('image/jpeg', 0.95);
return dataURL;
}

Resources