Get arrowheads to point at outer edge of node in D3 - svg

I'm new to D3 and I'm trying to create an interactive network visualization. I've copied large parts of this example, but I have changed the curved lines to straight ones by using SVG "lines" rather than "paths", and I've also scaled the nodes according to the data they represent. The problem is that my arrowheads (created with SVG markers) are at the ends of the lines. Since some of the nodes are large, the arrows get hidden behind them. I'd like my arrowheads to show up right at the outside edge of the node they point to.
Here is how I'm creating the markers and links:
svg.append("svg:defs").selectAll("marker")
.data(["prereq", "coreq"])
.enter().append("svg:marker")
.attr("id", String)
.attr("viewBox", "0 -5 10 10")
.attr("refX", 15)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("svg:path")
.attr("d", "M0,-5L10,0L0,5");
var link = svg.selectAll(".link")
.data(force.links())
.enter().append("line")
.attr("class", "link")
.attr("marker-end", function(d) { return "url(#" + d.type + ")"; });
I noticed that the "refX" attribute specifies how far from the end of the line the arrowhead should show up. How can I make this dependent on the radius of the node it's pointing to? If I can't do that, could I instead change the endpoints of the lines themselves? I'm guessing I would do that in this function, which resets the endpoints of the lines as everything moves:
function tick() {
link
.attr("x1", function(d) { return d.source.x; })
.attr("y1", function(d) { return d.source.y; })
.attr("x2", function(d) { return d.target.x; })
.attr("y2", function(d) { return d.target.y; });
circle.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
text.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
}
Which approach makes more sense, and how would I implement it?

