d3 synchronizing 2 separate zoom behaviors - d3.js

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.

Related

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.

drawing lots of identical features — what's the best approach?

I need to draw a large number of rectangles (potentially up to millions), which are located all over the world. I was wondering what the optimal approach is to achieve the best possible performance. my requirements are:
all items are rectangles (not squares), and identical in size and color
their rotation is different per individual item though
they have different fixed locations – they do not move
the rectangles need to be pickable
they might need to be scaled according to current zoom level (to make them look like real objects on the ground)
use the webgl renderer
what I have tried to far:
const features = R.map(
(i) => {
// [...] calculate `coords` and `rotation`
const point = new ol.geom.Point(coords);
const feature = new ol.Feature(point);
feature.__angle = rotation;
return feature;
},
R.range(0, count /* lots of them! */)
);
const sheetStyle = new ol.style.Style({
image: new ol.style.Icon({
size: [5, 8], // shape of rectangle
src: 'color.png' // 1×1px image
})
});
const vectorLayer = new ol.layer.Vector({
source: new ol.source.Vector({ features }),
preload: Infinity,
updateWhileAnimating: true,
updateWhileInteracting: true,
style: (feature, resolution) => {
const image = sheetStyle.getImage();
// TODO: is there a way to only have to do this once?
image.setRotation(feature.__angle);
// scale according to zoom level
image.setScale(0.3 / resolution);
return sheetStyle;
},
});
I was wondering if ol3 was doing any sort of optimization under the hood.
does it merge the geometries into one?
does it only display items that in the visible part of the map?
since all items are identical, is there a way to use instancing?
related: for better performance, I am only creating a single style object that I am reusing for all items. however I need to set a rotation on each of them, which is why I am using a style function. once it is set, the rotation won't change anymore though. is there a way around having to call the style function every frame?
I am also considering using a heatmap layer for lower zoom levels and then switching to the vector layer as the user zooms in.
it would be great if someone could give me hints for overall performance improvements.

How to know if user is zooming and in which direction using d3 behavior zoom?

I'm developing an application which use d3 for charting. x axis is time scale. User can zoom in/out to expand or shrink x axis. Now I want to add some logic in the event handler based on the direction user is zooming. But I don't find an easy way to know if user is doing zoom and in which direction.
Can someone share some experience here?
You want to add an event handler to the zoom.
To run a function whenever the zoom changes, you can add an event handler for zoom as follows:
zoom.on("zoom", function () {
var scale = d3.event.scale;
var translate = d3.event.translate;
// Do your processing
});
If you want to tell the direction of the zoom, you can save the translate each time, and compare the current one to the previous.
Direction of zoom : Zooming in or zooming out
In version 4 of d3, ZoomEvent contains WheelEvent as sourceEvent property. So to have the direction i'am doing :
zoom.on("zoom", () => {
-1 * Math.sign(d3.event.sourceEvent.deltaY)
});
d3.event.sourceEvent.deltaY gives nigative values when you zooming in, for me i use negative values when i zoom out that's why i'am multipling by -1.
The value of d3.event.deltaY works only in wheelDelta method of the zoom (see code below) but it gives mes values in v3 when i called it in the handler of zoom.
d3
.zoom()
.wheelDelta((ev, i, g) => {
return -d3.event.deltaY * (d3.event.deltaMode ? 120 : 1) / 500;
})
.on('zoom', () => {
// ...
});

Focus/Context Brushing + Pan/Zoom graph - How to limit panning

I've managed to make a d3.js line+area graph sync with focus/context brushing and pan/zoom, with a small example here:
http://jsfiddle.net/MtXvx/8/
I'm having trouble limiting the panning to stop at the original domain boundaries, while also working nicely with the brush. This is to prevent users from losing the graph in their view.
While I have tried manually detecting when panning has exceeded boundaries and then setting zoom.translate([0,0]), such as in these examples:
d3.js scatter plot - zoom/drag boundaries, zoom buttons, reset zoom, calculate median
Limiting domain when zooming or panning in D3.js
d3.js scatter plot - zoom/drag boundaries, zoom buttons, reset zoom, calculate median
...as I do here at line 183:
//If exceed original domain, limit panning by resetting translate
if (x.domain()[0] < x0.domain()[0]) {
zoom.translate([0, 0]);
}
The problem occurs when:
1) Create a brush region in the small context graph
2) Pan the big focus graph all the way towards the earliest date
3) Graph jumps when panning is almost at the boundary
Would appreciate any help to prevent the jumping from happening, or if there is any other way to limit the panning (and eventually the zooming out too) to the original domain boundaries.
Regarding limiting the zoom-out, setting:
var zoom = d3.behavior.zoom().x(x).scaleExtent([1,10]).on("zoom", zoomed);
...does not work nicely because the zoom-out would be limited to the brush region instead of the full extent of the graph data.
Much thanks!
I had similar problems combining D3 Brushing and Zoom & Pan, but figured it out eventually. I found the key to limit the panning is to reset the translate of the zoom behavior object. Specifically, here is my zoom callback function:
function zoomed() {
var t = d3.event.translate;
var s = d3.event.scale;
var size = width*s;
t[0] = Math.min(t[0], 0);
t[0] = Math.max(t[0], width-size);
zoom.translate(t);
focus.select(".area").attr("d", area);
focus.select(".x.axis").call(xAxis);
var brushExtent = [x.invert(0), x.invert(width)];
context.select(".brush").call(brush.extent(brushExtent));
}
While not part of your question, also an important part to make the whole demo work right is to update the zoom translate and scale when brushing is done, so here is my brushed callback:
function brushed() {
x.domain(brush.empty() ? x2.domain() : brush.extent());
focus.select(".area").attr("d", area);
focus.select(".x.axis").call(xAxis);
var s = x.domain();
var s_orig = x2.domain();
var newS = (s_orig[1]-s_orig[0])/(s[1]-s[0]);
var t = (s[0]-s_orig[0])/(s_orig[1]-s_orig[0]);
var trans = width*newS*t;
zoom.scale(newS);
zoom.translate([-trans,0]);
}
Here is a complete example based one of D3 examples: http://bl.ocks.org/sbreslav/be9af0d809b49864b7d8
to limit the extent of the panning on the graph you could use clamp, although I couldn't see where or if you were using a scale in that fiddle (actually it didn't appear to be working). Here's a simple example in a fiddle

Resources