Is there a way to limit the size of a brush, even though the extent is larger?
I put together a brush with only an x-scale that can be moved and resized. I would like to be able to limit the extent to which it can be resized by the user (basically only up to a certain point).
In the following example, the brush function stops updating when the brush gets bigger than half the maximum extent. The brush itself, though, can still be extended. Is there a way to prevent this from happening? Or is there a better way of handling this?
Many thanks!
See this code in action here: http://bl.ocks.org/3691274 (EDIT: This demo now works)
bar = function(range) {
var x_range = d3.scale.linear()
.domain([0, range.length])
.range([0, width]);
svg.selectAll("rect.items").remove();
svg.selectAll("rect.items")
.data(range)
.enter().append("svg:rect")
.attr("class", "items")
.attr("x", function(d, i) {return x_range(i);})
.attr("y", 0)
.attr("width", width/range.length-2)
.attr("height", 100)
.attr("fill", function(d) {return d})
.attr("title", function(d) {return d});
}
var start = 21;
bar(data.slice(0, start), true);
var control_x_range = d3.scale.linear()
.domain([0, data.length])
.range([0, width]);
controlBar = svg.selectAll("rect.itemsControl")
.data(data)
.enter().append("svg:rect")
.attr("class", "itemsControl")
.attr("x", function(d, i) {return control_x_range(i);})
.attr("y", 110)
.attr("width", width/data.length-2)
.attr("height", 20)
.attr("fill", function(d) {return d});
svg.append("g")
.attr("class", "brush")
.call(d3.svg.brush().x(d3.scale.linear().range([0, width]))
.extent([0,1*start/data.length])
.on("brush", brush))
.selectAll("rect")
.attr("y", 110)
.attr("height", 20);
function brush() {
var s = d3.event.target.extent();
if (s[1]-s[0] < 0.5) {
var start = Math.round((data.length-1)*s[0]);
var end = Math.round((data.length-1)*s[1]);
bar(data.slice(start,end));
};
}
I eventually solved it by redrawing the brush to its maximum allowed size when the size is exceeded:
function brush() {
var s = d3.event.target.extent();
if (s[1]-s[0] < 0.5) {
var start = Math.round((data.length-1)*s[0]);
var end = Math.round((data.length-1)*s[1]);
bar(data.slice(start,end));
}
else {d3.event.target.extent([s[0],s[0]+0.5]); d3.event.target(d3.select(this));}
}
Demo: http://bl.ocks.org/3691274
I'm still interested in reading better solutions.
Here's another strategy using d3.v4 an ES6:
brush.on('end', () => {
if (d3.event.selection[1] - d3.event.selection[0] > maxSelectionSize) {
// selection is too large; animate back down to a more reasonable size
let brushCenter = d3.event.selection[0] + 0.5 * (d3.event.selection[1] - d3.event.selection[0]);
brushSel.transition()
.duration(400)
.call(brush.move, [
brushCenter - 0.49 * maxSelectionSize,
brushCenter + 0.49 * maxSelectionSize
]);
} else {
// valid selection, do stuff
}
});
If the brush selection size is too large when the user lets go of it, it animates back down to the specified maximum size (maxSelectionSize). At that point, the 'end' event will fire again, with an acceptable selection size.
Note the 0.49 scalar: this is required to prevent floating point / rounding errors that could cause an infinite loop if the brush is move()d to a size that is still too large.
Here is an example of limiting the brush's minimum width as 100px and maximum width as 200ps. I added a few lines into d3.v4.js, for setting the limitation of the brush width.
Added brush.limit([min, max]) for set the limitation:
var _limit = null;
brush.limit = function (l) {
_limit = l;
}
Break mouse move event in move() function:
if (_limit && e1 - w1 < _limit[0]) {
return;
}
(Demo) (Source Code)
Nice piece of code, but I found a little bug.
It does lock your brush whenever s[1]-s[0] < 0.5, but if you keep pressed the resize and bring all your brush to the oposite direction, it starts "moving" the brush without any action (i.e. it doesn't behave as it should).
I am pretty sure I can come up with a reasonable solution. If so, I'll re-post it here.
(sorry to post it here as an answer, but as you've already answered it once, I cannot post as a comment, I guess).
Related
I am creating a bar graph and I would like to have a focus feature in it. So whenever I select, mouseover event, a particular bar, the width and height of bar increase and everything else remains the same making this bar more in focus. Something like this :-
Lets say if I hover mouse on 2nd bar, it should look like this :-
Is is possible to leverage focus and zoom functionality of d3.js?
Threw something together for you https://jsfiddle.net/guanzo/h1hdet8d/1/
It doesn't account for the axis/labels at all, but it should get you started. The idea is that on hover you increase the scale of the bar. Calculate how much more width it has, then divide that by 2 to get how much you should shift the other bars.
Important: Apply .style('transform-origin','bottom') to the bars so that they grow upward and to both sides evenly.
g.selectAll(".bar")
.data(data)
.enter().append("rect")
.attr("class", "bar")
.attr("x", function(d) { return x(d.letter); })
.attr("y", function(d) { return y(d.frequency); })
.attr("width", x.bandwidth())
.attr("height", function(d) { return height - y(d.frequency); })
.style('transform-origin','bottom')
.on('mouseover',mouseover)
.on('mouseout',mouseout)
function mouseover(data,index){
var bar = d3.select(this)
var width = bar.attr('width')
var height = bar.attr('height')
var scale = 1.5;
var newWidth = width* scale;
var newHeight = height*scale;
var shift = (newWidth - width)/2
bar.transition()
.style('transform','scale('+scale+')')
d3.selectAll('.bar')
.filter((d,i)=> i < index)
.transition()
.style('transform','translateX(-'+shift+'px)')
d3.selectAll('.bar')
.filter((d,i)=> i > index)
.transition()
.style('transform','translateX('+shift+'px)')
}
function mouseout(data,index){
d3.select(this).transition().style('transform','scale(1)')
d3.selectAll('.bar')
.filter(d=>d.letter !== data.letter)
.transition()
.style('transform','translateX(0)')
}
I have adapted Christopher Manning's force-directed graph on a sphere. I would like to have the graph settle down and then rotate the sphere without changing the relationships among the points in the graph. Instead, when I drag, it seems to drag the graph, and not rotate the sphere. Dragging the graph activates the force start. If I turn off force.start() nothing changes.
var svg = d3.select("#cluster").append("svg")
.attr("width", width)
.attr("height", height)
.call(d3.behavior.drag()
.origin(function() { var r = projection.rotate(); return {x: 2 * r[0], y: -2 * r[1]}; })
.on("drag", function() { force.start(); var r = [d3.event.x / 2, -d3.event.y / 2, projection.rotate()[2]]; t0 = Date.now(); origin = r; projection.rotate(r); }))
From http://bl.ocks.org/mbostock/3795040, I found that I could rotate the graticule, but then all of my links and nodes disappear.
var path = d3.geo.path()
.projection(projection);
var λ = d3.scale.linear()
.domain([0, width])
.range([-180, 180]);
var φ = d3.scale.linear()
.domain([0, height])
.range([90, -90]);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
svg.on("mousemove", function() {
var p = d3.mouse(this);
projection.rotate([λ(p[0]), φ(p[1])]);
svg.selectAll("path").attr("d", path);
});
Links and nodes get added like this:
var link = svg.selectAll("path.link")
.data(graph.links)
.enter().append("path").attr("class", "link")
.attr ("stroke-width", function(d){return d.value/3});
var node = svg.selectAll("path.node")
.data(graph.nodes)
.enter()
.append("g")
.attr("class", "gnode")
.attr("text-anchor", "middle")
.append("path").attr("class", "node")
.style("fill", function(d) { return d.color; })
.style("stroke", function(d) { return d3.rgb(fill(d.group)).darker(); })
What I would like to accomplish: when the graph settles into position, I would like to use the drag gesture to rotate the sphere with the nodes and links on it.
How do I make this happen?
This could be done by adjusting the handler registered for the drag event on the svg which is defined in your first snippet. This requires two edits:
Get rid of force.start() because you don't want to restart the force on drag.
Trigger the rendering of nodes and links after the projection has been updated, which can easily be done by calling tick(). If the force had not been deactivated by step 1., this would be called repeatedly because the function is registered as the handler for the force layout's tick event. Now, that you have deactivated the force, you will have to call it explicitely.
The reformatted code will look like:
.on("drag", function() {
//force.start(); // 1. Don't restart the force.
var r = [d3.event.x / 2, -d3.event.y / 2, projection.rotate()[2]];
t0 = Date.now();
origin = r;
projection.rotate(r);
tick(); // 2. Trigger the rendering after adjusting the projection.
}))
Have a look at this working example.
This may rotate along one axis
var rotateScale = d3.scale.linear().domain([0, width]).range([-180,180]);//width of SVG
d3.select("svg").on("mousedown",startRotating).on("mouseup",stopRotating);
function startRotating() {
d3.select("svg").on("mousemove",function() {
var p = d3.mouse(this);
projection.rotate([rotateScale(p[0]),0]);
});
}
function stopRotating() {
d3.select("svg").on("mousemove",null);
}
I have 2 issues I want to fix with my current d3 app which is based off this:
Here's the fiddle: http://jsfiddle.net/zoa5m20z/
I want to initialize my brush so that only a small specific portion is brushed by default when the app starts up. I tried the following with .extent but with no luck.
//date parsing function
var parseDate = d3.time.format("%Y-%m-%d %H:%M:%S").parse;
//setting up brush and defaultExtent
var brush = d3.svg.brush()
.x(x2)
.extent([parseDate("2014-08-11 10:20:00"), parseDate("2014-08-11 18:20:00")]) //trying to intialize brush
.on("brush", brushed);
I want to stop my plotted circles from overlapping with the yaxis. I'd like to only plot/show circles to the right of the y-axis when the brush is moved. Just like the canonical Bostock Focus+Context via Brushing Block. I tried playing with the margins and the scales, domains, ranges but to no avail yet.
What I'm trying to avoid:
I'm new to d3.js, so all tips & advice is welcome and appreciated!
For your first question, you need to call brush.event to get the brush to pick up the new extent.
brush = d3.svg.brush()
.x(x)
.extent([config.start, d3.time.day.offset(config.start, config.range)])
.on("brush", brushmove)
.on("brushend", brushend);
gBrush = svg.select('.brush')
.call(brush);
gBrush.call(brush.event);
For your second question, I usually just filter out the data that is outside the brush extent such that I am only drawing the visible data. Here is an example that would be called on your brush move/zoom event:
// update the data so that only visible points are shown
var points= svg.selectAll('.point')
.data(function(d) { return onScreen(d, brush.extent); },
function(d) { return d.id; });
// draw the points that are now onscreen
var pointsEnter = points.enter().append('g').attr('class', 'point');
// remove any points that are now offscreen
points.exit().remove();
// up date the x/y position of your points based on the new axis.
// ... left up to you
It's helpful to have a unique id for the points so that they can just be translated to their new positions as the brush moves instead of having to destroy them and redraw them.
I have an example that uses these techniques at http://bl.ocks.org/bunkat/1962173.
Here is a working example based on your code: http://jsfiddle.net/26sd8uc9/4/
1 - You are right about the .extent, the problem is that you haven't specify the domain for you x2 scale. By adding the following code it works:
x2 = d3.time.scale()
.domain([
parseDate("2014-08-11 05:30:00"),
parseDate("2014-08-12 19:25:00")
])
.nice(d3.time.minute)
.range([0, width]);
And to initialize the circles, you also have to call the brushed event after creating the brush by adding .call(brush.event):
// brush slider display
context.append("g")
.attr("class", "x brush")
.call(brush)
.call(brush.event)
.selectAll("rect")
.attr("y", -6)
.attr("height", height2 + 7);
2 - use a variable to keep track where is the current range under the brush, and hide the circles that are not in the range by setting the radius to zero(alternatively you can set the visibility)
var currentRange;
var inRange = function(d) {
if(!currentRange || d.time < currentRange[0] || d.time > currentRange[1] ) {
return 0;
} else {
return 5;
}
}
function brushed() {
currentRange = (brush.empty()? undefined : brush.extent());
x.domain(brush.empty() ? x2.domain() : brush.extent());
focus.select(".x.axis").call(xAxis);
mydots.selectAll(".circle")
.attr("cx", xMap)
.attr("cy", yMap)
.attr("r", inRange); // set radius to zero if it's not in range
console.log(brush.extent())
}
For the scale definition, it will be better to write something like this:
.domain([
d3.min(dataset, function(d){ return d.time; }),
d3.max(dataset, function(d){ return d.time; })
])
In your example, you have to make sure to parse and initialize the data before you do this.
I've built a d3.js scatter plot with zoom/pan functionality. You can see the full thing here (click 'Open in a new window' to see the whole thing):
http://bl.ocks.org/129f64bfa2b0d48d27c9
There are a couple of features that I've been unable to figure out, that I'd love a hand with it if someone can point me in the right direction:
I want to apply X/Y zoom/pan boundaries to the area, so that you can't drag it below a certain point (e.g. zero).
I've also made a stab at creating Google Maps style +/- zoom buttons, without any success. Any ideas?
Much less importantly, there are also a couple of areas where I've figured out a solution but it's very rough, so if you have a better solution then please do let me know:
I've added a 'reset zoom' button but it merely deletes the graph and generates a new one in its place, rather than actually zooming the objects. Ideally it should actually reset the zoom.
I've written my own function to calculate the median of the X and Y data. However I'm sure that there must be a better way to do this with d3.median but I can't figure out how to make it work.
var xMed = median(_.map(data,function(d){ return d.TotalEmployed2011;}));
var yMed = median(_.map(data,function(d){ return d.MedianSalary2011;}));
function median(values) {
values.sort( function(a,b) {return a - b;} );
var half = Math.floor(values.length/2);
if(values.length % 2)
return values[half];
else
return (parseFloat(values[half-1]) + parseFloat(values[half])) / 2.0;
};
A very simplified (i.e. old) version of the JS is below. You can find the full script at https://gist.github.com/richardwestenra/129f64bfa2b0d48d27c9#file-main-js
d3.csv("js/AllOccupations.csv", function(data) {
var margin = {top: 30, right: 10, bottom: 50, left: 60},
width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var xMax = d3.max(data, function(d) { return +d.TotalEmployed2011; }),
xMin = 0,
yMax = d3.max(data, function(d) { return +d.MedianSalary2011; }),
yMin = 0;
//Define scales
var x = d3.scale.linear()
.domain([xMin, xMax])
.range([0, width]);
var y = d3.scale.linear()
.domain([yMin, yMax])
.range([height, 0]);
var colourScale = function(val){
var colours = ['#9d3d38','#c5653a','#f9b743','#9bd6d7'];
if (val > 30) {
return colours[0];
} else if (val > 10) {
return colours[1];
} else if (val > 0) {
return colours[2];
} else {
return colours[3];
}
};
//Define X axis
var xAxis = d3.svg.axis()
.scale(x)
.orient("bottom")
.tickSize(-height)
.tickFormat(d3.format("s"));
//Define Y axis
var yAxis = d3.svg.axis()
.scale(y)
.orient("left")
.ticks(5)
.tickSize(-width)
.tickFormat(d3.format("s"));
var svg = d3.select("#chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.call(d3.behavior.zoom().x(x).y(y).scaleExtent([1, 8]).on("zoom", zoom));
svg.append("rect")
.attr("width", width)
.attr("height", height);
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
svg.append("g")
.attr("class", "y axis")
.call(yAxis);
// Create points
svg.selectAll("polygon")
.data(data)
.enter()
.append("polygon")
.attr("transform", function(d, i) {
return "translate("+x(d.TotalEmployed2011)+","+y(d.MedianSalary2011)+")";
})
.attr('points','4.569,2.637 0,5.276 -4.569,2.637 -4.569,-2.637 0,-5.276 4.569,-2.637')
.attr("opacity","0.8")
.attr("fill",function(d) {
return colourScale(d.ProjectedGrowth2020);
});
// Create X Axis label
svg.append("text")
.attr("class", "x label")
.attr("text-anchor", "end")
.attr("x", width)
.attr("y", height + margin.bottom - 10)
.text("Total Employment in 2011");
// Create Y Axis label
svg.append("text")
.attr("class", "y label")
.attr("text-anchor", "end")
.attr("y", -margin.left)
.attr("x", 0)
.attr("dy", ".75em")
.attr("transform", "rotate(-90)")
.text("Median Annual Salary in 2011 ($)");
function zoom() {
svg.select(".x.axis").call(xAxis);
svg.select(".y.axis").call(yAxis);
svg.selectAll("polygon")
.attr("transform", function(d) {
return "translate("+x(d.TotalEmployed2011)+","+y(d.MedianSalary2011)+")";
});
};
}
});
Any help would be massively appreciated. Thanks!
Edit: Here is a summary of the fixes I used, based on Superboggly's suggestions below:
// Zoom in/out buttons:
d3.select('#zoomIn').on('click',function(){
d3.event.preventDefault();
if (zm.scale()< maxScale) {
zm.translate([trans(0,-10),trans(1,-350)]);
zm.scale(zm.scale()*2);
zoom();
}
});
d3.select('#zoomOut').on('click',function(){
d3.event.preventDefault();
if (zm.scale()> minScale) {
zm.scale(zm.scale()*0.5);
zm.translate([trans(0,10),trans(1,350)]);
zoom();
}
});
// Reset zoom button:
d3.select('#zoomReset').on('click',function(){
d3.event.preventDefault();
zm.scale(1);
zm.translate([0,0]);
zoom();
});
function zoom() {
// To restrict translation to 0 value
if(y.domain()[0] < 0 && x.domain()[0] < 0) {
zm.translate([0, height * (1 - zm.scale())]);
} else if(y.domain()[0] < 0) {
zm.translate([d3.event.translate[0], height * (1 - zm.scale())]);
} else if(x.domain()[0] < 0) {
zm.translate([0, d3.event.translate[1]]);
}
...
};
The zoom translation that I used is very ad hoc and basically uses abitrary constants to keep the positioning more or less in the right place. It's not ideal, and I'd be willing to entertain suggestions for a more universally sound technique. However, it works well enough in this case.
To start with the median function just takes an array and an optional accessor. So you can use it the same way you use max:
var med = d3.median(data, function(d) { return +d.TotalEmployed2011; });
As for the others if you pull out your zoom behaviour you can control it a bit better. So for example instead of
var svg = d3.select()...call(d3.behavior.zoom()...)
try:
var zm = d3.behavior.zoom().x(x).y(y).scaleExtent([1, 8]).on("zoom", zoom);
var svg = d3.select()...call(zm);
Then you can set the zoom level and translation directly:
function zoomIn() {
zm.scale(zm.scale()*2);
// probably need to compute a new translation also
}
function reset() {
zm.scale(1);
zm.translate([0,0]);
}
Restricting the panning range is a bit trickier. You can simply not update when the translate or scale is not to your liking inside you zoom function (or set the zoom's "translate" to what you need it to be). Something like (I think in your case):
function zoom() {
if(y.domain()[0] < 0) {
// To restrict translation to 0 value
zm.translate([d3.event.translate[0], height * (1 - zm.scale())]);
}
....
}
Keep in mind that if you want zooming in to allow a negative on the axis, but panning not to you will find you get into some tricky scenarios.
This might be dated, but check out Limiting domain when zooming or panning in D3.js
Note also that the zoom behaviour did have functionality for limiting panning and zooming at one point. But the code was taken out in a later update.
I don't like to reinvent the wheel. I was searching for scatter plots which allow zooming. Highcharts is one of them, but there's plotly, which is based on D3 and not only allows zooming, but you can also have line datasets too on the scatter plot, which I desire with some of my datasets, and that's hard to find with other plot libraries. I'd give it a try:
https://plot.ly/javascript/line-and-scatter/
https://github.com/plotly/plotly.js
Using such nice library can save you a lot of time and pain.
I am attempting to add labels/axis/titles/etc to a D3 bar graph. I can get something close to the right size, however I end up clipping off part of the last bar (so the last bar is skinnier than the others).
Here is the pertinent code:
var x = d3.scale.linear()
.domain([0, object.data.length])
.range([0, object.width]);
var y = d3.scale.linear()
.domain([object.min-(object.max-object.min)*.15, object.max (object.max-object.min)*.15])
.rangeRound([ object.height - 30, 0]);
var vis = d3.select(object.ele)
.append("svg:svg")
.attr("width", object.width)
.attr("height", object.height)
.append("svg:g");
vis.selectAll("g")
.data(object.data)
.enter().append("rect")
.attr("x", function(d, i) { return x(i); })
.attr("y", function(d) { return object.height - y(d.value); })
.attr("width", object.width/object.data.length - 1)
.attr("height", function(d) { return y(d.value); })
.attr("transform", "translate(30,-30)");
At the moment everything (labels, axis, and so on) is 30px. How do I correctly alter the graph to make room for whatever else I need?
You are cutting off your last bar because you use translate the x coordinate but your the range of your x is only to the width without the extra 30 pixels.
Also it may be easier to simplify your y domain to use .domain([object.min, object.max]) then have the "y" and "height" functions reversed. This way you start the rect at the y(d.value) and make it's height object.height - y(d.value).
I would create three groups initially, one for your y axis, one for x axis, and then another for the bars. Draw your bars inside the last group and then translate the whole group itself instead of each individual bar. Increase the size of your object.width and object.height to match the total space you want.