Thanks Lars Kotthoff, I got this to work following the advice from the other question! First I switched from using lines to paths. I don't think I actually had to do that, but it made it easier to follow the other examples I was looking at because they used paths.
Then, I added a "radius" field to my nodes. I just did this when I set the radius attribute, by adding it as an actual field rather than returning the value immediately:
var circle = svg.append("svg:g").selectAll("circle")
.data(force.nodes())
.enter().append("svg:circle")
.attr("r", function(d) {
if (d.logic != null) {
d.radius = 5;
} else {
d.radius = node_scale(d.classSize);
}
return d.radius;
I then edited my tick() function to take this radius into account. This required a bit of simple geometry...
function tick(e) {
path.attr("d", function(d) {
// Total difference in x and y from source to target
diffX = d.target.x - d.source.x;
diffY = d.target.y - d.source.y;
// Length of path from center of source node to center of target node
pathLength = Math.sqrt((diffX * diffX) + (diffY * diffY));
// x and y distances from center to outside edge of target node
offsetX = (diffX * d.target.radius) / pathLength;
offsetY = (diffY * d.target.radius) / pathLength;
return "M" + d.source.x + "," + d.source.y + "L" + (d.target.x - offsetX) + "," + (d.target.y - offsetY);
});
Basically, the triangle formed by the path, it's total x change (diffX), and it's total y change (diffY) is a similar triangle to that formed by the segment of the path inside the target node (i.e. the node radius), the x change inside the target node (offsetX), and the y change inside the target node (offsetY). This means that the ratio of the target node radius to the total path length is equal to the ratio of offsetX to diffX and to the ratio of offsetY to diffY.
I also changed the refX value to 10 for the arrows. I'm not sure why that was necessary but now it seems to work!

I answered the same question over here. The answer uses vector math, it's quite useful for other calculations as well.

Related

change height and direction of triangles in D3

I have a basic Bar chart with red and green triangles. Red triangle on negative comparison of data and green triangle if the data is positive. The green triangle should always point upwards and red downwards at the bottom of the bar and if the data is neutral the circle should be displayed. I'm unable to align the arrow/triangle to bottom(touch x-axis) as well as rotate the arrow to point up or down based on condition. Here is my code
svg.selectAll("line.arrow")
.data(input.filter(function (d) { return d.AverageValue}))
.enter().append("line")
.attr("class", "arrow")
.attr("x1", function (d) {
return xScale(d.AppName) + xScale.rangeBand() / 2;
})
.attr("x2", function (d) {
return xScale(d.AppName) + xScale.rangeBand() / 2;
})
.attr("y1", function (d) {
return yScale(20);//bring arrows to bottom
})
.attr("y2", function (d) {
var getValue = d.AverageValue;
if (getValue >= 0) {
return yScale(10)-37;
} else {
return yScale(23) - 6;
}
})
.attr("marker-end", function (d) {
var getValue = d.ComparedToPreviousMonth;
if (getValue < 0) {
return "url(#redArrow)";
} else if (getValue > 0) {
return "url(#greenArrow)";
}
});
The entire code is in fiddle
http://jsfiddle.net/911vgmp1/
Looking at the generated svg output you will notice that the lines you are using for setting your markers are geometrically points rather than lines, i.e. x1 equals x2 and y1 equals y2:
<line class="arrow" x1="33.5" x2="33.5" y1="296" y2="296" marker-end="url(#redArrow)"></line>
Because this line / point doesn't have any direction there is no way to determine the orientation of the marker. Hence, your orient="auto" will not rotate the markers appropriately.
In the generator function for attr("marker-end") you are using d.ComparedToPreviousMonth which works fine determining which marker to use. The generator function for .attr("y2"), however, uses d.AverageValue to calcutate y2 and, thus, the direction of the line and the orientation of the marker. Since the AverageValues are all positive there is no change in direction of your lines / markers.
You may fix this by adjusting the y1 and y2 generators to:
.attr("y1", function (d) {
return h;
})
.attr("y2", function (d) {
var getValue = d.ComparedToPreviousMonth;
if (getValue < 0) {
return h - .001;
} else {
return h + .001;
}
})
For the purists the y2 generator may even be shortened to:
.attr("y2", function (d) {
return d.ComparedToPreviousMonth < 0 ? h - .001 : h + .001;
})
With that in place, you'll just have to set different refX values for your markers to visually position them on the x-axis:
.attr("refX", function(d) {
return d == "red" ? 0 : 10;
})
See the updated JSFiddle.
Here's one way. I've
Split the green and red arrows so they can have separate transforms
translated the markers down to the bottom of the bars
Made the markers overflow visible so they still display after the data is translated
// add text and arrow
svg.selectAll("line.arrow")
.data(["red"])
.enter()
.append("defs").append("marker")
.attr("id", function (d) {
return d + "Arrow";
})
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("markerWidth", 12)
.attr("markerHeight", 20)
.attr("orient", "auto")
.attr("overflow", "visible")
.append("svg:path")
.attr("d", "M0,-5L10,0L0,5")
.attr("transform", "rotate(90, 5, 0) translate(56, 0)")
.attr("class", function (d) {
return "marker_" + d;
});
svg.selectAll("line.arrow")
.data(["green"])
.enter()
.append("defs").append("marker")
.attr("id", function (d) {
return d + "Arrow";
})
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("markerWidth", 12)
.attr("markerHeight", 20)
.attr("orient", "auto")
.attr("overflow", "visible")
.append("svg:path")
.attr("d", "M0,-5L10,0L0,5")
.attr("transform", "rotate(270, 5, 0) translate(-56, 0)")
.attr("class", function (d) {
return "marker_" + d;
});

Change starting point of alongPath animation

I have a simple path element that a circle has to follow using D3.js. I found a way to do this through using getTotalLength and getPointAtLength methods on SVG as described in this example:
http://bl.ocks.org/mbostock/1705868
It works fine but the problem is that my circle starts at the bottom and follows the line upwards, how do i change the starting point of the circle in the animation so it goes from top to bottom? Here's my code:
my path element:
<path id="testpath" style="fill:none;stroke:#FFFFFF;stroke-width:0.75;" d="M1312.193,1035.872l80.324-174.458l13.909-264.839l507.09-211.095
l8.667-248.405" />
the D3 code:
function followPath()
{
var circle = d3.select("svg").append("circle")
.attr("r", 6.5)
.attr("transform", "translate(0,0)")
.attr("class","circle");
var path = d3.select("#testpath");
function transition()
{
circle.transition()
.duration(10000)
.attrTween("transform", translateAlong(path.node()))
.each("end", transition);
}
function translateAlong(path)
{
var l = path.getTotalLength();
return function (d, i, a) {
return function (t) {
var p = path.getPointAtLength(t * l);
return "translate(" + p.x + "," + p.y + ")";
};
};
}
transition();
}
I made a fiddle where u can see it going from bottom to top(i know the viewbox is big, it's part of a much bigger SVG image)
http://jsfiddle.net/6286X/3/
The animation will start at the start of the line, as defined in the SVG. To make it start at an arbitrary point, you have to offset it accordingly. To get the exact opposite as in your case, the change is almost trivial though -- you just start at the end and move towards the beginning. That is, you "invert" the transition by considering 1-t instead of t:
return function (t) {
var p = path.getPointAtLength((1-t) * l);
return "translate(" + p.x + "," + p.y + ")";
};
Complete demo here.

.style of child nodes in d3js

I'm trying to make a simple graph with nodes and links. I have "g" elements containing a circle and its text, and links on their own. I have, for example, this bit on code called on a mouseover event:
//check if circle is connected to current "viewed" (mouseover-ed)
//circle via a link referenced by "that" and paint it green if so
circles.filter(function() {
return d3.select(this).attr("index") == d3.select(that).attr("src");
}).attr("viewed",1).style("stroke", "green");
});
This was really a long shot as nodes is the 'g' element container and I wasn't sure what calling .style would do, but to my surprise it did change the color - but only for the text!
Is there a way to make it change the stroke style of the circle as well?
The declaration code:
var circles = svg.append("g")
.attr("class","nodes")
.selectAll("circle")
.data(graph.nodes)
.enter()
.append("g")
.attr("transform",function(d,i){d.x = getX(i);d.y=getY(i);return "translate(" + d.x + "," + d.y + ")";})
.attr("name", function(d){return d.name;})
.attr("viewed", 0)
.attr("focused", 0)
.attr("index", function(d, i) {return i;});
circles.append("circle")
.style("stroke", "gray")
.style("fill", "white")
.attr("r", node_radius_wo_pad)
.on("mouseover", function(){...};
circles.append("text")
.attr("text-anchor","middle")
.text(function(d){return d.name});
The reason this is working is that you haven't explicitly declared a stroke colour for the text and so it inherits what you set for the parent g element. To make this work for the circles, you have to select them explicitly:
var toChange = circles.filter(function() {
return d3.select(this).attr("index") == d3.select(that).attr("src");
});
toChange.attr("viewed", 1);
toChange.selectAll("circle").style("stroke", "green");
toChange.selectAll("text").style("stroke", "green");

Sunburst Diagram Intelligent Text Wrapping

I have a zoomable sunburst diagram based on the great d3 "coffee wheel" example:
In that example there is simple text wrapping code that searches for a space in a label and wraps the text at the first space character:
var textEnter = text.enter().append("text")
.style("fill-opacity", 1)
.style("fill", function(d) {
return brightness(d3.rgb(colour(d))) < 125 ? "#eee" : "#000";
})
.attr("text-anchor", function(d) {
return x(d.x + d.dx / 2) > Math.PI ? "end" : "start";
})
.attr("dy", ".2em")
.attr("transform", function(d) {
var multiline = (d.name || "").split(" ").length > 1,
angle = x(d.x + d.dx / 2) * 180 / Math.PI - 90,
rotate = angle + (multiline ? -.5 : 0);
return "rotate(" + rotate + ")translate(" + (y(d.y) + padding) +
")rotate(" + (angle > 90 ? -180 : 0) + ")";
})
.on("click", click);
textEnter.append("tspan")
.attr("x", 0)
.text(function(d) {
return d.depth ? d.name.split(" ")[0] : "";
});
textEnter.append("tspan")
.attr("x", 0)
.attr("dy", "1em")
.text(function(d) {
return d.depth ? d.name.split(" ")[1] || "" : "";
});
This code works fine, but it is very basic, especially if the whole label would have fitted on one line in the available sunbust segment (eiether because the particular label is short or the available space is large enough as the sunburst is zoom'ed). In densely packed sunbursts wrapping the labels unnecesarily causes the diagram to look messy/cluttered.
I would like to make the text processing more intelligent and compare the length of the label with the available space (noting that the "width" of a segment changes depending on the depth of segments visible when zoom'ed).
Also if the available space for the label is known then the text wrapping could be more intelligent eg if the label contains more than two words code can decide whether it is better to break at first space or second.
If anyone has already solved this problem and has some example, greatly appreciated if it can be shared. Alternatively if anyone has any ideas about how the length of a label can be compared to the changing available space?

How do I adjust my SVG transform based on the viewport?

I'm working with the d3 library and have had success working with the chloropleth example, as well as getting a click action to zoom in to a particular state (see this question for details). In particular, here is the code I'm using for my click to zoom event on a state:
// Since height is smaller than width,
var baseWidth = 564;
var baseHeight = 400;
d3.selectAll('#states path')
.on('click', function(d) {
// getBBox() is a native SVG element method
var bbox = this.getBBox(),
centroid = [bbox.x + bbox.width/2, bbox.y + bbox.height/2],
// since height is smaller than width, I scale based off of it.
zoomScaleFactor = baseHeight / bbox.height,
zoomX = -centroid[0],
zoomY = -centroid[1];
// set a transform on the parent group element
d3.select('#states')
.attr("transform", "scale(" + scaleFactor + ")" +
"translate(" + zoomX + "," + zoomY + ")");
});
However, when I click to view on the state, my transform is not in the center of my viewport, but off to the top left, and it might not have the proper scale to it as well. If I make minor adjustments manually to the scaleFactor or zoomX/zoomY parameters, I lose the item altogether. I'm familiar with the concept that doing a scale and transform together can have significantly different results, so I'm not sure how to adjust.
The only other thing I can think of is that the original chloropleth image is set for a 960 x 500 image. To accomodate for this. I create an albersUSA projection and use my d3.geo.path with this projection and continue to add my paths accordingly.
Is my transform being affected by the projection? How would I accomodate for it if it was?
The scale transform needs to be handled like a rotate transform (without the optional cx,cy parameters), that is, the object you want to transform must first be moved to the origin.
d3.select('#states')
.attr("transform",
"translate(" + (-zoomX) + "," + (-zoomY) + ")" +
"scale(" + scaleFactor + ")" +
"translate(" + zoomX + "," + zoomY + ")");
For futher reference,
I found this article where you should find how to use the matrix transformation to achieve zoom and pan effects very simple.
Excerption:
<script type="text/ecmascript">
<![CDATA[
var transMatrix = [1,0,0,1,0,0];
function init(evt)
{
if ( window.svgDocument == null )
{
svgDoc = evt.target.ownerDocument;
}
mapMatrix = svgDoc.getElementById("map-matrix");
width = evt.target.getAttributeNS(null, "width");
height = evt.target.getAttributeNS(null, "height");
}
]]>
</script>
function pan(dx, dy)
{
transMatrix[4] += dx;
transMatrix[5] += dy;
newMatrix = "matrix(" + transMatrix.join(' ') + ")";
mapMatrix.setAttributeNS(null, "transform", newMatrix);
}
function zoom(scale)
{
for (var i=0; i<transMatrix.length; i++)
{
transMatrix[i] *= scale;
}
transMatrix[4] += (1-scale)*width/2;
transMatrix[5] += (1-scale)*height/2;
newMatrix = "matrix(" + transMatrix.join(' ') + ")";
mapMatrix.setAttributeNS(null, "transform", newMatrix);
}

Resources