I am starting with d3.js, and am trying to create a network graph each circle of which contains a label.
What I want is a line break an svg text.
What I am trying to do is to break the text into multiple <tspan>s, each with x="0" and variable "y" to simulate actual lines of text. The code I have written gives some unexpected result.
var text = svg.selectAll("text").data(force.nodes()).enter().append("text");
text
.text(function (d) {
arr = d.name.split(" ");
var arr = d.name.split(" ");
if (arr != undefined) {
for (i = 0; i < arr.length; i++) {
text.append("tspan")
.text(arr[i])
.attr("class", "tspan" + i);
}
}
});
In this code am splitting the text string by white space and appending the each splitted string to tspan. But the text belonging to other circle is also showing in each circle. How to overcome this issue?
Here is a JSFIDDLE http://jsfiddle.net/xhNXS/ with only svg text
Here is a JSFIDDLE http://jsfiddle.net/2NJ25/16/ showing my problem with tspan.
You need to specify the position (or offset) of each tspan element to give the impression of a line break -- they are really just text containers that you can position arbitrarily. This is going to be much easier if you wrap the text elements in g elements because then you can specify "absolute" coordinates (i.e. x and y) for the elements within. This will make moving the tspan elements to the start of the line easier.
The main code to add the elements would look like this.
text.append("text")
.each(function (d) {
var arr = d.name.split(" ");
for (i = 0; i < arr.length; i++) {
d3.select(this).append("tspan")
.text(arr[i])
.attr("dy", i ? "1.2em" : 0)
.attr("x", 0)
.attr("text-anchor", "middle")
.attr("class", "tspan" + i);
}
});
I'm using .each(), which will call the function for each element and not expect a return value instead of the .text() you were using. The dy setting designates the line height and x set to 0 means that every new line will start at the beginning of the block.
Modified jsfiddle here, along with some other minor cleanups.
Related
I'm trying to add a gradient of different colors (meant to symbolize the percentage of different answers recorded in a survey) to a svg shape (in this case, the shape is a country the user has clicked on). I want it to look like this (please ignore the awful choice of colors).
However, this screenshot is taken when the percentage data for each answer is hard coded (visible to the right in the picture), and not fetched from the csv file with the survey data. So, I wrote this snippet of code to generate a gradient based on the data in the survey. It calculates the percentage each answer received, and creates two offset-attributes that defines the boundaries for that specific answer (which is then applied to the gradient svg). Finally, this gradient is applied to the country the user clicked on.
function calculateGrad(csvData, noOfQuestions, chosenCountry){
var total = 0
for (var i = 0; i < noOfQuestions; i++) {
total += parseInt(csvData[i][chosenCountry])
}
var grad = svg.append("defs")
.append("linearGradient")
.attr("id", "grad")
.attr("x1", "0%")
.attr("x2", "0%")
.attr("y1", "100%")
.attr("y2", "0%")
totalPercentFilled = 0
counter = 0
for (var i = 0; i < noOfQuestions; i++) {
var randomColor = "#000000".replace(/0/g,function(){return (~~(Math.random()*16)).toString(16);});
if (i == 0) {
grad.append("stop").attr("offset", "0%").attr("stop-color", randomColor)
var prevColor = randomColor
counter += 1
}
else {
grad.append("stop").attr("offset", totalPercentFilled).attr("stop-color", randomColor)
var prevColor = randomColor
counter += 1
}
grad.append("stop").attr("offset", (totalPercentFilled + (parseInt(csvData[i][chosenCountry])/parseInt(total))*100)).attr("stop-color", prevColor);
totalPercentFilled += (parseInt(csvData[i][chosenCountry])/parseInt(total)*100)
}
console.log(grad)
// console.log("Number of loops made: " + counter)
// console.log("Number of Q:s: " + noOfQuestions)
g.selectAll("#" + country.id).style("fill", "url(#grad)")
console.log("Gradient applied")
While this code seems to generate the exact same gradient (to me), it results in the country getting filled with just one color (the first of the gradient).
I can't figure out why this happens, as the gradients logged in the console in both examples look identical to me. If someone knows what the problem is I'll be eternally grateful for some guidance.
Please also let me know if I need to provide more details.
In the example D3 screenshot you can see that you are not appending the percentage % unit to the gradient stop. The default unit for gradient stops is the objectBoundingBox, and the units run from 0 to 1.
So your gradient is probably there, it's just 100× from what you intended. You're only seeing one band of your (very large) gradient.
Now i got this second problem using d3.js that i cannot solve by my own. I got a dynamic array "path" which length always changes when i click on. Then i got a "text" variable at a starting position on the svg (112, 490).
With the help of you guys, i now use the for-loop to show the names of the "path" array based on it's switching length on the console and it worked. But now i want the names to appear on the screen. But my
textNode.attr("dx", 112 + i*2);
line does not work. I want the text shift right on the x-scale starting at the point 112 a little bit with each node.
This is the text
var text = svg.append("text")
.attr("dx", 112)
.attr("dy", 490)
.text("1. Node: " )
and this is the for loop
for (var i=0;i<path.length;i++) {
text.attr("dx", 112 + i*2);
text.text( +i+1+". Node: " + path[i].name);
A very basic principle in D3: don't use loops, such as a for loop, to show or display the data (sometimes we do use loops, but in very specific and complex situations, not this one). In your other question, the proposed solution used loops because there was no d3.js tag in the question. But using a loop like this makes little sense if you're using D3. It's an entire library created to manipulate data, and you're ignoring its most important aspect.
Instead of that, bind your data to a selection. In your case, as your array is constantly changing, you're gonna need an "enter", "update" and "exit" selections.
First, bind your data:
var texts = svg.selectAll(".texts")
.data(data);
Then, set the selections:
textsExit = texts.exit().remove();
textsEnter = texts.enter()
.append("text")
.attr("class", "texts");
textsUpdate = texts.merge(textsEnter)
.attr("x", 10)
.attr("y", (d, i) => i * 16)
.text(d => d.name);
Here is a demo to show you how it works. I have an data array, which length changes every second:
var svg = d3.select("body").append("svg");
var dataset = [{
name: "foo"
}, {
name: "bar"
}, {
name: "baz"
}, {
name: "lorem"
}, {
name: "ipsum"
}, {
name: "dolot"
}, {
name: "amet"
}];
print(dataset);
setInterval(()=> {
var data = dataset.slice(Math.random() * 6);
print(data);
}, 1000);
function print(data) {
var texts = svg.selectAll(".texts")
.data(data);
textsExit = texts.exit().remove();
textsEnter = texts.enter()
.append("text")
.attr("class", "texts");
textsUpdate = texts.merge(textsEnter).attr("x", 10)
.attr("y", (d, i) => 20 + i * 16)
.text((d,i) => "Node " + (i+1) + ", name: " + d.name);
}
<script src="https://d3js.org/d3.v4.min.js"></script>
Not sure if this is incorrect for your specific use case, but I think you want to do "x" instead of "dx". That's how I've moved objects to the left / right in d3.js:
textNode.attr("x", 112 + i*2);
EDIT: here's an example that adds text objects and moves them to the right based on the index:
for (var i = 0; i < path.length; i++) {
var text = svg.append("text")
.attr("x", 112 + i*2)
.attr("y", 490)
.text("1. Node: " )
}
EDIT2: See Gerardo's answer. D3js has its own methods for binding and looping through data.
I am building a chart using D3.js, which shows some info about employee's competencies.
screenshot:
As you can see, some text is larger than container element size, because of that, part of the text, is cut. I want to wrap these texts inside container.
I found this example , but I was not able to apply some solution to my chart.
Help would be appreciated...
Here is charts codepen url
and here is full screen view
p.s. I need text to be wrapped by words
In order to wrap the labels, you need to adjust Mike's solution to deal with textPath elements.
For this, we need several things:
1. Get the available width, reaching which the labels should wrap
You could compute the length of the arc itself, but I've done this by computing the segment created by the endpoints of your invisible paths that your labels follow. This will provide us with a little side margin as well, as the segment's length is shorter than the arc's length.
The distance between two points is computed as follows:
d = sqrt((x2 - x1)^2 + (y2 - y1)^2)
2. Wrap the labels when they rich available width and keep the aligned to center
For managing this one, I had to dig into the SVG documentation on the textPath element to see how it can be wrapped and shifted along the y axis.
Initially, I tried setting several textPath elements within one text label, but I couldn't manage to shift them along the y axis. It turns out, that for this you need to add tspan elements within textPath elements. But here another problem arose - I couldn't manage to keep them centrally aligned.
In the end, to achieve shift along y axis and central alignment, you need to use one textPath element (for horizontal alignment) with one tspan element inside (for vertical alignment).
3. Wrap the labels by letters, not by words
This is the point that I have assumed that you'll need namely letter wrapping (at the moment of writing, I didn't get the answer from OP), because on small sizes of your chart, there are words too long to fit into one line.
This was the easiest problem to solve. Just adjust the splitting and joining operations to switch from words to letters:
letters = text.text().split('').reverse(); // instead of .split(/\s+/)
...
tspan.text(line.join("")); // instead of .join(" ")
And here's the whole code that was changed, with relevant comments:
outerSvg.selectAll(".outerCircleText")
.data(pie(behaviorsDatasetOuterCircle))
.enter().append("text")
.attr("class", "outerCircleText")
//Move the labels below the arcs for those slices with an end angle greater than 90 degrees
.attr("dy", function (d, i) {
d.i = i;
return (d.startAngle >= 90 * Math.PI / 180 ? 18 : -11);
})
.text(function(d) { return d.data.name; })
.call(wrap); // Do not add `textPath` elements here. Instead, add them in the `wrap` function
function wrap(text) {
text.each(function() {
var text = d3.select(this),
letters = text.text().split('').reverse(),
letter,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
y = text.attr("y"),
dy = parseFloat(text.attr("dy")),
textPath = text.text(null).append("textPath") // Add a textPath element
.attr("startOffset", '50%')
.style("text-anchor", "middle")
.attr("xlink:href", function(d) { return "#outerArc" + d.i; }),
tspan = textPath.append('tspan'), // Inslide textPath, add a tspan element, for offset feature later.
path = d3.select(text.select('textPath').attr('xlink:href')); // Get the path to compute width of text later.
var startLoc = /M(.*?)A/;
var newStart = path.attr('d').match(startLoc)[1];
var newEnd = path.attr('d').indexOf(' 0 0 0 ') > -1
? path.attr('d').split(' 0 0 0 ')[1]
: path.attr('d').split(' 0 0 1 ')[1] ;
// Compute the start/end coordinate points of the arc that the text will follow.
var x1 = parseFloat(newStart.split(' ')[0]),
y1 = parseFloat(newStart.split(' ')[1]),
x2 = parseFloat(newEnd.split(' ')[0]),
y2 = parseFloat(newEnd.split(' ')[1]);
// Compute the length of the segment between the arc start/end points. This will be the
// width which the labels should wrap when reaching it.
var width = Math.sqrt(Math.pow((x2 - x1), 2) + Math.pow((y2 - y1), 2));
// And then we go on (with slight changes) with the example from Mike Bostock
// from here https://bl.ocks.org/mbostock/7555321
while (letter = letters.pop()) {
line.push(letter);
tspan.text(line.join(""));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
tspan.text(line.join(""));
line = [letter];
// Instead of adding only a tspan element, add a new textPath so that the wrapped
// letters will be aligned to center. Without it, the letters will start drawing
// from right with part of them invisible, like if the labels are not wrapped.
textPath = text.append("textPath")
.attr("startOffset", '50%')
.style("text-anchor", "middle")
.attr("xlink:href", function(d) { return "#outerArc" + d.i; }),
// Add a tspan element to offset the wrapped letters from the previous line
tspan = textPath.append("tspan")
.attr('dy', '1em')
.attr('text-anchor', 'middle')
.text(letter);
}
}
});
}
In the end, it was an interesting challenge. Here is a fork of your codepen with a working example (the changes are starting with line 749).
The codepen has only the outer labels wrapped. I have left the inner labels for you to implement the approach described here. Good luck with that!
I'm drawing out text labels to my svg in multiple lines. The solution I have is working and ok, but it has a limitation that it will not draw any more lines than what I hard-code, and also there is a bit of inefficient processing. Is there a better way to make this happen, without having to re-parse the name string every time, and appending the tspan just the right amount of times?
node.append("text")
.attr("id", function(d){ return "contact-node-label-"+d.id })
.style("text-anchor", "middle")
.attr("dy", function(d)
{
// split name by space and -
var n = d.name.replace("-","- ").split(" ") // this expression is repeated
return n.length/3-(n.length-1)*0.9+'em'
})
.text(function(d)
{
var n = d.name.replace("-","- ").split(" ")
// return first part of name
return n[0]
})
// some kind of loop would start here
.append("tspan").attr('x',0).attr('dy','1em').text(function(d)
{
var n = d.name.replace("-","- ").split(" ");
if (n.length > 1) return n[1];
})
// second round of loop would be this
.append("tspan").attr('x',0).attr('dy','1em').text(function(d)
{
var n = d.name.replace("-","- ").split(" ");
if (n.length > 2) return n[2];
})
Perhaps I could use the following code. The problem is that n is created (and recreated) inside the method, and if I save it outside it will reference the wrong data. The solution would be to be able to put this code inside one of the methods, but I couldn't make it work (neither in the text method, nor in the tspan append method):
d3.select(this).append("tspan").attr('x',0).attr('dy','1em').text( n[i] )
Looks like a job for .each:
node.append("text")
.each(function(d) {
// split name by space and -
var n = d.name.replace("-","- ").split(" ");
// get the current element
var text = d3.select(this)
.attr("dy", n.length / 3 - (n.length-1) * 0.9 + 'em')
.text(n[0]);
// now loop
for (var i = 1; i < n.length; i++) {
text.append("tspan")
.attr('x', 0)
.attr('dy', '1em')
.text(n[i])
}
});
One big advantage of .each, as shown here, is that it gives you a per-element scope to work with, making it easy to avoid repeated calculations like this.
I am trying to complete the last bit of a d3 project which dynamically creates these blue arcs, over which I need to place arc text, as shown in this image:
The image above is something I've done by placing the arc text statically, through trial and error, but I want to place it dynamically, based on the blue arcs which sit beneath the text. This is the code that dynamically creates the arcs:
var groupData = data_group.selectAll("g.group")
.data(nodes.filter(function(d) { console.log(d.__data__.key); return (d.key=='Employers' ||{exp:channel:entries category="13" backspace="2"} d.key == '{url_title}' ||{/exp:channel:entries}) && d.children; }))
.enter().append("group")
.attr("class", "group");
arc_group.selectAll("g.arc")
.data(groupData[0])
.enter().append("svg:path")
.attr("d", groupArc)
.attr("class", "groupArc")
.style("fill", "#1f77b4")
.style("fill-opacity", 0.5);
The {exp:} content is preparsed data I'm pulling from my content management system in expression engine if it looks confusing.
So, I have my arcs. Now you'll notice in the groupData code block I have a console.log statement, that will give me the names I want to appear in the arc text:
console.log(d.__data__.key);
Now, the code I was using to place the arc text statically was this:
var arcData = [
{aS: 0, aE: 45,rI:radius - chartConfig.linePadding + chartConfig.arcPadding,rO:radius - chartConfig.linePadding + chartConfig.textPadding-chartConfig.arcPadding}
];
var arcJobsData = d3.svg.arc().innerRadius(arcData[0].rI).outerRadius(arcData[0].rO).startAngle(degToRad(1)).endAngle(degToRad(15));
var g = d3.select(".chart").append("svg:g").attr("class","arcs");
var arcJobs = d3.select(".arcs").append("svg:path").attr("d",arcJobsData).attr("id","arcJobs").attr("class","arc");
g.append("svg:text").attr("x",3).attr("dy",15).append("svg:textPath").attr("xlink:href","#arcJobs").text("JOBS").attr("class","arcText"); //x shifts x pixels from the starting point of the arc. dy shifts the text y units from the top of the arc
And in this above code, the only thing left that I should need to do is dynamically assign an ID to the arcs, and then reference that ID in the xlink:href attribute, as well as replace the text("JOBS") with text that pulls from d.data__key. Given the code above which dynamically creates the arcs, and given that I know how to dynamically create and retrieve the text I want to place in the arcs using d.__data.key, I should be able to finish this thing off, but I can't figure out how write code in d3 that will take the data and place it in the arcs. Can anybody help with this?
You should give this blog post on nested selections a read; I believe it'll explain what you're trying to do.
Here's the gist. When you add data to your selection, assign the selection to a variable:
var g = data_group.selectAll("g.group")
.data(nodes.filter(function(d) { /* stuff */ }));
That way, you can perform subselections on it, which will receive a single element of the data bound to your g selection. You can use this to add your arcs and text:
g.enter().append('group') // Question: Are you sure you mean 'group' here?
.attr('class', 'group')
g.selectAll('g.arc')
.data(function(d, i) { return d; })
enter().append('path')
// Setup the path here
g.selectAll('text')
.data(function(d, i) { return d; })
.enter().append('text')
.attr('text', function(d) { return d.__data__.key })
The functions that are being used to do data binding in the nested selections (i.e., the g.selectAll()s) are being passed a single element of the data attached to g as d, and i is its index.
Figured this out. Changed the structure of things a bit so it made a little more sense, but essentially what I did is this:
var groupData = data_group.selectAll("g.group")
.data(nodes.filter(function(d) { return (d.key=='Employers' ||{exp:channel:entries category="13" backspace="2"} d.key == '{url_title}' ||{/exp:channel:entries}) && d.children; }))
.enter().append("group")
.attr("class", "group"); //MH - why do we need this group - these elements are empty. Shouldn't this just be an array? Find out how to delete the svg elements without getting rid of the data, which is needed below.
var groupArc = d3.svg.arc()
.innerRadius(ry - 177)
.outerRadius(ry - 157)
.startAngle(function(d) { return (findStartAngle(d.__data__.children)-2) * pi / 180;})
.endAngle(function(d) { console.log(d.__data__.key); return (findEndAngle(d.__data__.children)+2) * pi / 180});
var arc_and_text = arc_group.selectAll("g.arc")
.data(groupData[0])
.enter().append("svg:g")
.attr("class","arc_and_text");
var arc_path = arc_and_text.append("svg:path")
.attr("d", groupArc)
.attr("class", "groupArc")
.attr("id", function(d, i) { return "arc" + i; })
.style("fill", "#1f77b4")
.style("fill-opacity", 0.5); //MH: (d.__data__.key) gives names of groupings
var arc_text = arc_and_text.append("text")
.attr("class","arc_text")
.attr("x", 3)
.attr("dy", 15);
arc_text.append("textPath")
.attr("xlink:href", function(d, i) { return "#arc" + i; })
.attr("class","arc_text_path")
.style("fill","#ffffff")
.text(function(d, i) { return d.__data__.key; });
D3 still mystifies me a bit, and I'm sure this code could be much improved, but it works.