Skip to content

Instantly share code, notes, and snippets.

@christianbriggs
Last active June 4, 2017 14:45
Show Gist options
  • Save christianbriggs/676a9cec97fba90623961f3d488d773b to your computer and use it in GitHub Desktop.
Save christianbriggs/676a9cec97fba90623961f3d488d773b to your computer and use it in GitHub Desktop.
Network with multicategorical nodes
license: mit

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

Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<!DOCTYPE html>
<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>
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56.8 56.8"><g><circle cx="0" cy="0" r="28.4" fill="tomato"/><text transform="translate(0,0)" text-anchor="middle" alignment-baseline="middle" font-size="32" font-family="ArialMT, Arial" letter-spacing="-0.12em" style="isolation:isolate">D</text></g></svg>
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
{
"nodes": [
{"id": "Myriel", "group": 1},
{"id": "Napoleon", "group": 1},
{"id": "Mlle.Baptistine", "group": 1},
{"id": "Mme.Magloire", "group": 1},
{"id": "CountessdeLo", "group": 1},
{"id": "Geborand", "group": 1},
{"id": "Champtercier", "group": 1},
{"id": "Cravatte", "group": 1},
{"id": "Count", "group": 1},
{"id": "OldMan", "group": 1},
{"id": "Labarre", "group": 2},
{"id": "Valjean", "group": 2},
{"id": "Marguerite", "group": 3},
{"id": "Mme.deR", "group": 2},
{"id": "Isabeau", "group": 2},
{"id": "Gervais", "group": 2},
{"id": "Tholomyes", "group": 3},
{"id": "Listolier", "group": 3},
{"id": "Fameuil", "group": 3},
{"id": "Blacheville", "group": 3},
{"id": "Favourite", "group": 3},
{"id": "Dahlia", "group": 3},
{"id": "Zephine", "group": 3},
{"id": "Fantine", "group": 3},
{"id": "Mme.Thenardier", "group": 4},
{"id": "Thenardier", "group": 4},
{"id": "Cosette", "group": 5},
{"id": "Javert", "group": 4}
],
"links": [
{"source": "Napoleon", "target": "Myriel", "value": 1},
{"source": "Mlle.Baptistine", "target": "Myriel", "value": 8},
{"source": "Mme.Magloire", "target": "Myriel", "value": 10},
{"source": "Mme.Magloire", "target": "Mlle.Baptistine", "value": 6},
{"source": "CountessdeLo", "target": "Myriel", "value": 1},
{"source": "Geborand", "target": "Myriel", "value": 1},
{"source": "Champtercier", "target": "Myriel", "value": 1},
{"source": "Cravatte", "target": "Myriel", "value": 1},
{"source": "Count", "target": "Myriel", "value": 2},
{"source": "OldMan", "target": "Myriel", "value": 1},
{"source": "Valjean", "target": "Labarre", "value": 1},
{"source": "Valjean", "target": "Mme.Magloire", "value": 3},
{"source": "Valjean", "target": "Mlle.Baptistine", "value": 3},
{"source": "Valjean", "target": "Myriel", "value": 5},
{"source": "Marguerite", "target": "Valjean", "value": 1},
{"source": "Mme.deR", "target": "Valjean", "value": 1},
{"source": "Isabeau", "target": "Valjean", "value": 1},
{"source": "Gervais", "target": "Valjean", "value": 1},
{"source": "Listolier", "target": "Tholomyes", "value": 4},
{"source": "Fameuil", "target": "Tholomyes", "value": 4},
{"source": "Fameuil", "target": "Listolier", "value": 4},
{"source": "Blacheville", "target": "Tholomyes", "value": 4},
{"source": "Blacheville", "target": "Listolier", "value": 4},
{"source": "Blacheville", "target": "Fameuil", "value": 4},
{"source": "Favourite", "target": "Tholomyes", "value": 3},
{"source": "Favourite", "target": "Listolier", "value": 3},
{"source": "Favourite", "target": "Fameuil", "value": 3},
{"source": "Favourite", "target": "Blacheville", "value": 4},
{"source": "Dahlia", "target": "Tholomyes", "value": 3},
{"source": "Dahlia", "target": "Listolier", "value": 3},
{"source": "Dahlia", "target": "Fameuil", "value": 3},
{"source": "Dahlia", "target": "Blacheville", "value": 3},
{"source": "Dahlia", "target": "Favourite", "value": 5},
{"source": "Zephine", "target": "Tholomyes", "value": 3},
{"source": "Zephine", "target": "Listolier", "value": 3},
{"source": "Zephine", "target": "Fameuil", "value": 3},
{"source": "Zephine", "target": "Blacheville", "value": 3},
{"source": "Zephine", "target": "Favourite", "value": 4},
{"source": "Zephine", "target": "Dahlia", "value": 4},
{"source": "Fantine", "target": "Tholomyes", "value": 3},
{"source": "Fantine", "target": "Listolier", "value": 3},
{"source": "Fantine", "target": "Fameuil", "value": 3},
{"source": "Fantine", "target": "Blacheville", "value": 3},
{"source": "Fantine", "target": "Favourite", "value": 4},
{"source": "Fantine", "target": "Dahlia", "value": 4},
{"source": "Fantine", "target": "Zephine", "value": 4},
{"source": "Fantine", "target": "Marguerite", "value": 2},
{"source": "Fantine", "target": "Valjean", "value": 9},
{"source": "Mme.Thenardier", "target": "Fantine", "value": 2},
{"source": "Mme.Thenardier", "target": "Valjean", "value": 7},
{"source": "Thenardier", "target": "Mme.Thenardier", "value": 13},
{"source": "Thenardier", "target": "Fantine", "value": 1},
{"source": "Thenardier", "target": "Valjean", "value": 12},
{"source": "Cosette", "target": "Mme.Thenardier", "value": 4},
{"source": "Cosette", "target": "Valjean", "value": 31},
{"source": "Cosette", "target": "Tholomyes", "value": 1},
{"source": "Cosette", "target": "Thenardier", "value": 1},
{"source": "Javert", "target": "Valjean", "value": 17},
{"source": "Javert", "target": "Fantine", "value": 5}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment