D3 v5 zoom limit pan - d3.js

I am trying to limit pan in d3 zoom but I am not getting correct results. I am using following code to extent both scale and translate.
var treeGroup = d3.select('.treeGroup');
var rootSVG = d3.select('.rootSVG')
var zoom = d3.zoom()
.scaleExtent([1.6285, 3])
.translateExtent([[0, 0],[800, 600]])
.on('zoom', function(){
treeGroup.attr('transform', d3.event.transform);
})
rootSVG.call(zoom);
Here is the JSFiddle: https://jsfiddle.net/nohe76yd/45/
scaleExtent works fine but translateExtent is giving issues. How do I specify correct value for translateExtent so that while panning content always stays inside the svg container?

The translateExtent works best when used dynamically to the graph group you're using. It takes two arguments: topLeft and bottomRight, which are x and y coordinates each.
In my example, I recalculate the extent based on the graph's size, with the help of getBBox() and adding some margins. Take a look, it might help you: https://bl.ocks.org/agnjunio/fd86583e176ecd94d37f3d2de3a56814
EDIT: Adding the code that does this to make easier to read, inside zoom function.
// Define some world boundaries based on the graph total size
// so we don't scroll indefinitely
const graphBox = this.selections.graph.node().getBBox();
const margin = 200;
const worldTopLeft = [graphBox.x - margin, graphBox.y - margin];
const worldBottomRight = [
graphBox.x + graphBox.width + margin,
graphBox.y + graphBox.height + margin
];
this.zoom.translateExtent([worldTopLeft, worldBottomRight]);

Related

d3 zoom: pan axis on scroll, pan axis on drag, zoom axis on pinch

I'm hoping to configure the zoom behavior of a plot to have three kinds of interaction:
It should be possible to pan from left to right with the scroll wheel.
It should be possible to pan from left to right with mousedown drag events.
it should be possible to zoom in 1-D with the pinch event (i.e. scroll wheel with control key pressed).
Right now, I can get the latter two to work in this Fiddle: https://jsfiddle.net/s1t7mrpw/6/
The zoomed function looks like this:
function zoomed() {
if (d3.event.sourceEvent.ctrlKey || d3.event.sourceEvent.type === 'mousemove') {
view.attr("transform", d3.event.transform);
centerline.call(xAxis.scale(d3.event.transform.rescaleX(x)));
d3.event.transform.y = 0;
g.attr("transform", d3.event.transform);
} else {
current_transform = d3.zoomTransform(view);
current_transform.x = current_transform.x - d3.event.sourceEvent.deltaY;
// what do I do here to pan the axis and update `view`'s transform?
// centerline.call(xAxis.scale(d3.event.transform.rescaleX(x))); ?
// view.attr('transform', current_transform); ?
g.attr('transform', current_transform);
}
}
It uses the rescale nomenclature from these blocks:
https://bl.ocks.org/mbostock/db6b4335bf1662b413e7968910104f0f
https://bl.ocks.org/rutgerhofste/5bd5b06f7817f0ff3ba1daa64dee629d
And it uses d3-xyzoom for independent scaling in the x and y directions.
https://bl.ocks.org/etiennecrb/863a08b5be3eafe7f1d61c85d724e6c4
But I can't figure out how to pan the axis in the else bracket, when the user is just scrolling (without pressing the control key).
I had previously used a separate trigger for wheel.zoom but then I need to handle the control key in that other function as well.
I basically want the default zoom behaviors for mousemove and when the control key is pressed, but panning rather than zooming on scroll when the control key is not pressed.
I am using d3-zoom on this answer as it is more common and part of the d3 bundle. d3-xyzoom, as an add-on module, adds some useful functionality using d3-zoom as a base, so this solution should work with xyzoom with minor revisions - but I don't believe its use is necessary
The key challenge lies in using the wheel to apply a translate change not a scale change. Roughly speaking, when a scroll event without control occurs you grab the transform on the selection (this should be the node) and update it by taking the scroll and creating an x offset from it to translate the view rectangle. But, you don't update the zoomTransform to nullify the change in scale caused by the zoom - so when you pan by dragging, the rectangle changes size because you haven't updated the zoomTransform used by the zoom behavior. Likewise, the translate can be off if it doesn't account for scale.
The disassociation between zoom transform applied to an element as a "transform" attribute and the zoom transform tracked by the zoom behavior is the cause of many headaches on SO - but it allows freer implementation options as one not need use an element's transform attribute to apply a zoom behavior (useful in canvas, zooming unorthodox things like color, etc).
I'm going to propose a basic solution here.
First, the zoom transform tracked by the zoom behavior can be updated by modifying the d3.event.transform object itself (or with d3.zoomTransform(selection.node) where selection is the selection that the zoom was called on originally).
Secondly, as the d3.event.transform object registers scrolling as scaling, we need to override this and convert this scaling into translating - when needed. To do so, we need to keep track of the current (pre-event) translate and scale - so we can modify them manually:
// Keep track of zoom state:
var k = 1;
var tx = 0;
function zoomed() {
var t = d3.event.transform;
// If control is not pressed and a wheel was turned, set the scale to last known scale, and modify transform.x by some amount:
if(!d3.event.sourceEvent.ctrlKey && d3.event.sourceEvent.type == "wheel") {
t.k > k ? tx += 40/k : tx -= 40/k;
t.k = k;
t.x = tx;
}
// otherwise, proceed as normal, but track current k and tx:
else {
k = t.k;
tx = t.x;
}
// Apply the transform:
view.attr("transform", "translate("+[t.x,0]+")scale("+[t.k,1]+")");
axisG.call(xAxis.scale(t.rescaleX(xScale)));
}
Above, I modify the transform t.k and t.k for events where wheeling/scrolling should translate. By comparing t.k with the stored k value I can determine direction: would it normally be a zoom out or zoom in event, and then I can convert this into a change in the current translate value. By setting t.k back to the original k value, I can stop the change in scaling that would otherwise occur.
For other zoom events it is business as usual, except, I store the t.x and t.k values for later, for when wheel events should translate rather than scale.
Note: I've set the y scaling and translate in the transform to a hard coded zero, so we don't need to worry about the y component at all.
var width = 500;
var height = 120;
// Keep track of zoom state:
var k = 1;
var tx = 0;
// Some text to show the transform parameters:
var p = d3.select("body")
.append("p");
// Nothing unordinary here:
var svg = d3.select("body")
.append("svg")
.attr("width",width)
.attr("height",height);
var xScale = d3.scaleLinear()
.domain([0,100])
.range([20,width-20])
var xAxis = d3.axisTop(xScale);
var axisG = svg.append("g")
.attr("transform","translate(0,50)")
.call(xAxis);
var view = svg.append("rect")
.attr("width", 20)
.attr("height",20)
.attr("x", width/2)
.attr("y", 60)
var zoom = d3.zoom()
.on("zoom",zoomed);
svg.call(zoom)
function zoomed() {
var t = d3.event.transform;
// If control is not pressed and a wheel was turned, set the scale to last known scale, and modify transform.x by some amount:
if(!d3.event.sourceEvent.ctrlKey && d3.event.sourceEvent.type == "wheel") {
t.k > k ? tx += 40/k : tx -= 40/k;
t.k = k;
t.x = tx;
}
// otherwise, proceed as normal, but track current k and tx:
else {
k = t.k;
tx = t.x;
}
// Apply the transform:
view.attr("transform", "translate("+[t.x,0]+")scale("+[t.k,1]+")");
axisG.call(xAxis.scale(t.rescaleX(xScale)));
// Show current zoom transform:
p.text("transform.k:" + t.k + ", transform.x: " + t.x);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.6.0/d3.min.js"></script>

d3js ordinal scale not able to limit pan

I am trying to limit pan. Not sure how to limit when axis is ordinal scale. For linear scale its fine. The problem is even not zoomed, I am able to pan which leaves empty space as everything moves including axis and if zoomed, I want to limit so that we cannot pan beyond limit.
var translate = zoom.translate(),
scale = zoom.scale();
var tx = Math.min(0, Math.max(translate[0], width - width * scale));
var ty = Math.min(0, Math.max(translate[1], height - height * scale));
Here is jsfiddle:

Zooming or scaling with d3.js

I've created a graph with d3 to show defects on a surface. The surface itself is about 1000 mm wide but could be a few kilometres long. To see the defects more clearly I've implemented d3 zooming, but, sometimes the defects are spread across the x range, so zooming in that far would result in having to scroll from left to right.
Here's a simplified jsFiddle
I could however change the scale to view a specific defect, say one starts at 1000mm and ends at 1500mm I could do:
var yScale = d3.scale.linear()
.domain([1000 - margin, 1500 + margin])
.range([0, pixelHeight]);
But since my defects are rectangles I need to calculate the width with the yScale like this:
.attr("height", function (d) {
return yScale(d.height);
})
Which won't work if I changed the scale's domain (the height could be smaller then the domain value, giving negative values).
So how would I solve this problem? Is there a way to calculate the height of the defect relative to the yScale. Or is there another zooming possibillity?
UPDATE
Following Marks suggestion I implemented it and made a second jsFiddle
The problem I'm facing now is also with the scales. I've tried to fix it a bit but as soon as one uses panning or zooming, the scale functions (xScale and yScale) won't give correct values (mostly negative because it's out of the viewport).
.on('click', function (d) {
d3.selectAll('rect').classed('selected-rect', false);
d3.select(this).classed('selected-rect', true);
var dx = xScale(d.width)*2.2,
dy = yScale(d.height)*2.2,
x = xScale(d.x) ,
y = yScale(d.y) ,
scale = .9 / Math.max(dx / width, dy / pixelHeight),
translate = [pixelWidth / 2 - (scale*1.033) * x,
pixelHeight / 2 - (scale*1.033) * y];
svg.transition()
.duration(750)
.call(zoom.translate(translate).scale(scale).event);
});
So, without panning or zooming and clicking directly, the above code works. Can someone give me a clue on what I did wrong?
Ok i figured it out. You can scale and translate with the zoom function. A good example is the zoom to bounding box example, but that example is based on d3.geo without x and y scaling functions.
With x and y lineair scaled objects you could do this:
.on('click', function (d) {
d3.selectAll('rect').classed('selected-rect', false);
d3.select(this).classed('selected-rect', true);
zoom.translate([0, 0]).scale(1).event;
var dx = xScale(d.width),
dy = yScale(d.height),
x = xScale(d.x) + xScale(d.width/2),
y = yScale(d.y) + yScale(d.height/2),
scale = .85 / Math.max(dx / pixelWidth, dy / pixelHeight),
translate = [pixelWidth / 2 - scale * x,
pixelHeight / 2 - scale * y];
svg.transition()
.duration(750)
.call(zoom.translate(translate).scale(scale).event);
});
First off: you have to reset the translate and scale before doing any xScale and yScale calculations. I just do this by this statement:
zoom.translate([0, 0]).scale(1).event;
dx and dy are the scaled width/height of the objects. To get to the middle of the object, one needs to take the scaled x and y value and add half of the width and height to it. Else it will not center properly ofcourse (i say ofcourse now because i've been fiddling with it).
The scale is calculated by taking the max width or height of the object and devide have 0.85 devided by it to get a little margin around it.
Here's a jsFiddle

How can I get a time axis onto an object, using D3?

I'm very new to d3 and trying to learn by building a visualization.
My goal right now is to make a circle and color the circle based on some temporal data. I've made the circle, and want to add a timescale to it. The circle I have created fine using d3.arc() on an svg element. I have also created a time scale (seen below). My question is, how can I "attach" this time scale to the circle? I want to be able to say that at xyz point in time, my data holds this value, so now color the circle based on a color scale.
Or...am I going about this wrong?
var time = d3.scale.ordinal()
.domain(d3.extent(data, function(d) {
return d.date;
}))
I think you may need to use a quantitative scale instead of ordinal.
https://github.com/mbostock/d3/wiki/Ordinal-Scales says -
Ordinal scales have a discrete domain, such as a set of names or categories
and in your code, you use the "extent" of the date property, which only gives you 2 values - the earliest and most recent date in your data. That is a discrete domain, but a very limited one, and wouldn't represent your data very well. The scale will only output at most 2 values.
var now = Date.now();
var then = now - 1000;
var colors = d3.scale.ordinal()
.domain([then, now])
.range(['#ff0000','#0000ff']);
colors(then); // red
colors(now); // blue
colors(now - 500); // red... expecting violet
change 'ordinal' to 'linear' and leave the rest as is.
var now = Date.now();
var then = now - 1000;
var colors = d3.scale.linear()
.domain([then, now])
.range(['#ff0000','#0000ff']);
colors(then); // red
colors(now); // blue
colors(now - 500); // violet
The tricky part (at least for me) was remembering that the output of d3.scale.linear() (the 'colors' variable above) is a function. It can be called just like any other function.
var fakeData = d3.range(then, now, 10);
var svg = d3.select('body')
.append('svg')
.attr({ height: 500, width: 500 });
var circle = svg.append('circle')
.attr({ r: 100, cx: 250, cy: 250 });
function changeTime(time){
circle.attr('fill', colors(time));
}

d3 pie chart transition with attrtween

i'm trying to somehow sweep in a half-donut-chart, meaning starting with a blank screen the chart starts drawing at -90 degree (or 270) and performs a halfcircle until reaching 90 degree. the code looks like:
var width = 800;
var height = 400;
var radius = 300;
var grad=Math.PI/180;
var data = [30, 14, 4, 4, 5];
var color = d3.scale.category20();
var svg = d3.select("body").append("svg").attr("width", width).attr("height",
`height).append("g").attr("transform", "translate(" + radius*1.5 + "," + radius*1.5 +
")");
var arc = d3.svg.arc().innerRadius(radius - 100).outerRadius(radius - 20);
var pie = d3.layout.pie().sort(null);
svg.selectAll("path").data(pie(data)).enter().append("path").attr("d",
arc).attr("fill",
function(d, i) { return color(i); }).transition().duration(500).attrTween("d", sweep);
function sweep(a) {
var i = d3.interpolate({startAngle: -90*grad, endAngle: -90*grad},{startAngle: -90*grad, endAngle: 90*grad});
return function(t) {
return arc(i(t));
};
}
looking at several examples i managed to get the animation, however, i fail at binding (or converting) the data to the arc. my feeling is that there is only one path drawn and then it stops.
if i change the interpolation to start/end -90/90 and a, i get different colors but not all of them. adding the start/end-angle to the pie-var gives me a transition where a one-colored-arc is shown at the beginning and then the other parts slide in (which would be correct if there was no arc at the beginning - the proportions also seem a bit wrong). setting the initial color to white does not help because then everything stays white.
i'm afraid i'm missing an obvious point, but so far i'm stuck, maybe someone can point me in the right direction.
after quite some variations and tests it somehow started to work, using these to lines of code:
var pie = d3.layout.pie().sort(null).startAngle(-90*grad).endAngle(90*grad);
var i = d3.interpolate({startAngle: -90*grad, endAngle: -90*grad},a);
one final "problem" was that the height of the svg was too small and so some segments got cut off, so changing it to
var height = 800;
ended my search. thanks for any considerations.
A small typo on the
var svg = d3.select("body").append("svg").attr("width", width).attr("height", `height)
should be:
var svg = d3.select("body").append("svg").attr("width", width).attr("height", height)

Resources