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.
Related
I have a D3.js line chart that I want to update on user-input. One path is for ‘total price’, made up of a fixed price + a variable cost. I also show a ’fixed price’ line (not a path).
I have a slider to change the value of the fixed cost and then update the path and line.
The line takes the new inputted slider value and updates as expected. The path, however, starts being plotted with very negative y values and so doesn’t show on the chart.
Am I missing some logic to this?
If I hard-code a new value for ‘fixedCost’ the path updates as expected, but as soon as I substitute it for document.getElementById('fixed').value - it gives me a negative plot. The same problem occurs on first draw if I use the slider value.
I've successfully updated line charts in D3 before but that's usually loading new data set on a change event. I haven't encountered this problem with paths before. I'm using D3 V4. Below is the code for the slider and for the update function. Thanks
...javascript
var slider = d3.select("#chart").append("p").attr('id', 'slider')
.style('position', 'absolute')
.style('top', height + margin.top + 60 + 'px')
.style('left', margin.left + 'px')
.append("input")
.attr("type", "range")
.attr('id', 'fixed')
.attr("value", 408000)
.attr("min", 0)
.attr("max", 1000000)
.style("width", sliderWidth)
.on("input", updateFixed);
// set starting parameters
// var fixedCost = document.getElementById('fixed').value; // doesn't behave as expected when plotting path.
var fixedCost = 408000;
var valuelineTotCost = d3.line()
.x(function(d) { return x(d.subs); })
.y(function(d) { return y(d.variCost + fixedCost); });
function updateFixed() {
var thisValue = document.getElementById('fixed').value;
d3.select('#sliderText')
.text("Fixed Costs: " + format(thisValue) ); // displays as expected
console.log(thisValue); // returns as expected
svg.select('#fixedCostLine')
.attr("x1", 0)
.attr("x2", width)
.attr("y1", y(thisValue))
.attr("y2", y(thisValue)); // this line updates as expected
// var fixedCost = document.getElementById('fixed').value; // tried this instead of using thisValue but still not behaving as expected
// var fixedCost = thisValue; // also not behaving as expected
var fixedCost = 508000; // behaves as expected
// adjust totCost path
var valuelineTotCost = d3.line()
.x(function(d) { return x(d.subs); })
.y(function(d) { return y(d.variCost + fixedCost); });
svg.select("#totalPath")
.style("stroke", "purple")
.attr("d", valuelineTotCost);
};
...
Here are the original path co-ordinates generated by d3.line followed by the negative y plots given when adjusted (even minutely) using the slider value.
Lastly, using a hard-coded value for the update, switching from the starting point of 408000 to 508000 - gave the third set of plots.
The path generator was reading the value from the input as a string.
thisValue = parseInt(thisValue);
solved the problem.
I've been playing around with this example here for a little while. What I'm trying to do is highlight a single node/circle in the plot (by making it larger with a border; later I want to add text or a letter inside it too).
Currently, I've made the circle for Bhutan larger in the plot like the following:
.attr("r",
function(d){return ( d.countryName === "Bhutan" ? r + 4 : r);})
.attr("stroke", function(d){if (d.countryName==="Bhutan"){return "black"}})
However, it overlaps with the other circles. What would be the best approach to avoid these collisions/overlaps? Thanks in advance.
Link to Plunkr - https://plnkr.co/edit/rG6X07Kzkg9LeVVuL0PH?p=preview
I tried the following to add a letter inside the bhutan circle
//find bhutan circle and add a "B" to it
countriesCircles
.data(data)
.enter().append("text")
.filter(function(d) { return d.countryName === "Bhutan"; })
.text("B");
Updated Plunkr - https://plnkr.co/edit/Bza5AMxqUr2HW9CYdpC6?p=preview
This is a slightly different problem than in this question here: How to change the size of dots in beeswarm plots in D3.js
You have a few options that I can think of:
Set the forceCollide to be your largest possible radius * 1.33, e.g. (r + 4) * 1.33. This will prevent overlapping, but spread things out a lot and doesn't look that great.
Add the radius property to each entry in your array and make the collide work based off that, which will look a bit better but not perform as awesomely for large sets.
Here's an example of how to do that:
...
d3.csv("co2bee.csv", function(d) {
if (d.countryName === "Bhutan") {
d.r = r + 4;
} else {
d.r = r;
}
return d;
}, function(error, data) {
if (error) throw error;
var dataSet = data;
...
var simulation = d3.forceSimulation(dataSet)
...
.force("collide", d3.forceCollide(function(d) { return d.r * 1.33; }))
...
countriesCircles.enter()
.append("circle")
.attr("class", "countries")
.attr("cx", 0)
.attr("cy", (h / 2)-padding[2]/2)
.attr("r", function(d){ return d.r; })
....
Use the row function in d3.csv to add a property to each member of the array called r, and check the country name to determine which one gets the larger value. Then use that value wherever you need to mess with the radius.
I guess it would've been possible to check the country name everywhere the radius was impacted (e.g. .force("collide", d3.forceCollide(function(d) { return d.countryName === "Bhutan" ? (r + 4) * 1.33 : r * 1.33; }), etc.). This feels a bit cleaner to me, but it might be cleaner still by abstracting out the radius from the data entries themselves...
Forked your plunk here: https://plnkr.co/edit/Tet1DVvHtC7mHz91eAYW?p=preview
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 have a geoJSON looking like so
{"type":"FeatureCollection",
"crs":{"type":"name",
"properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},
"features":[{"type":"Feature",
"properties":{"scalerank":10,"natscale":1,"labelrank":8,"featurecla":"Admin-1 capital","name":"Colonia del Sacramento","namepar":null,"namealt":null,"diffascii":0,"nameascii":"Colonia del Sacramento","adm0cap":0,"capalt":0,"capin":null,"worldcity":0,"megacity":0,"sov0name":"Uruguay","sov_a3":"URY","adm0name":"Uruguay","adm0_a3":"URY","adm1name":"Colonia","iso_a2":"UY","note":null,"latitude":-34.479999,"longitude":-57.840002,"changed":4,"namediff":1,"diffnote":"Added missing admin-1 capital. Population from GeoNames.","pop_max":21714,"pop_min":21714,"pop_other":0,"rank_max":7,"rank_min":7,"geonameid":3443013,"meganame":null,"ls_name":null,"ls_match":0,"checkme":0},
"geometry":{"type":"Point","coordinates":[-57.8400024734013,-34.4799990054175]
}}]
}
I want to set to use colorbrewer to chose colors, depending on the value pop_max takes. Then I want to display this point data on a leaflet map through overlaying a svg ontop of leaflet. I can easily display the points and chose the color like so:
var feature = g.selectAll("path")
.data(collection.features)
.enter()
.append("path")
.style("fill", function(d) {
if(d.properties.pop_max) < 1000 {
return("red")
} else if {....
};
});
However, inconvenient.
So i tried:
var colorScale = d3.scale.quantize()
.range(colorbrewer.Greens[7])
.domain(0,30000000);
var feature = g.selectAll("path")
.data(collection.features)
.enter()
.append("path")
.style("fill", function(d) {
colorScale(d.properties.pop_max);
});
That does not display any points at all... Note that I estimated my domain. 0 is not necessarily the lowest number nor 30000000 the highest.
Any ideas?
First you'll need to find the max and min pop_max, something like this should work:
var extent = d3.extent(geojson.features, function(d) { return d.properties.pop_max; });
Second, since you want colors to represent 7 ranges of values you should be using d3.scale.threshold:
var N = 7,
step = (extent[1] - extent[0]) / (N + 1),
domain = d3.range(extent[0], extent[1] + step, step);
var colorScale = d3.scale.threshold()
.domain(domain)
.range(colorbrewer.Greens[N]);
EDITS
Looks like quantile can do this easier:
d3.scale.quantile()
.domain([extent[0], extent[1]])
.range(colorbrewer.Greens[N]);
I'm using d3 and I'd like to append a group with basic shapes attached to it, like the following:
startEvent (a circle)
task (a recangle)
endEvent (two circles)
since I'm new to d3 I'd like to know how to append each group dynamically depending on the 'shape type' and avoid to append each shape one by one using a foreach.
this is the code:
var shapes ={
startEvent:function(id,x,y,params){
var radius = 18,
cy = Math.floor(Number(y) + radius),
cx = Math.floor(Number(x) + radius),
g = d3.select('g');
var circle = g.append('circle')
.attr('cx', cx)
.attr('cy', cy)
.attr('r', radius)
.attr('id', id);
if(params.label!==undefined){
var txt = g.append('text')
.attr('y',y).text(params.label);
txt.attr('x',Number(x));
txt.attr('y',Number(y));
}
return g;
},
endEvent:function(id,x,y, params){
// something similar to startEvent, but with two circles instead of one
},
task:function(id,x,y, params){
// something similar but with a rectangle
}
};
passing the data and rendering the elements:
svg.selectAll('g')
.data(data)
.enter()
.append(function(d){
params={label: d.meta.name};
return shapes[d.type](d.id,d.x,d.y,params);
});
but I'm getting
Error: Failed to execute 'appendChild' on 'Node': The new child
element is null.
I guess that's because I'm returning the selector, any ideas?
based on this and this answers I got to the following point, it seems like you need to create an instance manually under the d3 namespace, once you got that you can use a d3 selector over it and return the node() of the element which return the actual DOM code.
this is the code:
var shapes ={
startEvent:function(id,x,y,params){
var radius = 18,
cy = Math.floor(Number(y) + radius),
cx = Math.floor(Number(x) + radius),
e = document.createElementNS(d3.ns.prefix.svg,'g'),
g = d3.select(e).attr('id', id).
attr('class','node');
var circle = g.append('circle')
.attr('cx', cx)
.attr('cy', cy)
.attr('r', radius)
.attr('class','circle');
if(params.label!==undefined){
var txt = g.append('text')
.attr('y',y).text(params.label);
txt.attr('x',Number(x));
txt.attr('y',Number(y));
}
return g;
},
endEvent:function(id,x,y, params){
// something similar to startEvent, but with two circles instead of one
},
task:function(id,x,y, params){
// something similar but with a rectangle
}
};
and then return the node
svg.selectAll('g')
.data(data)
.enter()
.append(function(d){
params={label: d.meta.name};
var v = shapes[d.type](d.id,d.x,d.y,params);
return v.node();
});