D3.js Brush Controls: getting extent width, coordinates - javascript

I'm using d3.js, and was wondering how I might get the sides, width, coordinates, etc; of the extent. In an example like this http://bl.ocks.org/mbostock/1667367

Brush.extent()
When using a brush control, you access information about the state of the brush using the .extent() method on the brush object.
The information returned by the .extent() method depends on what sort of scale(s) you have connected to the brush object.
If you have one scale linked (either an X-scale or a Y-scale, but not both), then the extent method returns a two-element array of the form [minimum, maximum].
If you have both X and Y scales attached to the brush object, then the extent method returns a nested array of the form [‍​[xMinimum, yMinimum], [xMaximum, yMaximum]​].
But what are these minimum and maximum values? That also depends on the scale. If the scale has a valid .invert(value) method, the minimum and maximum values will be converted into your data domain values. For ordinal, threshold and other scales which do not have a simple invert method, the brush function returns the values in the coordinate system in effect for the brush element.
1. One dimension brush
To answer your question for the specific example you linked to, we need to look at the brush object and scales objects. In that example, the brush is connected to the horizontal scale on the smaller, "context" chart (the x2 scale):
var x = d3.time.scale().range([0, width]),
x2 = d3.time.scale().range([0, width]),
y = d3.scale.linear().range([height, 0]),
y2 = d3.scale.linear().range([height2, 0]);
var brush = d3.svg.brush()
.x(x2)
.on("brush", brushed);
brush's initialisation
The brush object created above only exists in the Javascript, not in the document. However, the object is also a function which can be called (similar to the axis functions) in order to create a series of rectangles which will respond to mouse events (these are invisible) and the one "extent" rectangle (which in this example is coloured gray with a white border).
context.append("g")
.attr("class", "x brush")
.call(brush) //call the brush function, causing it to create the rectangles
.selectAll("rect") //select all the just-created rectangles
.attr("y", -6)
.attr("height", height2 + 7); //set their height
The default size of the invisible rectangles is based on the output range of the X and Y scales. Because this brush doesn't have a Y scale, the constant height and vertical position of the rectangles has to be set explicitly.
The initial size of the extent rectangle is based on the extent of the brush object (zero width and height by default). The height of that rectangle is also set in the above code.
brush interaction
When the user interacts with the brush on screen, the brush object captures those events and (1) updates the width of the "extent" rectangle, (2) calls the function which you associated with the "brush" event in the line .on("brush", brushed).
The brushed() function is:
function brushed() {
x.domain(brush.empty() ? x2.domain() : brush.extent());
focus.select(".area").attr("d", area);
focus.select(".x.axis").call(xAxis);
}
The purpose of this brush is to scale the main chart, and this is done by setting the domain of the main chart's X-scale. If the brush has zero-width, brush.empty() returns true and the main chart X-domain is set to the full domain shown in the small chart.
However, if the brush has a valid width, the empty test returns false, and the domain is set to the results of brush.extent(). Because the brush is attached to a linear X scale, and no Y scale, the extent is returned in the form [xMin, xMax] (in the data numbers), which is exactly what is needed for setting the domain.
Extracting values from brush
If you needed to know the width in the data values, it is a simple matter of subtraction:
var extent = brush.extent(); //returns [xMin, xMax]
var width = extent[1] - extent[0]; //data-width = max - min
However, if you are drawing other elements on screen, you want to know the actual coordinates in the SVG, not just the data values. To do the conversion, you use the same thing you always use to convert from data to SVG coordinates: your scale function. Remembering to use the x2 scale that controls the small chart, not the zoomed-in scale of the main chart, that would look like:
var extent = brush.extent(); //returns [xMin, xMax]
var rangeExtent = [x2( extent[0] ), x2( extent[1] ) ]; //convert
var rangeWidth = rangeExtent[1] - rangeExtent[0];
2. X and Y brush
To re-emphasize, this example is for a brush with one (horizontal / X) scale, which is a linear scale. If you were using both X and Y linear scales, you would need to separate out the X and Y extent values with code like this:
function brushed() {
if (brush.empty()) {
//either the height OR the width is empty
x.domain( x2.domain() ); //reset X scale
y.domain( y2.domain() ); //reset Y scale
}
var extent = brush.extent();
x.domain( [extent[0][0] , extent[1][0] ] ); //min and max data X values
y.domain( [extent[0][1] , extent[1][1] ] ); //min and max data Y values
var rangeExtent = [
[x2(extent[0][0]), y2(extent[0][1])],
[x2(extent[1][0]), y2(extent[1][1])]
];
var rangeWidth = rangeExtent[1][0] - rangeExtent[0][0];
var rangeHeight = rangeExtent[1][1] - rangeExtent[0][1];
focus.select(".area").attr("d", area);
focus.select(".x.axis").call(xAxis);
focus.select(".y.axis").call(yAxis);
}
Extracting values from brush
If you want to know the coordinates of the top-left point of the rectangle, you'll also need to know whether your Y scale switches the minimum value from top to bottom.
Alternately, you could get the width, height, and top left coordinate from the on-screen "extent" rectangle, which the brush object modifies for you:
function brushed() {
//use the brush object's values to set the data domains:
if (brush.empty()) {
//either the height OR the width is empty
x.domain( x2.domain() ); //reset X scale
y.domain( y2.domain() ); //reset Y scale
}
var extent = brush.extent();
x.domain( [extent[0][0] , extent[1][0] ] ); //min and max data X values
y.domain( [extent[0][1] , extent[1][1] ] ); //min and max data Y values
//use the brush extent rectangle to get the SVG coordinates:
var extentRect = d3.select("g.x.brush rect.extent");
var rangeWidth = extentRect.attr("width");
var rangeHeight = extentRect.attr("height");
var rangeLeft = extentRect.attr("x");
var rangeTop = extentRect.attr("y");
focus.select(".area").attr("d", area);
focus.select(".x.axis").call(xAxis);
focus.select(".y.axis").call(yAxis);
}
If you are using ordinal scales, it is more complicated for zooming, but less complicated for finding screen coordinates. This answer describes how to use a brush with an ordinal scale to zoom. However, since the values returned by .extent() are already in SVG coordinates, you would not have to use the scale itself to convert back if you want coordinates and width
References
The API page for the brush control has a set of thumbnail images at the top of the page; click on any of them to open up a working example. For more discussion, you might be interested in this tutorial with another good example of brushes in action.

Related

Force colliding labels but not their points in d3

I am using d3 to make a line chart that has to support up to 100 points on it, making it very crowded. The problem is that some of the labels overlap.
The method I was trying involved drawing all the points, then separately drawing all the labels and running a force collision on the labels to stop them overlapping, then after the force collision drawing a line between each of the labels and their associated point.
I can't make the forces work, let alone the drawing of lines after.
Any suggestions for a better way to do this are heartily welcomed also.
Here is my code:
$.each(data.responseJSON.responsedata, function(k, v) {
var thispoint = svg.append("g").attr("transform", "translate("+pointx+","+pointy+")");
thispoint.append("circle").attr("r", 10).style("fill","darkBlue").style("stroke","black");
var label = svg.append("text").text(v.conceptName).style("text-anchor", "end").attr("font-family", "Calibri");
label.attr("transform", "translate("+(pointx)+","+(pointy-12)+") rotate(90)")
});
nodes = d3.selectAll("text")
simulation = d3.forceSimulation(nodes)
.force("x", d3.forceX().strength(10))
.force("y", d3.forceY().strength(10))
.force("collide",d3.forceCollide(20).strength(5))
.velocityDecay(0.15);
ticks = 0;
simulation.nodes(data)
.on("tick", d => {
ticks = ticks + 1;
d3.select(this).attr("x", function(d) { return d.x }).attr("y", function(d) { return d.x });
console.log("updated" + this)
});
Force layout is a relatively expensive way of moving labels to avoid collision. It is iteratively and computationally intensive.
More efficient algorithms add the labels one at a time, determining the best position for each. For example a 'greedy' strategy adds each label in sequence, selecting the position where the label has the lowest overlap with already added labels.
I've created a D3 components, d3fc-label-layout, that implements a number of label layout strategies:
https://github.com/d3fc/d3fc-label-layout
Here's an example of how to use it:
// Use the text label component for each datapoint. This component renders both
// a text label and a circle at the data-point origin. For this reason, we don't
// need to use a scatter / point series.
const labelPadding = 2;
const textLabel = fc.layoutTextLabel()
.padding(2)
.value(d => d.language);
// a strategy that combines simulated annealing with removal
// of overlapping labels
const strategy = fc.layoutRemoveOverlaps(fc.layoutGreedy());
// create the layout that positions the labels
const labels = fc.layoutLabel(strategy)
.size((d, i, g) => {
// measure the label and add the required padding
const textSize = g[i].getElementsByTagName('text')[0].getBBox();
return [
textSize.width,
textSize.height
];
})
.position((d) => {
return [
d.users,
d.orgs
]
})
.component(textLabel);
https://bl.ocks.org/ColinEberhardt/27508a7c0832d6e8132a9d1d8aaf231c

How to filter a featurecollection to an object that can be used with path.bounds()

I’ve been trying to make a map with a zoom to bounding box functionality, based on this example: https://bl.ocks.org/mbostock/9656675.
But for municipalities with islands, the zoom goes to the bounding box of the selected landmass instead of the bounding box of the municipality.
I figured out that in my data, municipalities with several areas separated by water consist of multiple polygons with the same nametag instead of a single multipolygon as in Mike Bostocks example above.
I managed to fix the issue for filling in the areas, so the error becomes even more obvious if you click on one of the small islands, but I cannot figure out how to properly zoom to the bounding box of the municipality instead of the land area.
I tried looking for different ways to filter or subset a featurecollection based on the areanames but my solutions all end up giving me a wrong data type, or a bounding box from -infinity to infinity.
To sum up, the intended behaviour is for the zoom to go to the bounding box of the highlighted area instead of the selected landmass.
Here is my map so far: http://plnkr.co/edit/iywWsM9RLs7UzI40q66M?p=preview
I slowed down the zoom a bit so the it’s easier to spot the error, I hope it’s not too annoying.
And here is the code piece where i suspect things are going wrong.
function clicked(d) {
if (d.properties.KOMNAVN == kommune) return reset();
d3.selectAll("path")
.attr("fill", "teal");
kommune = d.properties.KOMNAVN;
var bounds = path.bounds(d),
dx = bounds[1][0] - bounds[0][0],
dy = bounds[1][1] - bounds[0][1],
x = (bounds[0][0] + bounds[1][0]) / 2,
y = (bounds[0][1] + bounds[1][1]) / 2,
scale = Math.max(1, Math.min(zoomExtent, 0.95 / Math.max(dx / w, dy / h))),
translate = [w / 2 - scale * x, h / 2 - scale * y];
svg.transition()
.duration(4000)
.call(zoom.transform, d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale));
d3.selectAll("path").filter(function(d) {
return d.properties.KOMNAVN == kommune
})
.attr("fill", "darkred");
}
Thanks in advance!
path.bounds (or projection.fitSize and projection.fitExtent) for that matter, requires a a geojson object, which can be a feature collection. Feeding this function an array will cause issues.
A feature collection looks like:
{
"type":"FeatureCollection",
"features": features
}
where features is an array of feature types.
You have a a feature collection for your dataset, and you can filter the features:
var filteredFeatures = data.features.filter(function(feature) {
return feature.properties.property == criteria
})
Then you can create a new feature collection with these filtered features. In your case this might look like:
var filteredFeatures = json.features.filter(function(feature) {
return feature.properties.KOMNAVN == d.properties.KOMNAVN;
})
var filteredFeatureCollection = {
"type":"FeatureCollection",
"features":filteredFeatures
}
No you can send this new feature collection to path.bounds.
Note that for your example I've moved the click function into the call back function for d3.json so that the scope of the json variable covers the click function.
Here's an updated plunker.

