Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active August 29, 2015 14:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nitaku/a161bdb59b9dcec9a32e to your computer and use it in GitHub Desktop.
Save nitaku/a161bdb59b9dcec9a32e to your computer and use it in GitHub Desktop.
Node-link polar layout

This example shows a simple circular layout for representing graphs using a node-link diagram. Link thickness is determined by a weight attribute.

This specific layout is an instance of a more generic polar layout, implemented in a way that's similar to other d3 layouts, using the approach introduced by Mike Bostock for reusable charts (you can read about it in this page).

To define a new polar layout function, just call polar_layout, then optionally provide more parametrs by method chaining:

circular = polar_layout()
    .rho(200)

By passing data into the layout (by calling circular(data_array)), each datum is assigned a position on the plane. x and y properties will be set for each datum, as well as rho and theta (same as x and y, but in polar coordinates). If no other configuration is provided, each datum will be displaced along a circumference at constant angle distance from each other (as in this very example).

This standard behavior of the layout can be customized by overriding its defaults, either by providing constant values or functions of data to the methods in the chain:

  • rho(datum, index, data_array) controls the distance from the origin. The default is a constant value of 100. Providing a function of the datum can be used for example to create a radar chart.
  • theta(datum, index, data_array) controls the angle (in radians) assigned to each datum. The default is to compute it by multiplying the datum index by a value obtained by calling delta_theta, then add a fixed value obtained by invoking the theta_0 function.
  • delta_theta(datum, index, data_array) controls the increment of theta (in radians) for each datum. The default is to evenly subdivide the circumference.
  • theta_0(datum, index, data_array) controls the initial offset of theta (in radians). The default is to rotate the layout in order to have the first datum facing north.

The use of this layout is of course not limited to the visualization of node-link diagrams. For example, it can be used to create regular polygons (or randomly irregular, roundish polygons like these ones), or to simply displace elements on a regular polygon's vertices (like in this example).

# 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} #{width} #{height}"
# layout
polar_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 = polar_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)
links.enter().append('path')
.attr
class: 'link'
d: (link) -> "M#{link.source.x} #{link.source.y} L#{link.target.x} #{link.target.y}"
'stroke-width': (link) -> link_thickness(link.weight)
svg {
background: white;
}
.node {
fill: lightgray;
stroke: gray;
stroke-width: 2px;
}
.link {
stroke: black;
opacity: 0.1;
}
.label {
text-anchor: middle;
font-size: 16px;
fill: #444;
font-weight: bold;
font-family: sans-serif;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="description" content="Node-link circular layout" />
<title>Node-link circular layout</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, circular, circular_layout, graph_data, height, labels, link_thickness, links, links_layer, nodes, nodes_layer, svg, 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) + " " + width + " " + height
});
polar_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 = polar_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);
links.enter().append('path').attr({
"class": 'link',
d: function(link) {
return "M" + link.source.x + " " + link.source.y + " L" + link.target.x + " " + link.target.y;
},
'stroke-width': function(link) {
return link_thickness(link.weight);
}
});
}).call(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment