wrapping text on circular textpath - javascript

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!

Related

Best way to fit variably-sized text into a semi-circle in d3.js?

I am currently working on a visualization in d3.js with the goal to visualize data similarity. I wish to compare my data within circles by creating two semi-circles for each node and putting the comparison data within these semicircles. My data consists of strings (each semicircle receives a single sentence).
My current approach is as follows:
First, I create my necessary node data using the pack-layout.
var bubble = d3.pack().size([SVG_WIDTH,SVG_HEIGHT]).padding(CIRCLE_PADDING),
root = d3.hierarchy({children: COMPARISON_DATA}).sum(function(d){ return d.children ? 0 : d[2]});
var nodeData = bubble(root).children;
d[2] is the maximum string length of the two sentences that are being put into the semicircles and thus decides the radius of the circles.
Next, I iterate over each node and create the corresponding semicircles. I have removed all the code-parts which are irrelevant to my question.
nodeData.forEach(function (data, index) {
//upperCircleGroup simply adds a small y-translate, so that the semicircles have a margin
var gUpper = upperCircleGroup.append("g");
var gLower = lowerCircleGroup.append("g");
var lowerCircle = gLower.append('path')
.attr('d', d3.arc()({
innerRadius: 0,
outerRadius: data.r,
startAngle: Math.PI / 2,
endAngle: 3 / 2 * Math.PI
}))
.attr('transform', `translate(${data.x},${data.y})`)
var upperCircle = gUpper.append('path')
.attr('d', d3.arc()({
innerRadius: 0,
outerRadius: data.r,
startAngle: 1 / 2 * Math.PI,
endAngle: - 1 / 2 * Math.PI
}))
.attr('transform', `translate(${data.x},${data.y})`)
var upperText = gUpper
.append("foreignObject")
.attr("width", () => {return data.r*Math.sqrt(2)})
.attr("height", () => {return data.r*(Math.sqrt(2)/2)})
.attr('transform', `translate(${data.x - (data.r / Math.sqrt(2))},${data.y - (data.r/Math.sqrt(2)) })`)
.text(() => {return data.data[0]})
var lowerText = gLower
.append("foreignObject")
.attr("width", () => {return data.r*Math.sqrt(2)})
.attr("height", () => {return data.r*(Math.sqrt(2)/2)})
.attr('transform', `translate(${data.x - (data.r / Math.sqrt(2))},${data.y })`)
.text(() => {return data.data[1]})
});
As you can see, I draw my semicircles using d3's arc. Now this is where my question arises. I've had trouble putting my textual content inside the arc, so after searching for a while I chose this solution to put a div inside my semicircles which then receives the text. The sqrt(2) operations are used to fit the square into the semicircle.
My problem with this solution is, that at times, the sentence simply won't fit into the div and some content is lost. Is there a way to calculate the font-size of a string necessary, so that it fits the div of a given size? If this were possible, I could simply calculate the appropriate font-size and add a zoom option to the visualization. Also, if there are better ways to achieve what I am trying to do I would also be happy to get some feedback from you guys as I am a complete beginner when it comes to using d3.
Making text responsive to an element is difficult but CSS-Tricks have made a great article about different ways to approach it...
https://css-tricks.com/fitting-text-to-a-container/

How to draw a svg line from center of other element in transformed group with snap.svg?

I have three shapes all in the same group. This group have been transformed. I want to draw a line from one of the elements within that group. I am trying to access this elements coordinates by:
s.select("#hitboxHeel").getBBox().cx and s.select("#hitboxHeel").getBBox().cy
However this gives some weird coordinates, that are far off from where they should be. How do i get the actual position of the points, thus being able to draw a line?
I had a similar problem and found the solution from this post : Rectangle coordinates after transform
Here, you basically want to apply the 'aggregated' transform matrix of your shape to coordinates that are not transformed (sorry for the awkward phrasing). This matrix also incorporates the transformations of parent elements (group nodes here), so you shouldn't have to worry about them.
So, given :
your native svg element node
your native svg container svg
your original point of interest coordinates (before transforms) x and y that you want transformed
the expected transformed coordinates of your original point transformedPoint
`
// get the component transform matrix
var ctm = node.getCTM();
var svgPoint = svg.createSVGPoint();
svgPoint.x = x;
svgPoint.y = y;
// apply the matrix to the point
var transformedPoint = svgPoint.matrixTransform(ctm);
// an example using d3.js ( svg > g > rect )
// get the center of the rectangle after tansformations occured
var svg = d3.select('body').append('svg')
.attr('width', 500)
.attr('height', 500)
.attr('id', 'myCanvas')
.style('margin', 100)
var g = svg.append('g')
.attr('transform', 'translate(-10,10)')
var r = g.append('rect')
.attr('x', 300).attr('y', 100).attr('width', 79).attr('height', 150)
.attr('transform', 'translate(-54,300)rotate(-30,30,20)')
.attr('stroke', 'black')
.attr('fill', 'red')
var pt = svg.node().createSVGPoint()
pt.x = parseInt(r.attr('x')) + parseInt(r.attr('width')) / 2
pt.y = parseInt(r.attr('y')) + parseInt(r.attr('height')) / 2
var ctm = r.node().getCTM()
var center = pt.matrixTransform(ctm)
console.log('the transformed rectangle center', center)
// draw the center to confirm the accuracy of the process
svg.append('circle')
.attr('cx', center.x).attr('cy', center.y).attr('r', 5)
.attr('stroke', 'black')
.attr('fill', 'blue')
`

D3 put arc labels in a Pie Chart if there is enough space

I will put a text element in every arc of my Pie Chart (center) - as shown in this example:
http://bl.ocks.org/mbostock/3887235
But I will only put the text element if the room is sufficient for the whole text, so im must compare the size of my text element with the "available" space in every arc.
I think I can do this with getBBox() to get the text dimensions... but how can I get (and compare) the dimension of the available space in every arc.
thx...!
This question has been asked several times before.
The solutions I have suggested there is to rotate the label but it has never quite satisfied me. Part of it was the horrible font rendering done by some browsers and loss in legibility that brings and the weird flip when one label crosses over the 180° line. In some cases, the results were acceptable and unavoidable, e.g. when the labels were too long.
One of the other solution, the one suggested by Lars, is to put the labels outside the pie chart. However, that just pushes the labels outside, granting them a larger radius, but does not solve the overlap problem completely.
The other solution is actually using the technique you suggest: just remove the labels which do not fit.
Hide overflowing labels
Compare Original, which has >= 65 label overflowing to Solution where the overflowing label is gone.
Reducing the problem
The key insight is to see that this problem is of finding whether one convex polygon (a rectangle, the bounding box) is contained inside another convex polygon(-ish) (a wedge).
The problem can be reduced to finding whether all the points of the rectangle lie inside the wedge or not. If they do, then the rectangle lies inside the arc.
Does a point lie inside a wedge
Now that part is easy. All one needs to do is to check:
The distance of the point from the center is less than the radius
The angle subtended by the point on the center is between the startAngle and endAngle of the arc.
function pointIsInArc(pt, ptData, d3Arc) {
// Center of the arc is assumed to be 0,0
// (pt.x, pt.y) are assumed to be relative to the center
var r1 = d3Arc.innerRadius()(ptData), // Note: Using the innerRadius
r2 = d3Arc.outerRadius()(ptData),
theta1 = d3Arc.startAngle()(ptData),
theta2 = d3Arc.endAngle()(ptData);
var dist = pt.x * pt.x + pt.y * pt.y,
angle = Math.atan2(pt.x, -pt.y); // Note: different coordinate system.
angle = (angle < 0) ? (angle + Math.PI * 2) : angle;
return (r1 * r1 <= dist) && (dist <= r2 * r2) &&
(theta1 <= angle) && (angle <= theta2);
}
Find the bounding box of the labels
Now that we have that out of the way, the second part is figuring out what are the four corners of the rectangle. That, also, is easy:
g.append("text")
.attr("transform", function(d) { return "translate(" + arc.centroid(d) + ")"; })
.attr("dy", ".35em")
.style("text-anchor", "middle")
.text(function(d) { return d.data.age; })
.each(function (d) {
var bb = this.getBBox(),
center = arc.centroid(d);
var topLeft = {
x : center[0] + bb.x,
y : center[1] + bb.y
};
var topRight = {
x : topLeft.x + bb.width,
y : topLeft.y
};
var bottomLeft = {
x : topLeft.x,
y : topLeft.y + bb.height
};
var bottomRight = {
x : topLeft.x + bb.width,
y : topLeft.y + bb.height
};
d.visible = pointIsInArc(topLeft, d, arc) &&
pointIsInArc(topRight, d, arc) &&
pointIsInArc(bottomLeft, d, arc) &&
pointIsInArc(bottomRight, d, arc);
})
.style('display', function (d) { return d.visible ? null : "none"; });
The pith of the solution is in the each function. We first place the text at the right place so that the DOM renders it. Then we use the getBBox() method to get the bounding box of the text in the user space. A new user space is created by any element which has a transform attribute set on it. That element, in our case, is the text box itself. So the bounding box returned is relative to the center of the text, as we have set the text-anchor to be middle.
The position of the text relative to the arc can be calculated since we have applied the transformation 'translate(' + arc.centroid(d) + ')' to it. Once we have the center, we just calculate the topLeft, topRight, bottomLeft and bottomRight points from it and see whether they all lie inside the wedge.
Finally, we determine if all the points lie inside the wedge and if they do not fit, set the display CSS property to none.
Working demo
Original
Solution
Note
I am using the innerRadius which, if non zero, makes the wedge non-convex which will make the calculations much more complex! However, I think the danger here is not significant since the only case it might fail is this, and, frankly, I don't think it'll happen often (I had trouble finding this counter example):
x and y are flipped and y has a negative sign while calculating Math.atan2. This is because of the difference between how Math.atan2 and d3.svg.arc view the coordinate system and the direction of positive y with svg.
Coordinate system for Math.atan2
θ = Math.atan2(y, x) = Math.atan2(-svg.y, x)
Coordinate system for d3.svg.arc
θ = Math.atan2(x, y) = Math.atan2(x, -svg.y)
You can't really do this with the bounding box because the bounding box is much larger than a wedge for the pie chart wedges. That is, even though the wedge at the outer edge would be wide enough to accommodate the text, that doesn't mean that it's wide enough at the actual position of the text.
Unfortunately, there's no easy way of doing what you're trying to do (pixel-level overlap testing). See e.g. this question for some more information. I would suggest simply putting the text labels outside of the pie chart so you don't run into this problem.

How to linebreak an svg text in javascript?

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.

d3.js / svg - how to dynamically append text to my arcs

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.

Categories

Resources