D3.js shade area between two lines using CSS fill [duplicate]

So I have a chart plotting traffic vs. date and rate vs. date. I'm trying to shade the area between the two lines. However, I want to shade it a different color depending on which line is higher. The following works without that last requirement:
var area = d3.svg.area()
.x0(function(d) { return x(d3.time.format("%m/%d/%Y").parse(d.original.date)); })
.x1(function(d) { return x(d3.time.format("%m/%d/%Y").parse(d.original.date)); })
.y0(function(d) { return y(parseInt(d.original.traffic)); })
.y1(function(d) { return y(parseInt(d.original.rate)); })
However, adding that last requirement, I tried to use defined():
.defined(function(d){ return parseInt(d.original.traffic) >= parseInt(d.original.rate); })
Now this mostly works, except when lines cross. How do I shade the area under one line BETWEEN points? It's shading based on the points and I want it to shade based on the line. If I don't have two consecutive points on one side of the line, I don't get any shading at all.
Since you don't have datapoints at the intersections, the simplest solution is probably to get the areas above and below each line and use clipPaths to crop the difference.
I'll assume you're using d3.svg.line to draw the lines that the areas are based on. This way we'll be able to re-use the .x() and .y() accessor functions on the areas later:
var trafficLine = d3.svg.line()
.x(function(d) { return x(d3.time.format("%m/%d/%Y").parse(d.original.date)); })
.y(function(d) { return y(parseInt(d.original.traffic)); });
var rateLine = d3.svg.line()
.x(trafficLine.x()) // reuse the traffic line's x
.y(function(d) { return y(parseInt(d.original.rate)); })
You can create separate area functions for calculating the areas both above and below your two lines. The area below each line will be used for drawing the actual path, and the area above will be used as a clipping path. Now we can re-use the accessors from the lines:
var areaAboveTrafficLine = d3.svg.area()
.x(trafficLine.x())
.y0(trafficLine.y())
.y1(0);
var areaBelowTrafficLine = d3.svg.area()
.x(trafficLine.x())
.y0(trafficLine.y())
.y1(height);
var areaAboveRateLine = d3.svg.area()
.x(rateLine.x())
.y0(rateLine.y())
.y1(0);
var areaBelowRateLine = d3.svg.area()
.x(rateLine.x())
.y0(rateLine.y())
.y1(height);
...where height is the height of your chart, and assuming 0 is the y-coordinate of the top of the chart, otherwise adjust those values accordingly.
Now you can use the area-above functions to create clipping paths like this:
var defs = svg.append('defs');
defs.append('clipPath')
.attr('id', 'clip-traffic')
.append('path')
.datum(YOUR_DATASET)
.attr('d', areaAboveTrafficLine);
defs.append('clipPath')
.attr('id', 'clip-rate')
.append('path')
.datum(YOUR_DATASET)
.attr('d', areaAboveRateLine);
The id attributes are necessary because we need to refer to those definitions when actually clipping the paths.
Finally, use the area-below functions to draw paths to the svg. The important thing to remember here is that for each area-below, we need to clip to the opposite area-above, so the Rate area will be clipped based on #clip-traffic and vice versa:
// TRAFFIC IS ABOVE RATE
svg.append('path')
.datum(YOUR_DATASET)
.attr('d', areaBelowTrafficLine)
.attr('clip-path', 'url(#clip-rate)')
// RATE IS ABOVE TRAFFIC
svg.append('path')
.datum(YOUR_DATASET)
.attr('d', areaBelowRateLine)
.attr('clip-path', 'url(#clip-traffic)')
After that you'll just need to give the two regions different fill colors or whatever you want to do to distinguish them from one another. Hope that helps!

D3 albersUsa projection funtion return null

