Skip to content

Instantly share code, notes, and snippets.

@musakkhir
Last active July 13, 2017 12:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save musakkhir/85a0937a5fd1df8ecc1322ae418264b1 to your computer and use it in GitHub Desktop.
Save musakkhir/85a0937a5fd1df8ecc1322ae418264b1 to your computer and use it in GitHub Desktop.
Expandable Force Graph => Cluster
<!-- begin snippet: js hide: false console: true babel: false -->
<!-- language: lang-html -->
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<title>Expandable Force-Directed Graph with Cluster</title>
<style type="text/css">
svg {
border: 1px solid #ccc;
}
body {
font: 10px sans-serif;
}
circle.node {
fill: lightsteelblue;
stroke: #555;
stroke-width: 3px;
}
circle.leaf {
stroke: #fff;
stroke-width: 1.5px;
}
path.hull {
fill: lightsteelblue;
fill-opacity: 0.3;
}
line.link {
stroke: #333;
stroke-opacity: 0.5;
pointer-events: none;
}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<div id="chart"></div>
<script type="text/javascript">
var width = 960, // svg width
height = 600, // svg height
dr = 4, // default point radius
off = 15, // cluster hull offset
expand = {}, // expanded clusters
data, net, force, hullg, hull, linkg, link, nodeg, node;
var curve = d3.svg.line()
.interpolate("cardinal-closed")
.tension(.85);
var fill = d3.scale.ordinal()
.domain(["1","2","3","4","5","6"])
.range(["#989898","#FFFF00","#FFFFFF","#377eb8","#006600","#e41a1c", "#ff9933"]);
function noop() { return false; }
function nodeid(n) {
return n.size ? "_g_"+n.group : n.name;
}
function linkid(l) {
var u = nodeid(l.source),
v = nodeid(l.target);
return u<v ? u+"|"+v : v+"|"+u;
}
function getGroup(n) { return n.group; }
// Constructs the network to visualize
// Regenerates he nodes and links from the original data, based on the expand[]
// info, i.e. which group(s) should be shown in expanded form and which shouldn't.
function network(data, prev, index, expand) {
expand = expand || {};
var gm = {}, // group map
nm = {}, // node map
lm = {}, // link map
gn = {}, // previous group nodes
gc = {}, // previous group centroids
nodes = [], // output nodes
links = []; // output links
// process previous nodes for reuse or centroid calculation
if (prev) {
prev.nodes.forEach(function(n) {
var i = index(n), o;
if (n.size > 0) {
gn[i] = n;
n.size = 0;
} else {
o = gc[i] || (gc[i] = {x:0,y:0,count:0});
o.x += n.x;
o.y += n.y;
o.count += 1;
}
});
}
// determine nodes
for (var k=0; k<data.nodes.length; ++k) {
var n = data.nodes[k],
i = index(n),
l = gm[i] || (gm[i]=gn[i]) || (gm[i]={group:i, size:0, nodes:[]});
if (expand[i]) {
// the node should be directly visible
nm[n.name] = nodes.length;
nodes.push(n);
if (gn[i]) {
// place new nodes at cluster location (plus jitter)
n.x = gn[i].x + Math.random();
n.y = gn[i].y + Math.random();
}
} else {
// the node is part of a collapsed cluster
if (l.size == 0) {
// if new cluster, add to set and position at centroid of leaf nodes
nm[i] = nodes.length;
nodes.push(l);
if (gc[i]) {
l.x = gc[i].x / gc[i].count;
l.y = gc[i].y / gc[i].count;
}
}
l.nodes.push(n);
}
// always count group size as we also use it to tweak the force graph strengths/distances
l.size += 1;
n.group_data = l;
}
for (i in gm) { gm[i].link_count = 0; }
// determine links
for (k=0; k<data.links.length; ++k) {
var e = data.links[k],
u = index(e.source),
v = index(e.target);
if (u != v) {
gm[u].link_count++;
gm[v].link_count++;
}
u = expand[u] ? nm[e.source.name] : nm[u];
v = expand[v] ? nm[e.target.name] : nm[v];
var i = (u<v ? u+"|"+v : v+"|"+u),
l = lm[i] || (lm[i] = {source:u, target:v, size:0});
l.size += 1; // link thickness
}
for (i in lm) { links.push(lm[i]); }
return {nodes: nodes, links: links};
}
function convexHulls(nodes, index, offset) {
var hulls = {};
// create point sets
for (var k=0; k<nodes.length; ++k) {
var n = nodes[k];
if (n.size) continue;
var i = index(n),
l = hulls[i] || (hulls[i] = []);
l.push([n.x-offset, n.y-offset]);
l.push([n.x-offset, n.y+offset]);
l.push([n.x+offset, n.y-offset]);
l.push([n.x+offset, n.y+offset]);
}
// create convex hulls
var hullset = [];
for (i in hulls) {
hullset.push({group: i, path: d3.geom.hull(hulls[i])});
}
return hullset;
}
function drawCluster(d) {
return curve(d.path); // 0.8
}
// --------------------------------------------------------
var body = d3.select("body");
var vis = body.append("svg")
.attr("width", width)
.attr("height", height);
// A) To read from JSON file
//d3.json("/d3/CubeStructure/CubeStructureModel-ExpandableNodes.JSON", function(json) {
// d3.json("/d3/CubeStructure/CubeStructureModel.JSON", function(json) {
// data = json;
// B) For data internal to HTML
var data = {
"nodes": [
{"id": 0, "name": "Observations", "group": 1},
{"id": 1, "name": "qb:Observation", "group": 1},
{"id": 2, "name": "ds:dataset-DOMAIN", "group": 2},
{"id": 3, "name": "label", "group": 1},
{"id": 4, "name": "code:codeList(n)-CODE(n)", "group": 3},
{"id": 5, "name": "Attribute(n)", "group": 5},
{"id": 6, "name": "Measure", "group": 6},
{"id": 7, "name": "code:codeList", "group": 3},
{"id": 8, "name": "skos:ConceptScheme", "group": 3},
{"id": 9, "name": "LabelValue", "group": 3},
{"id": 10,"name": "code:codelist-CODE(n)", "group": 3},
{"id": 11,"name": "CL_CODLISTNAME", "group": 3},
{"id": 12,"name": "note", "group": 3},
{"id": 13,"name": "code:codeList Class", "group": 3},
{"id": 14,"name": "prefLabel", "group": 3},
{"id": 15,"name": "rdfs:Class", "group": 3},
{"id": 16,"name": "owl:Class", "group": 3},
{"id": 17,"name": "comment", "group": 3},
{"id": 18,"name": "label", "group": 3},
{"id": 19,"name": "skos:Concept", "group": 3},
{"id": 20,"name": "definition", "group": 3},
{"id": 21,"name": "submission value", "group": 3},
{"id": 22,"name": "synonym", "group": 3},
{"id": 23,"name": "domain", "group": 3},
{"id": 24,"name": "pref label", "group": 3},
{"id": 25,"name": "crnd-dimension:dim(n)", "group": 4},
{"id": 26,"name": "qb:ComponentSpecification", "group": 4},
{"id": 27,"name": "rdf:Property", "group": 4},
{"id": 28,"name": "qb:DimensionProperty", "group": 4},
{"id": 29,"name": "qb:CodedProperty", "group": 4},
{"id": 30,"name": "label", "group": 4},
{"id": 31,"name": "crnd-attribute:attr(n)", "group": 5},
{"id": 32,"name": "qb:AttributeProperty", "group": 5},
{"id": 33,"name": "label", "group": 5},
{"id": 34,"name": "crnd-measure:measure", "group": 6},
{"id": 35,"name": "qb:MeasureProperty", "group": 6},
{"id": 36,"name": "label", "group": 6},
{"id": 37,"name": "qb:DataSet", "group": 2},
{"id": 38,"name": "'comment'", "group": 2},
{"id": 39,"name": "'label'", "group": 2},
{"id": 40,"name": "description", "group": 2},
{"id": 41,"name": "title", "group": 2},
{"id": 42,"name": "ds:dsd-DOMAIN", "group": 2},
{"id": 43,"name": "timestamp", "group": 2},
{"id": 44,"name": "software", "group": 2},
{"id": 45,"name": "output file", "group": 2},
{"id": 46,"name": "input file", "group": 2},
{"id": 47,"name": "qb:DataStructureDefinition","group": 2},
{"id": 48,"name": "person/org", "group": 2},
{"id": 49,"name": "n.n.n", "group": 2},
{"id": 50,"name": "link", "group": 3}
],
"links": [
{"source": 0, "target": 1, "value": "rdf:type"},
{"source": 0, "target": 2, "value": "qb:dataSet"},
{"source": 0, "target": 3, "value": "rdfs:label"},
{"source": 0, "target": 4, "value": "crnd-dimension:<dim(n)>"},
{"source": 0, "target": 5, "value": "crnd-attribute:<attr(n)>"},
{"source": 0, "target": 6, "value": "crnd-measure:measure"},
{"source": 7, "target": 8, "value": "rdf:type"},
{"source": 7, "target": 9, "value": "rdfs:label"},
{"source": 7, "target": 10,"value": "skos:hasTopConcept"},
{"source": 7, "target": 11,"value": "skos:notation"},
{"source": 7, "target": 12,"value": "skos:note"},
{"source": 7, "target": 13,"value": "rdfs:seeAlso"},
{"source": 7, "target": 14,"value": "skos:prefLabel"},
{"source": 13,"target": 15,"value": "rdf:type"},
{"source": 13,"target": 16,"value": "rdf:type"},
{"source": 13,"target": 17,"value": "rdfs:comment"},
{"source": 13,"target": 18,"value": "rdfs:label"},
{"source": 13,"target": 7, "value": "rdfs:seeAlso"},
{"source": 13,"target": 19,"value": "rdfs:subClassOf"},
{"source": 10,"target": 13,"value": "rdf:type"},
{"source": 10,"target": 19,"value": "rdf:type"},
{"source": 10,"target": 20,"value": "cts:cdiscDefinition"},
{"source": 10,"target": 21,"value": "cts:cdiscSubmissionValue"},
{"source": 10,"target": 22,"value": "cts:cdiscSynonyms"},
{"source": 10,"target": 23,"value": "mms:nciDomain"},
{"source": 10,"target": 7, "value": "skos:inScheme, skos:topConceptOf"},
{"source": 10,"target": 24,"value": "skos:prefLabel"},
{"source": 25,"target": 26,"value": "rdf:type"},
{"source": 25,"target": 27,"value": "rdf:type"},
{"source": 25,"target": 28,"value": "rdf:type"},
{"source": 25,"target": 29,"value": "rdf:type"},
{"source": 25,"target": 30,"value": "rdfs:label"},
{"source": 25,"target": 13,"value": "rdfs:range"},
{"source": 25,"target": 7, "value": "qb:codeList"},
{"source": 31,"target": 26,"value": "rdf:type"},
{"source": 31,"target": 27,"value": "rdf:type"},
{"source": 31,"target": 32,"value": "rdf:type"},
{"source": 31,"target": 33,"value": "rdfs:label"},
{"source": 34,"target": 26,"value": "rdf:type"},
{"source": 34,"target": 27,"value": "rdf:type","edgeType": "edgeSolid"},
{"source": 34,"target": 35,"value": "rdf:type"},
{"source": 34,"target": 36,"value": "rdfs:label"},
{"source": 2, "target": 37,"value": "rdf:type"},
{"source": 2, "target": 38,"value": "rdfs:comment"},
{"source": 2, "target": 39,"value": "rdfs:label"},
{"source": 2, "target": 40,"value": "dct:description"},
{"source": 2, "target": 41,"value": "dct:title"},
{"source": 2, "target": 42,"value": "qb:structure"},
{"source": 2, "target": 43,"value": "pav:createdOn"},
{"source": 2, "target": 44,"value": "pav:createdWith"},
{"source": 2, "target": 45,"value": "dcat:distribution"},
{"source": 2, "target": 46,"value": "prov:wasDerivedFrom"},
{"source": 42,"target": 47,"value": "rdf:type"},
{"source": 42,"target": 25,"value": "qb:dimension"},
{"source": 42,"target": 31,"value": "qb:attribute"},
{"source": 42,"target": 34,"value": "qb:measure"},
{"source": 5, "target": 31},
{"source": 6, "target": 34},
{"source": 4, "target": 7},
{"source": 2, "target": 48,"value": "pav:createdBy"},
{"source": 2, "target": 49,"value": "pav:version"},
{"source": 7, "target": 50,"value": "skos:definition"}
]
}
for (var i=0; i<data.links.length; ++i) {
o = data.links[i];
o.source = data.nodes[o.source];
o.target = data.nodes[o.target];
}
hullg = vis.append("g");
linkg = vis.append("g");
nodeg = vis.append("g");
init();
vis.attr("opacity", 1e-6)
.transition()
.duration(1000)
.attr("opacity", 1);
// }); // needed when read from JSON file
function init() {
if (force) force.stop();
net = network(data, net, getGroup, expand);
force = d3.layout.force()
.nodes(net.nodes)
.links(net.links)
.size([width, height])
.linkDistance(function(l, i) {
var n1 = l.source, n2 = l.target;
// larger distance for bigger groups:
// both between single nodes and _other_ groups (where size of own node group still counts),
// and between two group nodes.
//
// reduce distance for groups with very few outer links,
// again both in expanded and grouped form, i.e. between individual nodes of a group and
// nodes of another group or other group node or between two group nodes.
//
// The latter was done to keep the single-link groups ('blue', rose, ...) close.
return 30 +
Math.min(20 * Math.min((n1.size || (n1.group != n2.group ? n1.group_data.size : 0)),
(n2.size || (n1.group != n2.group ? n2.group_data.size : 0))),
-30 +
30 * Math.min((n1.link_count || (n1.group != n2.group ? n1.group_data.link_count : 0)),
(n2.link_count || (n1.group != n2.group ? n2.group_data.link_count : 0))),
100);
// return 100;
})
.linkStrength(function(l, i) {
return 1;
})
.gravity(0.05) // gravity+charge tweaked to ensure good 'grouped' view (e.g. green group not smack between blue&orange, ...
.charge(-600) // ... charge is important to turn single-linked groups to the outside
.friction(0.5) // friction adjusted to get dampened display: less bouncy bouncy ball [Swedish Chef, anyone?]
.start();
hullg.selectAll("path.hull").remove();
hull = hullg.selectAll("path.hull")
.data(convexHulls(net.nodes, getGroup, off))
.enter().append("path")
.attr("class", "hull")
.attr("d", drawCluster)
// .style("fill", function(d) { return fill(d.group); })
.style("fill", function(d) {
if (d.group == 1) {return "#FFFF00"} // YELLOW
else if (d.group == 2) {return "#989898"} // GREY
else if (d.group == 3) {return "#ff9933"} //ORANGE
else if (d.group == 4) {return "#377eb8"} // BLUE
else if (d.group == 5) {return "#006600"} // GREEN
else if (d.group == 6) {return "#e41a1c"} // RED
;
;})
.on("click", function(d) {
console.log("hull click", d, arguments, this, expand[d.group]);
expand[d.group] = false; init();
});
link = linkg.selectAll("line.link").data(net.links, linkid);
link.exit().remove();
link.enter().append("line")
.attr("class", "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; })
.style("stroke-width", function(d) { return d.size || 1; });
node = nodeg.selectAll("g.node").data(net.nodes, nodeid);
node.exit().remove();
var onEnter = node.enter();
// NEW HERE
var g = onEnter
.append("g")
.attr("class", function(d) { return "node" + (d.size?"":" leaf"); })
.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
g.append("circle")
// if (d.size) -- d.size > 0 when d is a group node.
.attr("r", function(d) { return d.size ? d.size + dr : dr+1; })
// .style("fill", function(d) { return fill(d.group); })
.style("fill", function(d) {
if (d.group == 1) {return "#FFFF00"} // YELLOW
else if (d.group == 2) {return "#989898"} // GREY
else if (d.group == 3) {return "#ff9933"} //ORANGE
else if (d.group == 4) {return "#377eb8"} // BLUE
else if (d.group == 5) {return "#006600"} // GREEN
else if (d.group == 6) {return "#e41a1c"} // RED
;
;})
.on("click", function(d) {
// console.log("node click", d, arguments, this, expand[d.group]);
expand[d.group] = !expand[d.group];
init();
});
g.append("text")
.attr("fill","black")
.text(function(d,i){
if (d['name']){
// return d['name'];
return d['id']; // Use to troubleshoot nodes by ID number
}
});
//-------------------------------- END NEW
node.call(force.drag);
force.on("tick", function() {
if (!hull.empty()) {
hull.data(convexHulls(net.nodes, getGroup, off))
.attr("d", drawCluster);
}
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("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
});
}
</script>
</body>
</html>
<!-- end snippet -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment