Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active August 29, 2015 14:04
Node-link polar layout: curved links

Same as this experiment, but with curved links. The slider controls the tension, i.e. the strength with which edges are attracted to the center of the diagram.

# data
graph_data = {
nodes: [
{id: 'A'},
{id: 'B'},
{id: 'C'},
{id: 'D'},
{id: 'E'},
{id: 'F'},
{id: 'G'}
],
links: [
{source: 'A', target: 'B', weight: 12},
{source: 'A', target: 'C', weight: 2},
{source: 'A', target: 'D', weight: 33},
{source: 'A', target: 'F', weight: 5},
{source: 'A', target: 'G', weight: 24},
{source: 'B', target: 'D', weight: 10},
{source: 'B', target: 'E', weight: 10},
{source: 'B', target: 'F', weight: 8},
{source: 'B', target: 'G', weight: 16},
{source: 'C', target: 'D', weight: 29},
{source: 'C', target: 'E', weight: 11},
{source: 'D', target: 'E', weight: 4},
{source: 'D', target: 'F', weight: 12},
{source: 'E', target: 'F', weight: 19}
]
}
# objectify the graph
# resolve node IDs (not optimized at all!)
graph_data.links.forEach (l) ->
graph_data.nodes.forEach (n) ->
if l.source is n.id
l.source = n
if l.target is n.id
l.target = n
svg = d3.select('svg')
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height
# translate the viewBox to have (0,0) at the center of the vis
svg
.attr
viewBox: "#{-width/2} #{-height/2-30} #{width} #{height}"
# layout
circular_layout = () ->
rho = (d, i, data) -> 100
theta_0 = (d, i, data) -> -Math.PI/2 # start from the angle pointing north
delta_theta = (d, i, data) -> 2*Math.PI/data.length
theta = (d, i , data) -> theta_0(d, i, data) + i*delta_theta(d, i, data)
self = (data) ->
data.forEach (d, i) ->
d.rho = rho(d, i, data)
d.theta = theta(d, i, data)
d.x = d.rho * Math.cos(d.theta)
d.y = d.rho * Math.sin(d.theta)
return data
self.rho = (x) ->
if x?
if typeof(x) is 'function'
rho = x
else
rho = () -> x
return self
# else
return rho
self.theta_0 = (x) ->
if x?
if typeof(x) is 'function'
theta_0 = x
else
theta_0 = () -> x
return self
# else
return theta_0
self.delta_theta = (x) ->
if x?
if typeof(x) is 'function'
delta_theta = x
else
delta_theta = () -> x
return self
# else
return delta_theta
self.theta = (x) ->
if x?
if typeof(x) is 'function'
theta = x
else
theta = () -> x
return self
# else
return theta
return self
# apply the layout
circular = circular_layout()
.rho(160)
circular(graph_data.nodes)
DIAMETER = 40
# draw nodes above links
links_layer = svg.append('g')
nodes_layer = svg.append('g')
nodes = nodes_layer.selectAll('.node')
.data(graph_data.nodes)
nodes.enter().append('circle')
.attr
class: 'node'
r: DIAMETER/2
cx: (node) -> node.x
cy: (node) -> node.y
# draw node labels
labels = nodes_layer.selectAll('.label')
.data(graph_data.nodes)
labels.enter().append('text')
.text((node) -> node.id)
.attr
class: 'label'
dy: '0.35em'
x: (node) -> node.x
y: (node) -> node.y
link_thickness = d3.scale.linear()
.domain([0, d3.max(graph_data.links, (link) -> link.weight)])
.range([0, DIAMETER*0.8]) # links are never larger than the 80% of a node's diameter
links = links_layer.selectAll('.link')
.data(graph_data.links)
tension = 0
links.enter().append('path')
.attr
class: 'link'
'stroke-width': (link) -> link_thickness(link.weight)
redraw = () ->
links
.attr
d: (link) ->
cxs = link.source.x-link.source.x*tension
cys = link.source.y-link.source.y*tension
cxt = link.target.x-link.target.x*tension
cyt = link.target.y-link.target.y*tension
return "M#{link.source.x} #{link.source.y} C#{cxs} #{cys} #{cxt} #{cyt} #{link.target.x} #{link.target.y}"
# draw the controls
tension_scale = d3.scale.linear()
.domain([0, 1])
.range([-width/2+50, width/2-50])
.clamp(true)
tension_brush = d3.svg.brush()
.x(tension_scale)
.extent([0, 0])
TENSION_SLIDER_Y = -240
svg.append('g')
.attr('class', 'x axis')
.attr('transform', "translate(0,#{TENSION_SLIDER_Y})")
.call(d3.svg.axis()
.scale(tension_scale)
.orient('bottom')
.tickFormat((d) -> d)
.tickSize(0)
.tickPadding(12))
.select('.domain')
.select(() -> this.parentNode.appendChild(this.cloneNode(true)) )
.attr('class', 'halo')
slider = svg.append('g')
.attr('class', 'slider')
.call(tension_brush)
slider.selectAll('.extent,.resize')
.remove()
slider.select('.background')
.attr('transform', "translate(0,#{TENSION_SLIDER_Y-11})")
.attr('height', 22)
handle = slider.append('circle')
.attr('class', 'handle')
.attr('transform', "translate(0,#{TENSION_SLIDER_Y})")
.attr('r', 9)
# initial animation
slider
.call(tension_brush.event)
.transition()
.duration(1800)
.call(tension_brush.extent([0.3, 0.3]))
.call(tension_brush.event)
brushed = () ->
tension = tension_brush.extent()[0]
if d3.event.sourceEvent # not a programmatic event
tension = tension_scale.invert(d3.mouse(this)[0])
tension_brush.extent([tension, tension])
handle.attr('cx', tension_scale(tension))
# redraw the links
redraw()
tension_brush
.on('brush', brushed)
svg {
background: white;
}
.node {
fill: lightgray;
stroke: gray;
stroke-width: 2px;
}
.link {
stroke: black;
fill: none;
opacity: 0.1;
}
.label {
text-anchor: middle;
font-size: 16px;
fill: #444;
font-weight: bold;
font-family: sans-serif;
}
.axis {
font: 10px sans-serif;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.axis .domain {
fill: none;
stroke: #000;
stroke-opacity: .3;
stroke-width: 10px;
stroke-linecap: round;
}
.axis .halo {
fill: none;
stroke: #ddd;
stroke-width: 8px;
stroke-linecap: round;
}
.slider .handle {
fill: #fff;
stroke: #000;
stroke-opacity: .5;
stroke-width: 1.25px;
pointer-events: none;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="description" content="Node-link circular layout: curved links" />
<title>Node-link circular layout: curved links</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<script src="http://d3js.org/d3.v3.min.js"></script>
</head>
<body>
<svg height="500" width="960"></svg>
<script src="index.js"></script>
</body>
</html>
(function() {
var DIAMETER, TENSION_SLIDER_Y, brushed, circular, circular_layout, graph_data, handle, height, labels, link_thickness, links, links_layer, nodes, nodes_layer, redraw, slider, svg, tension, tension_brush, tension_scale, width;
graph_data = {
nodes: [
{
id: 'A'
}, {
id: 'B'
}, {
id: 'C'
}, {
id: 'D'
}, {
id: 'E'
}, {
id: 'F'
}, {
id: 'G'
}
],
links: [
{
source: 'A',
target: 'B',
weight: 12
}, {
source: 'A',
target: 'C',
weight: 2
}, {
source: 'A',
target: 'D',
weight: 33
}, {
source: 'A',
target: 'F',
weight: 5
}, {
source: 'A',
target: 'G',
weight: 24
}, {
source: 'B',
target: 'D',
weight: 10
}, {
source: 'B',
target: 'E',
weight: 10
}, {
source: 'B',
target: 'F',
weight: 8
}, {
source: 'B',
target: 'G',
weight: 16
}, {
source: 'C',
target: 'D',
weight: 29
}, {
source: 'C',
target: 'E',
weight: 11
}, {
source: 'D',
target: 'E',
weight: 4
}, {
source: 'D',
target: 'F',
weight: 12
}, {
source: 'E',
target: 'F',
weight: 19
}
]
};
graph_data.links.forEach(function(l) {
return graph_data.nodes.forEach(function(n) {
if (l.source === n.id) {
l.source = n;
}
if (l.target === n.id) {
return l.target = n;
}
});
});
svg = d3.select('svg');
width = svg.node().getBoundingClientRect().width;
height = svg.node().getBoundingClientRect().height;
svg.attr({
viewBox: "" + (-width / 2) + " " + (-height / 2 - 30) + " " + width + " " + height
});
circular_layout = function() {
var delta_theta, rho, self, theta, theta_0;
rho = function(d, i, data) {
return 100;
};
theta_0 = function(d, i, data) {
return -Math.PI / 2;
};
delta_theta = function(d, i, data) {
return 2 * Math.PI / data.length;
};
theta = function(d, i, data) {
return theta_0(d, i, data) + i * delta_theta(d, i, data);
};
self = function(data) {
data.forEach(function(d, i) {
d.rho = rho(d, i, data);
d.theta = theta(d, i, data);
d.x = d.rho * Math.cos(d.theta);
return d.y = d.rho * Math.sin(d.theta);
});
return data;
};
self.rho = function(x) {
if (x != null) {
if (typeof x === 'function') {
rho = x;
} else {
rho = function() {
return x;
};
}
return self;
}
return rho;
};
self.theta_0 = function(x) {
if (x != null) {
if (typeof x === 'function') {
theta_0 = x;
} else {
theta_0 = function() {
return x;
};
}
return self;
}
return theta_0;
};
self.delta_theta = function(x) {
if (x != null) {
if (typeof x === 'function') {
delta_theta = x;
} else {
delta_theta = function() {
return x;
};
}
return self;
}
return delta_theta;
};
self.theta = function(x) {
if (x != null) {
if (typeof x === 'function') {
theta = x;
} else {
theta = function() {
return x;
};
}
return self;
}
return theta;
};
return self;
};
circular = circular_layout().rho(160);
circular(graph_data.nodes);
DIAMETER = 40;
links_layer = svg.append('g');
nodes_layer = svg.append('g');
nodes = nodes_layer.selectAll('.node').data(graph_data.nodes);
nodes.enter().append('circle').attr({
"class": 'node',
r: DIAMETER / 2,
cx: function(node) {
return node.x;
},
cy: function(node) {
return node.y;
}
});
labels = nodes_layer.selectAll('.label').data(graph_data.nodes);
labels.enter().append('text').text(function(node) {
return node.id;
}).attr({
"class": 'label',
dy: '0.35em',
x: function(node) {
return node.x;
},
y: function(node) {
return node.y;
}
});
link_thickness = d3.scale.linear().domain([
0, d3.max(graph_data.links, function(link) {
return link.weight;
})
]).range([0, DIAMETER * 0.8]);
links = links_layer.selectAll('.link').data(graph_data.links);
tension = 0;
links.enter().append('path').attr({
"class": 'link',
'stroke-width': function(link) {
return link_thickness(link.weight);
}
});
redraw = function() {
return links.attr({
d: function(link) {
var cxs, cxt, cys, cyt;
cxs = link.source.x - link.source.x * tension;
cys = link.source.y - link.source.y * tension;
cxt = link.target.x - link.target.x * tension;
cyt = link.target.y - link.target.y * tension;
return "M" + link.source.x + " " + link.source.y + " C" + cxs + " " + cys + " " + cxt + " " + cyt + " " + link.target.x + " " + link.target.y;
}
});
};
tension_scale = d3.scale.linear().domain([0, 1]).range([-width / 2 + 50, width / 2 - 50]).clamp(true);
tension_brush = d3.svg.brush().x(tension_scale).extent([0, 0]);
TENSION_SLIDER_Y = -240;
svg.append('g').attr('class', 'x axis').attr('transform', "translate(0," + TENSION_SLIDER_Y + ")").call(d3.svg.axis().scale(tension_scale).orient('bottom').tickFormat(function(d) {
return d;
}).tickSize(0).tickPadding(12)).select('.domain').select(function() {
return this.parentNode.appendChild(this.cloneNode(true));
}).attr('class', 'halo');
slider = svg.append('g').attr('class', 'slider').call(tension_brush);
slider.selectAll('.extent,.resize').remove();
slider.select('.background').attr('transform', "translate(0," + (TENSION_SLIDER_Y - 11) + ")").attr('height', 22);
handle = slider.append('circle').attr('class', 'handle').attr('transform', "translate(0," + TENSION_SLIDER_Y + ")").attr('r', 9);
slider.call(tension_brush.event).transition().duration(1800).call(tension_brush.extent([0.3, 0.3])).call(tension_brush.event);
brushed = function() {
tension = tension_brush.extent()[0];
if (d3.event.sourceEvent) {
tension = tension_scale.invert(d3.mouse(this)[0]);
tension_brush.extent([tension, tension]);
}
handle.attr('cx', tension_scale(tension));
return redraw();
};
tension_brush.on('brush', brushed);
}).call(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment