I would like to create a graphic in D3 that consists of nodes connected to each other with curved lines. The lines should be curved differently depending on how far apart the start and end point of the line are.
For example (A) is a longer connection and therefore is less curved than (C).
Which D3 function is best used for this calculation and how is it output as SVG path
A code example (for example on observablehq.com) would help me a lot.
Here is a code example in obserbavlehq.com
https://observablehq.com/#garciaguillermoa/circles-and-links
I will try to explain it, let me know if there is something I am not clear enough:
Lets start with our circles, we use d3.pie() to position this circles, passing the data defined above, it will return us some arcs, but as we want circles instead of arcs, we use arc.centroid to get the coordinates of our circles
Value is required for the spacing in the pie layout that we use to calculate the position, if you want more circles, you will need to reduce the value, here is the related code:
pie = d3
.pie()
.sort(null)
.value((d) => {
return d.value;
});
arc = d3.arc().outerRadius(300).innerRadius(50);
data = [
{ id: 0, value: 10 },
{ id: 1, value: 10 },
{ id: 2, value: 10 },
{ id: 3, value: 10 },
{ id: 4, value: 10 },
{ id: 5, value: 10 },
{ id: 6, value: 10 },
{ id: 7, value: 10 },
{ id: 8, value: 10 },
{ id: 9, value: 10 },
];
const circles = [];
for(let item of pieData) {
const [x, y] = arc.centroid(item);
circles.push({x, y});
}
Now we can render the circles:
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const mainGroup = svg
.append("g")
.attr("id", "main")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
// Insert lines and circles groups, lines first so they are behind circles
const linesGroup = mainGroup.append("g").attr("id", "lines");
const circlesGroup = mainGroup.append("g").attr("id", "circles");
circlesGroup
.selectAll("circle")
.data(circles, (_, index) => index)
.join((enter) => {
enter
.append("circle")
.attr("id", (_, index) => {
return `circle-${index}`;
})
.attr("r", 20)
.attr("cx", (d) => {
return d.x;
})
.attr("cy", (d) => {
return d.y;
})
.style("stroke-width", "2px")
.style("stroke", "#000")
.style("fill", "#963cff");
});
Now we need to declare the links, we could do this with an array specifying the id of the source and destination (from and to). we use this to search each circle, get its coordinates (the source and destination of our links) and then create the links, in order to create them, we can use a path and the d3 method quadraticCurveTo, this function requires four parameters, the first two are "the control point" which defines our curve, we use 0, 0 as it is the center of our viz (it is the center because we used a translate in the parent group).
lines = [
{
from: 1,
to: 3,
},
{
from: 8,
to: 4,
},
];
for (let line of lines) {
const fromCircle = circles[line.from];
const toCircle = circles[line.to];
const fromP = { x: fromCircle.x, y: fromCircle.y };
const toP = { x: toCircle.x, y: toCircle.y };
const path = d3.path();
path.moveTo(fromP.x, fromP.y);
path.quadraticCurveTo(0, 0, toP.x, toP.y);
linesGroup
.append("path")
.style("fill", "none")
.style("stroke-width", "2px")
.style("stroke-dasharray", "10 10")
.style("stroke", "#000")
.attr("d", path);
}
Related
I have rewritten most of my d3 code to v4, but the new update pattern is throwing me off. The example below is for a force diagram. A duplicate circle is created within the first container upon every update. The data in my example does not actually change, but it's irrelevant. If I use new data, the same issue (a duplicate circle) occurs.
var w = 800,
h = 500;
var svg = d3.select("body").append("svg")
.attr("width", w)
.attr("height", h);
var dataset = {};
function setData() {
dataset.nodes = [{
value: 200
}, {
value: 100
}, {
value: 50
}];
}
setData();
var rScale = d3.scaleSqrt()
.range([0, 100])
.domain([0, d3.max(dataset.nodes.map(function(d) {
return d.value;
}))]);
var node = svg.append("g")
.attr("class", "nodes")
.attr("transform", "translate(" + w / 2 + "," + h / 2 + ")")
.selectAll(".node");
var simulation = d3.forceSimulation(dataset.nodes)
.force("charge", d3.forceManyBody().strength(-1600))
.force("x", d3.forceX())
.force("y", d3.forceY())
.alphaDecay(.05)
.on("tick", ticked);
function ticked() {
node.selectAll("circle")
.attr("cx", function(d) {
return d.x;
})
.attr("cy", function(d) {
return d.y;
});
}
function restart() {
// Apply the general update pattern to the nodes.
node = node.data(dataset.nodes, function(d) {
return d.id;
});
node.exit().remove();
node = node.enter().append("g")
.attr("class", "node")
.merge(node);
node.append("circle")
.attr("r", function(d) {
return rScale(d.value);
});
// Update and restart the simulation.
simulation.nodes(dataset.nodes);
simulation.alpha(1).restart();
}
restart();
function update() {
setData();
restart();
}
d3.select("#update").on("click", update);
If you click the Update button in this codepen (https://codepen.io/cplindem/pen/wpQbQe), you will see all three circles animate as the simulation restarts, but behind the largest circle, there is another, identical circle that does not animate. You can also see the new circle appear in the html if you inspect it.
What am I doing wrong?
Your first problem seems to be that you are keying the data on an 'id' field, but your data doesn't have any ids, so that needs changed or you just keep adding new groups:
function setData() {
dataset.nodes = [{
value: 200,
id: "A"
}, {
value: 100,
id: "B"
}, {
value: 50,
id: "C"
}];
console.log("dataset", dataset);
}
The second problem is you merge the new and updated selection and then append new circles to all of them, even the existing ones (so you have multiple circles per group on pressing update). I got it to work by doing this: make the new nodes, merge with existing selection, add circles to just the new nodes, update the circles in all the nodes:
node.exit().remove();
var newNodes = node.enter().append("g");
node = newNodes
.attr("class", "node")
.merge(node);
newNodes.append("circle");
node.select("circle")
.attr("r", function(d) {
return rScale(d.value);
});
Whether that 2nd bit is optimal I don't know, I'm still more anchored in v3 myself...
https://codepen.io/anon/pen/WdLexR
I'm having trouble figuring out how to add a title element with bound data after using .datum()
When .datum() is called 'd' contains all of the expected properties, but after calling .datum() subsequent attempts to access the properties fails... 'd' contains only the path:
var oc = og.selectAll('.oc-circle')
.data(function(d) { return [d]; }, get_key);
oc.enter()
.append('path')
.attr({ 'class': 'occ oc-circle' });
oc.exit().remove();
oc
.datum(function(d) {
console.log(d);
// d has all of its properties
// Object {type: "Feature", properties: Object, geometry: Object, id: "nn00564043"}
return circle
.origin([d.geometry.coordinates[0], d.geometry.coordinates[1]])
.angle(.5)();
})
.style({
"fill" : 'red',
'opacity': 0.75,
})
.attr("d", geoPath)
.append('title')
.text(function(d) {
console.log(d);
// d only contains path data
// Object {type: "Polygon", coordinates: Array[1]}
// return 'Magnitue ' + d.properties.mag + ' ' + d.properties.place;
})
The inline comments above show what is accessible after calling .datum()
What am I missing?
Thanks!
Mark's suggestion was correct. With the help of this fiddle: http://jsfiddle.net/Lg9z57g5/ I came up with the following, including functions to draw circles using either d3.geo.circle() or path.pointRadius()
var pointPath = function(d, r) {
var coords = [d.geometry.coordinates[0], d.geometry.coordinates[1]];
var pr = geoPath.pointRadius(globe.scale() / 100 * 1.5);
return pr({
type: "Point",
coordinates: coords
})
}
var circlePath = function(d) {
var circle = d3.geo.circle();
var coords = [d.geometry.coordinates[0], d.geometry.coordinates[1]];
var cc = circle.origin(coords).angle(.5)();
return geoPath(cc);
}
var oc = og.selectAll('.oc-circle')
.data(function(d) { return [d]; }, get_key);
oc.enter()
.append('path')
.attr({ 'class': 'occ oc-circle' });
oc.exit().remove();
oc
.attr("d", pointPath)
.style({
'fill' : 'red',
'opacity': 0.75,
})
.append('title')
.text(function(d) {
return 'Magnitue ' + d.properties.mag + ' ' + d.properties.place;
})
The bigger issue in my situation was the reDraw() function, which was basically:
surface.selectAll("path").attr("d", d3.geo.path().projection(projection));
this expected all data bound to paths to contain d.geometry.coordinates
I had to add this line:
surface.selectAll('.occ').attr('d', pointPath)
I learned that when appending a 'path' with d3 it is not necessary to add .attr('d', path) if the bound data is already in the correct format for path.
Your .datum call is returning
return circle
.origin([d.geometry.coordinates[0], d.geometry.coordinates[1]])
.angle(.5)();
which is a path and this is what's being data-bound, not d.
I'm not sure you need that datum call at all:
oc
.style({
"fill" : 'red',
'opacity': 0.75,
})
.attr("d", function(d){
return geopath(circle
.origin([d.geometry.coordinates[0], d.geometry.coordinates[1]])
.angle(.5)());
})
.append('title')
.text(function(d) {
console.log(d);
// d only contains path data
// Object {type: "Polygon", coordinates: Array[1]}
// return 'Magnitue ' + d.properties.mag + ' ' + d.properties.place;
})
Thanks in advance for any help.
I'm new to D3 and javascript as a whole. I've been pretty stuck on this for a while now even searching through other similar posts.
I'd like to flip my stacked bar chart appropriately so that it aligns to the bottom of the SVG.
When I do try it the way I think it should be done, I get a "invalid negative value for '' message.
var dataset = [
[
{ x: 0, y: 5 },
{ x: 1, y: 4 },
{ x: 2, y: 2 },
{ x: 3, y: 7 },
{ x: 4, y: 23 }
],
[
{ x: 0, y: 10 },
{ x: 1, y: 12 },
{ x: 2, y: 19 },
{ x: 3, y: 23 },
{ x: 4, y: 17 }
],
[
{ x: 0, y: 22 },
{ x: 1, y: 28 },
{ x: 2, y: 32 },
{ x: 3, y: 35 },
{ x: 4, y: 43 }
]
];
//Width and Height
var w = 500;
h = 300;
//Create SVG canvas
var svg = d3.select('body').append('svg')
.attr('width', w)
.attr('height', h);
//Set up Stack
var stack = d3.layout.stack();
//Stack dataset
stack(dataset);
//Create scales
var xScale = d3.scale.ordinal()
.domain(d3.range(dataset[0].length))
.rangeRoundBands([0,w], 0.05);
var yScale = d3.scale.linear()
.domain([0,
d3.max(dataset, function(d) {
return d3.max(d, function(d) {
return d.y0 + d.y;
});
})
])
.range([0, h]);
//Create colors for scale
var colors = d3.scale.category10();
//Create a Group for each row of data
var groups = svg.selectAll('g')
.data(dataset)
.enter() //only creates placeholder
.append('g') //creates group
.style('fill', function(d, i) {
return colors(i);
});
//Add a rectangle for each datavalue
var rects = groups.selectAll("rect")
.data(function(d) { return d; })
.enter()
.append("rect")
.attr("x", function(d, i) {
return xScale(i);
})
.attr("y", function(d) { return yScale(d.y0 + d.y); })
.attr("height", function(d) { return yScale(d.y0) - yScale(d.y0 + d.y); })
.attr("width", xScale.rangeBand());
I think you need to change the way height is getting calculated for each bar. See this plnkr. Is this something you were looking for. May be you can use height (h) variable for your calculations. I changed y parameters like below.
//Add a rectangle for each datavalue
var rects = groups.selectAll("rect")
.data(function(d) {
return d;
})
.enter()
.append("rect")
.attr("x", function(d, i) {
return xScale(i);
})
/* .attr("y", function(d) { return yScale(d.y0 + d.y); })
.attr("height", function(d) { return yScale(d.y0) - yScale(d.y0 + d.y); })
.attr("y", function(d) { return yScale(d.y0 ); })
.attr("height", function(d) { return yScale(d.y0 + d.y);})
*/
.attr("y", function(d) {
return yScale(d.y0 + d.y);
})
.attr("height", function(d) {
return h - yScale(d.y0 + d.y);
})
.attr("width", xScale.rangeBand());
I'm trying to use attrTween in d3 to animate a pie chart when the page is loaded but it's not working for me. I've used attrTween before to animate a change in data and it's worked fine but this time I want to 'grow' the pie chart when the page is loaded first but it's not behaving as expected and I'm not getting any information as to why this is.
If I remove the line .attrTween('d' arcTweenStart); then everything works fine except of course it does not animate. If the line is left in then nothing is displayed and the arcTweenStart function is never entered. Can anyone spot where I'm going wrong?
function drawCharts()
{
// Create the chart and bind the data to it and position it
var pieChart = d3.select("#groupRisk").selectAll("svg")
.data(dataSet) // Bind the data to the chart
.enter().append("svg")
.attr("id", "pie")
.attr("width", w) // Set th width
.attr("height", h) // Set the height
.append("g")
.attr("transform", "translate(" + radius + "," + radius + ")"); // Position the chart
// Create the pie chart layout
var pie = d3.layout.pie()
.value(function(d) { return d.count; })
.sort(null); // Sort is set to null to allow for better looking tweens
// Create "slices" for each data element
var arcs = pieChart.selectAll("g.slice")
.data(pie) // Bind the pie layout to the slices
.attr("id", "arcs")
.enter()
.append("g")
.attr("class", "slice");
// Create the graphics for each slice and colour them
arcs.append("path")
.attr("fill", function(d, i) { return color(i); })
.attr("d", arc)
.each(function(d) { this._current = d; })
.transition()
.duration(500)
.attrTween('d' arcTweenStart);
}
function arcTweenStart(b)
{
var start =
{
startAngle: b.startAngle,
endAngle: b.endAngle
};
var i = d3.interpolate(start, b);
return function(t)
{
return arc(i(t));
};
}
EDIT:
My data set looks like this:
var dataSet=
[
[
{ "label": "Green", "count": 40 },
{ "label": "Amber", "count": 50 },
{ "label": "Red", "count": 10 }
],
[
{ "label": "Green", "count": 20 },
{ "label": "Amber", "count": 30 },
{ "label": "Red", "count": 50 }
],
[
{ "label": "Green", "count": 50 },
{ "label": "Amber", "count": 20 },
{ "label": "Red", "count": 30 }
]
];
I have an array of data sets so I want to draw a chart for each one.
You don't show what your dataSet variable holds (that would have really helped answer the question!) but assuming your data looks like this:
var dataSet = [{
count: 4
}, {
count: 5
}, {
count: 6
}];
You don't need to do the first bind/enter:
d3.select("#groupRisk").selectAll("svg")
.data(dataSet) // Bind the data to the chart
.enter()
...
This would give you a pie chart for each entry in the data. Getting rid of that, your bind then becomes:
var arcs = pieChart.selectAll("g.slice")
.data(pie(dataSet)) //<-- call pie with the dataSet
.attr("id", "arcs")
.enter()
.append("g")
.attr("class", "slice");
But really to the heart of your question, your tween var start, has the same start/end angle as where you want to end. So, you animate the same thing over and over again. What I think you meant is:
function arcTweenStart(b) {
var start = {
startAngle: b.startAngle,
endAngle: b.startAngle //<-- set end to start and adjust on each call
};
var i = d3.interpolate(start, b);
return function(t) {
return arc(i(t));
};
}
Oh, and one typo in there too:
.attrTween('d' arcTweenStart); //<-- comma missing between 'd' and arcTweenStart
Example here.
How to apply force repulsion on map's labels so they find their right places automatically ?
Bostock' "Let's Make a Map"
Mike Bostock's Let's Make a Map (screenshot below). By default, labels are put at the point's coordinates and polygons/multipolygons's path.centroid(d) + a simple left or right align, so they frequently enter in conflict.
Handmade label placements
One improvement I met requires to add an human made IF fixes, and to add as many as needed, such :
.attr("dy", function(d){ if(d.properties.name==="Berlin") {return ".9em"} })
The whole become increasingly dirty as the number of labels to reajust increase :
//places's labels: point objects
svg.selectAll(".place-label")
.data(topojson.object(de, de.objects.places).geometries)
.enter().append("text")
.attr("class", "place-label")
.attr("transform", function(d) { return "translate(" + projection(d.coordinates) + ")"; })
.attr("dy", ".35em")
.text(function(d) { if (d.properties.name!=="Berlin"&&d.properties.name!=="Bremen"){return d.properties.name;} })
.attr("x", function(d) { return d.coordinates[0] > -1 ? 6 : -6; })
.style("text-anchor", function(d) { return d.coordinates[0] > -1 ? "start" : "end"; });
//districts's labels: polygons objects.
svg.selectAll(".subunit-label")
.data(topojson.object(de, de.objects.subunits).geometries)
.enter().append("text")
.attr("class", function(d) { return "subunit-label " + d.properties.name; })
.attr("transform", function(d) { return "translate(" + path.centroid(d) + ")"; })
.attr("dy", function(d){
//handmade IF
if( d.properties.name==="Sachsen"||d.properties.name==="Thüringen"|| d.properties.name==="Sachsen-Anhalt"||d.properties.name==="Rheinland-Pfalz")
{return ".9em"}
else if(d.properties.name==="Brandenburg"||d.properties.name==="Hamburg")
{return "1.5em"}
else if(d.properties.name==="Berlin"||d.properties.name==="Bremen")
{return "-1em"}else{return ".35em"}}
)
.text(function(d) { return d.properties.name; });
Need for better solution
That's just not manageable for larger maps and sets of labels. How to add force repulsions to these both classes: .place-label and .subunit-label?
This issue is quite a brain storming as I haven't deadline on this, but I'am quite curious about it. I was thinking about this question as a basic D3js implementation of Migurski/Dymo.py. Dymo.py's README.md documentation set a large set of objectives, from which to select the core needs and functions (20% of the work, 80% of the result).
Initial placement: Bostock give a good start with left/right positionning relative to the geopoint.
Inter-labels repulsion: different approach are possible, Lars & Navarrc proposed one each,
Labels annihilation: A label annihilation function when one label's overall repulsion is too intense, since squeezed between other labels, with the priority of annihilation being either random or based on a population data value, which we can get via NaturalEarth's .shp file.
[Luxury] Label-to-dots repulsion: with fixed dots and mobile labels. But this is rather a luxury.
I ignore if label repulsion will work across layers and classes of labels. But getting countries labels and cities labels not overlapping may be a luxury as well.
In my opinion, the force layout is unsuitable for the purpose of placing labels on a map. The reason is simple -- labels should be as close as possible to the places they label, but the force layout has nothing to enforce this. Indeed, as far as the simulation is concerned, there is no harm in mixing up labels, which is clearly not desirable for a map.
There could be something implemented on top of the force layout that has the places themselves as fixed nodes and attractive forces between the place and its label, while the forces between labels would be repulsive. This would likely require a modified force layout implementation (or several force layouts at the same time), so I'm not going to go down that route.
My solution relies simply on collision detection: for each pair of labels, check if they overlap. If this is the case, move them out of the way, where the direction and magnitude of the movement is derived from the overlap. This way, only labels that actually overlap are moved at all, and labels only move a little bit. This process is iterated until no movement occurs.
The code is somewhat convoluted because checking for overlap is quite messy. I won't post the entire code here, it can be found in this demo (note that I've made the labels much larger to exaggerate the effect). The key bits look like this:
function arrangeLabels() {
var move = 1;
while(move > 0) {
move = 0;
svg.selectAll(".place-label")
.each(function() {
var that = this,
a = this.getBoundingClientRect();
svg.selectAll(".place-label")
.each(function() {
if(this != that) {
var b = this.getBoundingClientRect();
if(overlap) {
// determine amount of movement, move labels
}
}
});
});
}
}
The whole thing is far from perfect -- note that some labels are quite far away from the place they label, but the method is universal and should at least avoid overlap of labels.
One option is to use the force layout with multiple foci. Each foci must be located in the feature's centroid, set up the label to be attracted only by the corresponding foci. This way, each label will tend to be near of the feature's centroid, but the repulsion with other labels may avoid the overlapping issue.
For comparison:
M. Bostock's "Lets Make a Map" tutorial (resulting map),
my gist for an Automatic Labels Placement version (resulting map) implementing the foci's strategy.
The relevant code:
// Place and label location
var foci = [],
labels = [];
// Store the projected coordinates of the places for the foci and the labels
places.features.forEach(function(d, i) {
var c = projection(d.geometry.coordinates);
foci.push({x: c[0], y: c[1]});
labels.push({x: c[0], y: c[1], label: d.properties.name})
});
// Create the force layout with a slightly weak charge
var force = d3.layout.force()
.nodes(labels)
.charge(-20)
.gravity(0)
.size([width, height]);
// Append the place labels, setting their initial positions to
// the feature's centroid
var placeLabels = svg.selectAll('.place-label')
.data(labels)
.enter()
.append('text')
.attr('class', 'place-label')
.attr('x', function(d) { return d.x; })
.attr('y', function(d) { return d.y; })
.attr('text-anchor', 'middle')
.text(function(d) { return d.label; });
force.on("tick", function(e) {
var k = .1 * e.alpha;
labels.forEach(function(o, j) {
// The change in the position is proportional to the distance
// between the label and the corresponding place (foci)
o.y += (foci[j].y - o.y) * k;
o.x += (foci[j].x - o.x) * k;
});
// Update the position of the text element
svg.selectAll("text.place-label")
.attr("x", function(d) { return d.x; })
.attr("y", function(d) { return d.y; });
});
force.start();
While ShareMap-dymo.js may work, it does not appear to be very well documented. I have found a library that works for the more general case, is well documented and also uses simulated annealing: D3-Labeler
I've put together a usage sample with this jsfiddle.The D3-Labeler sample page uses 1,000 iterations. I have found this is rather unnecessary and that 50 iterations seems to work quite well - this is very fast even for a few hundred data points. I believe there is room for improvement both in the way this library integrates with D3 and in terms of efficiency, but I wouldn't have been able to get this far on my own. I'll update this thread should I find the time to submit a PR.
Here is the relevant code (see the D3-Labeler link for further documentation):
var label_array = [];
var anchor_array = [];
//Create circles
svg.selectAll("circle")
.data(dataset)
.enter()
.append("circle")
.attr("id", function(d){
var text = getRandomStr();
var id = "point-" + text;
var point = { x: xScale(d[0]), y: yScale(d[1]) }
var onFocus = function(){
d3.select("#" + id)
.attr("stroke", "blue")
.attr("stroke-width", "2");
};
var onFocusLost = function(){
d3.select("#" + id)
.attr("stroke", "none")
.attr("stroke-width", "0");
};
label_array.push({x: point.x, y: point.y, name: text, width: 0.0, height: 0.0, onFocus: onFocus, onFocusLost: onFocusLost});
anchor_array.push({x: point.x, y: point.y, r: rScale(d[1])});
return id;
})
.attr("fill", "green")
.attr("cx", function(d) {
return xScale(d[0]);
})
.attr("cy", function(d) {
return yScale(d[1]);
})
.attr("r", function(d) {
return rScale(d[1]);
});
//Create labels
var labels = svg.selectAll("text")
.data(label_array)
.enter()
.append("text")
.attr("class", "label")
.text(function(d) {
return d.name;
})
.attr("x", function(d) {
return d.x;
})
.attr("y", function(d) {
return d.y;
})
.attr("font-family", "sans-serif")
.attr("font-size", "11px")
.attr("fill", "black")
.on("mouseover", function(d){
d3.select(this).attr("fill","blue");
d.onFocus();
})
.on("mouseout", function(d){
d3.select(this).attr("fill","black");
d.onFocusLost();
});
var links = svg.selectAll(".link")
.data(label_array)
.enter()
.append("line")
.attr("class", "link")
.attr("x1", function(d) { return (d.x); })
.attr("y1", function(d) { return (d.y); })
.attr("x2", function(d) { return (d.x); })
.attr("y2", function(d) { return (d.y); })
.attr("stroke-width", 0.6)
.attr("stroke", "gray");
var index = 0;
labels.each(function() {
label_array[index].width = this.getBBox().width;
label_array[index].height = this.getBBox().height;
index += 1;
});
d3.labeler()
.label(label_array)
.anchor(anchor_array)
.width(w)
.height(h)
.start(50);
labels
.transition()
.duration(800)
.attr("x", function(d) { return (d.x); })
.attr("y", function(d) { return (d.y); });
links
.transition()
.duration(800)
.attr("x2",function(d) { return (d.x); })
.attr("y2",function(d) { return (d.y); });
For a more in depth look at how D3-Labeler works, see "A D3 plug-in for automatic label placement using simulated
annealing"
Jeff Heaton's "Artificial Intelligence for Humans, Volume 1" also does an excellent job at explaining the simulated annealing process.
You might be interested in the d3fc-label-layout component (for D3v5) that is designed exactly for this purpose. The component provides a mechanism for arranging child components based on their rectangular bounding boxes. You can apply either a greedy or simulated annealing strategy in order to minimise overlaps.
Here's a code snippet which demonstrates how to apply this layout component to Mike Bostock's map example:
const labelPadding = 2;
// the component used to render each label
const textLabel = layoutTextLabel()
.padding(labelPadding)
.value(d => d.properties.name);
// a strategy that combines simulated annealing with removal
// of overlapping labels
const strategy = layoutRemoveOverlaps(layoutGreedy());
// create the layout that positions the labels
const labels = 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 + labelPadding * 2, textSize.height + labelPadding * 2];
})
.position(d => projection(d.geometry.coordinates))
.component(textLabel);
// render!
svg.datum(places.features)
.call(labels);
And this is a small screenshot of the result:
You can see a complete example here:
http://bl.ocks.org/ColinEberhardt/389c76c6a544af9f0cab
Disclosure: As discussed in the comment below, I am a core contributor of this project, so clearly I am somewhat biased. Full credit to the other answers to this question which gave us inspiration!
For 2D case
here are some examples that do something very similar:
one http://bl.ocks.org/1691430
two http://bl.ocks.org/1377729
thanks Alexander Skaburskis who brought this up here
For 1D case
For those who search a solution to a similar problem in 1-D i can share my sandbox JSfiddle where i try to solve it. It's far from perfect but it kind of doing the thing.
Left: The sandbox model, Right: an example usage
Here is the code snippet which you can run by pressing the button in the end of the post, and also the code itself. When running, click on the field to position the fixed nodes.
var width = 700,
height = 500;
var mouse = [0,0];
var force = d3.layout.force()
.size([width*2, height])
.gravity(0.05)
.chargeDistance(30)
.friction(0.2)
.charge(function(d){return d.fixed?0:-1000})
.linkDistance(5)
.on("tick", tick);
var drag = force.drag()
.on("dragstart", dragstart);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.on("click", function(){
mouse = d3.mouse(d3.select(this).node()).map(function(d) {
return parseInt(d);
});
graph.links.forEach(function(d,i){
var rn = Math.random()*200 - 100;
d.source.fixed = true;
d.source.px = mouse[0];
d.source.py = mouse[1] + rn;
d.target.y = mouse[1] + rn;
})
force.resume();
d3.selectAll("circle").classed("fixed", function(d){ return d.fixed});
});
var link = svg.selectAll(".link"),
node = svg.selectAll(".node");
var graph = {
"nodes": [
{"x": 469, "y": 410},
{"x": 493, "y": 364},
{"x": 442, "y": 365},
{"x": 467, "y": 314},
{"x": 477, "y": 248},
{"x": 425, "y": 207},
{"x": 402, "y": 155},
{"x": 369, "y": 196},
{"x": 350, "y": 148},
{"x": 539, "y": 222},
{"x": 594, "y": 235},
{"x": 582, "y": 185}
],
"links": [
{"source": 0, "target": 1},
{"source": 2, "target": 3},
{"source": 4, "target": 5},
{"source": 6, "target": 7},
{"source": 8, "target": 9},
{"source": 10, "target": 11}
]
}
function tick() {
graph.nodes.forEach(function (d) {
if(d.fixed) return;
if(d.x<mouse[0]) d.x = mouse[0]
if(d.x>mouse[0]+50) d.x--
})
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; });
node.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
}
function dblclick(d) {
d3.select(this).classed("fixed", d.fixed = false);
}
function dragstart(d) {
d3.select(this).classed("fixed", d.fixed = true);
}
force
.nodes(graph.nodes)
.links(graph.links)
.start();
link = link.data(graph.links)
.enter().append("line")
.attr("class", "link");
node = node.data(graph.nodes)
.enter().append("circle")
.attr("class", "node")
.attr("r", 10)
.on("dblclick", dblclick)
.call(drag);
.link {
stroke: #ccc;
stroke-width: 1.5px;
}
.node {
cursor: move;
fill: #ccc;
stroke: #000;
stroke-width: 1.5px;
opacity: 0.5;
}
.node.fixed {
fill: #f00;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<body></body>