Last active
July 13, 2017 12:09
-
-
Save musakkhir/85a0937a5fd1df8ecc1322ae418264b1 to your computer and use it in GitHub Desktop.
Expandable Force Graph => Cluster
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- 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