I'm pretty new to D3 and I'm trying to set point on a map.
I'm confused as I created a projection with this code:
var projection = d3.geo
.albersUsa()
.scale(500)
.translate([el.clientWidth / 2, el.clientHeight / 2]);
I use this projection to draw a map and it works fine.
But then whenever I call projection([10, 20]) it returns null whichever values I'm passing in.
What is my error?
From the documentation
# projection(location)
[…]
May return null if the specified location has no defined projected position, such as when the location is outside the clipping bounds of the projection.
The Albers USA projection is defined only within its borders and will yield null for coordinates outside the well-defined area.
See this comparison of calls to projection(location) using [10,20], which is not valid, and [-110,40], which is a valid point:
var projection = d3.geo
.albersUsa()
.scale(500)
.translate([960, 500]);
d3.selectAll("p")
.data([[10,20],[-110,40]])
.enter().append("p")
.text(function(d) { return d + ": " + projection(d); });
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

bezier control point orientation d3

I'm hand cranking a network diagram from D3 (I didn't like the output of Force Directed). To do this, I've generated an array of nodes, each with an x/y co-ordinate.
{
"nodes" : [
{
"name" : "foo",
"to" : ["bar"]
},
{
"name" : "bar",
"to" : ["baz"]
},
{
"name" : "baz"
}
]
}
I then generate an svg, with a parent svg:g, and bind this data to a series of svg:g elements hanging off the parent.
addSvg = function () {
// add the parent svg element
return d3.select('#visualisation')
.append('svg')
.attr('width', width)
.attr('height', height);
};
addSvgGroup = function (p) {
// add a containing svg:g
return p.append('svg:g').
attr('transform', 'translate(0,0)');
};
addSvgNodes = function(p, nodes) {
// attach all nodes to the parent p data
// and render to svg:g
return p.selectAll('g')
.data(nodes)
.enter()
.append('svg:g')
.attr('class', 'node');
};
Then I manually position the nodes (this will be dynamic later, I'm just getting my feet)
transformNodes = function (nodes) {
// position nodes manually
// deprecate later for column concept
nodes.attr('transform', function (o, i) {
offset = (i + 1) * options.nodeOffset;
// options.nodeOffset = 150
o.x = offset;
o.y = offset / 2;
return 'translate(' + offset + ',' + offset / 2 + ')';
});
};
Then I attach these items to the parent svg:g, and hang some text off them.
This results in a staircase of text descending left to right within the svg. So far, so good.
Next, I want to generate some links, so I use a method to determine if the current node has a relationship, and then get that nodes location. Finally, I generate a series of links using d3.svg.diagonal and set their source/target to the appropriate nodes. Written longhand for clarity.
getLinkGenerator = function (o) {
return d3.svg.diagonal()
.source(o.source)
.target(o.target)
.projection(function (d) {
console.log('projection', d);
return [d.x, d.y]
});
};
Now, so far, so good - except the control handles for the bezier are not where I would like them to be. For example from node A to node B the path d attribute is thus:
<path d="M150,75C150,112.5 300,112.5 300,150" style="fill: none" stroke="#000"></path>
But I'd like it to alter the orientation of the control handles - i.e
<path d="M150,75C200,75 250,150 300,150" style="fill: none" stroke="#000"></path>
This would make it look more like a dendrograph from the page of examples. What I noticed in the collapsible dendrograph example is that it returns an inversion of the axes:
return [d.y, d.x]
But if I do this, while the control points are oriented as I would like, the location of the points is out of whack (i.e their x/y co-ordinates are also reversed, effectively translating them.
Has anyone else encountered an issue like this or have an idea of how to fix it?
OK, so I took a look at this and figured out a solution. It appears that some of the layouts (dendrogram, collapsed tree) are inverting the co-ordinates of source/target in the path links so that when they hit the projection call, they get reversed back into their correct location, but with the orientation of their bezier points rotated.
So, if you're hand cranking a custom layout and you want to orient the bezier controls horizontally (like the examples), that's what you need to do.

Categories