Force-directed graph where a set of qualifiers can be added each node. Useful for cases when the nodes of the network are multicategorical and this has to be represented somehow. Data is a subset of the character coappearence in Les Misérables.
Since the symbols used are external svgs the qualifier can be any visual. Regarding these external svg files some assumptions are made, such having a single 'g' acting as placeholder and having the content within the placeholder centereed around the 0,0 of the 'g' (As these svg are translated and scaled, it is important not to accumulate matrix transformations on the same element in order to achieve the desired effect).
Related bl.ocks
Mike Bostock's example for a force directed graph and Steve Haroz's example for a d3-force testing ground
forked from XavierGimenez's block: Network with multicategorical nodes
xxxxxxxxxx
<meta charset='utf-8'>
<canvas width='1' height='1'></canvas>
<style>
body {
display: flex;
font-family: Arial, Helvetica, sans-serif;
font-weight: bold;
font-size: 14px;
width : 100%;
}
.controls {
flex-basis: 300px;
padding: 0 5px;
}
.controls .setting {
background-color: #eee;
border-radius: 3px;
margin: 5px 0;
padding: 5px;
}
svg {
flex-basis: 100%;
min-width: 200px;
}
.links line {
stroke: #999;
stroke-opacity: 0.6;
}
.nodes circle {
fill: 'tomato';
stroke: #fff;
stroke-width: 1.5px;
}
g.qualifier circle {
fill: cornsilk;
stroke: #333;
stroke-width: 6px;
}
</style>
<script src='https://d3js.org/d3.v4.min.js'></script>
<body>
<div class='controls'>
<div class='setting'>
<p>
<label for='nScale'>scale of qualifiers= <span id='scale-factor-value'></span></label>
<input type='range' min='0.1' max='0.5' step='0.01' id='scale-factor'>
</p>
</div>
<div class='setting'>
<p>
<label for='nAngle'>angle between qualifiers = <span id='angle-inc-value'></span></label>
<input type='range' min='10' max='45' id='angle-inc'>
</p>
</div>
<div class='setting'>
<p>
<label for='nLinkDistance'>Force link distance = <span id='link-distance-value'></span></label>
<input type='range' min='50' max='500' id='link-distance'>
</p>
</div>
</div>
<svg>
</svg>
</body>
<script>
var margin = {top: 20, right: 20, bottom: 20, left: 20},
width = 760 - margin.left - margin.right,
height = 600 - margin.top - margin.bottom,
initialPosition = {x: width / 2, y : height / 2 };
var svg = d3.select('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
var simulation = d3.forceSimulation()
.force('link', d3.forceLink())
.force('charge', d3.forceManyBody())
.force('center', d3.forceCenter(width / 2, height / 2));
var propertySymbolFiles = [
'item-bell.svg',
'item-a.svg',
'item-bolt.svg',
'item-b.svg',
'item-certificate.svg',
'item-clock.svg',
'item-exclamation.svg',
'item-c.svg',
'item-d.svg',
'item-e.svg'],
getRandomInt = function(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
},
propertySymbolSVGs = [],
degreeToRadians = Math.PI / 180,
nodes,
config = {
linkDistance:350,
propertyScaleFactor : .2, //scale factor relative to node size
radius : 3,
angleInitial : -45,
angleIncrement : 45
};
var draw = function(graph) {
var link = svg.append('g')
.attr('class', 'links')
.selectAll('line')
.data(graph.links)
.enter().append('line')
.attr('stroke-width', function(d) { return Math.sqrt(d.value); });
nodes = svg.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(graph.nodes)
.enter().append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', function dragstarted(d) {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', function dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
})
.on('end', function dragended(d) {
if (!d3.event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}));
nodes.append('circle')
.attr('r', function() { return getRandomInt(5, 50);})
.attr('fill', 'tomato');
nodes.append('title')
.text(function(d) { return d.id; });
var qualifiers = nodes
.selectAll('.qualifier')
.data(function(d) { return d.qualifiers; })
.enter().append('g')
.attr('class', 'qualifier');
qualifiers.each(function(qualifier) {
d3.select(this).node().appendChild(qualifier.cloneNode(true));
});
update(qualifiers);
simulation
.nodes(graph.nodes)
.on('tick', function ticked() {
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; });
nodes.attr('transform', function(d) {return 'translate(' + d.x + ',' + d.y + ')';});
})
.force('link')
.links(graph.links);
};
var update = function(qualifier) {
var item_radius,
qualifier_size;
qualifier.attr('transform', function(d, i) {
item_radius = +d3.select(this.parentElement).select('circle').attr('r');
return 'translate(' +
item_radius * Math.cos((config.angleInitial + (config.angleIncrement * i)) * degreeToRadians) + ',' +
item_radius * Math.sin((config.angleInitial + (config.angleIncrement * i)) * degreeToRadians) + ')';
});
qualifier.selectAll('g')
.attr('transform', function(d, i) {
item_radius = +d3.select(this.parentElement.parentElement).select('circle').attr('r');
qualifier_size = (item_radius*2) * config.propertyScaleFactor;
return 'scale(' + (qualifier_size / this.getBBox().width) + ')';
});
}
function updateScale(propertyScaleFactor) {
d3.select('#scale-factor-value').text(propertyScaleFactor);
d3.select('#scale-factor').property('value', propertyScaleFactor);
}
function updateAngleInc(angleIncrement) {
d3.select('#angle-inc-value').text(angleIncrement);
d3.select('#angle-inc').property('value', angleIncrement);
}
function updateLinkDistance(linkDistance) {
d3.select('#link-distance-value').text(linkDistance);
d3.select('#link-distance').property('value', linkDistance);
}
simulation.force('link')
.id(function(d) {return d.id;})
.distance(config.linkDistance);
d3.select('#scale-factor').on('input', function() {
config.propertyScaleFactor = +this.value;
updateScale(config.propertyScaleFactor);
update(nodes.selectAll('.qualifier'));
});
d3.select('#angle-inc').on('input', function() {
config.angleIncrement = +this.value;
updateAngleInc(config.angleIncrement);
update(nodes.selectAll('.qualifier'));
});
d3.select('#link-distance').on('input', function() {
config.linkDistance = +this.value;
updateLinkDistance(config.linkDistance);
simulation.force('link').distance(config.linkDistance);
simulation.alpha(1).restart();
});
updateScale(config.propertyScaleFactor);
updateAngleInc(config.angleIncrement);
updateLinkDistance(config.linkDistance);
var q = d3.queue();
propertySymbolFiles.forEach(function(propertySymbolFile) {
q.defer(d3.xml, propertySymbolFile);
});
q.awaitAll(function(error, files) {
if (error) throw error;
files.forEach(function(file) {
// assuming that every svg file contains a single child, a 'g'
// node containing the visuals, get this placeholder
propertySymbolSVGs.push(file.getElementsByTagName('svg')[0].getElementsByTagName('g')[0]);
});
//load network and add a set of qualifiers for each node.
// each qualifier is a visual symbol
d3.json('miserables.json', function(e, graph) {
graph.nodes.forEach(function(node) {
var length = getRandomInt(1,8);
node.qualifiers = []
for(var i=0; i<length; i++)
node.qualifiers.push(propertySymbolSVGs[getRandomInt(0,propertySymbolSVGs.length-1)]);
});
if(e) throw e;
draw(graph);
})
});
</script>
https://d3js.org/d3.v4.min.js