Skip to content

Instantly share code, notes, and snippets.

@GerHobbelt
Created September 4, 2012 03:37
Show Gist options
  • Save GerHobbelt/3616279 to your computer and use it in GitHub Desktop.
Save GerHobbelt/3616279 to your computer and use it in GitHub Desktop.
d3.js: force layout with self-referencing links
# Editor backup files
*.bak
*~

Experiment …… ? 8

Derived from the D3.js example force_cluster.html and gist 3104394.

Features

  • all of gist 3104394 @ github
  • showcases a tweaked 'miserables.json' file which includes 'circular links' i.e. links which connect node A to node A (~ self-reference)

Usage

  • Slide the 'debug level' slider to see the various debug levels visually; the hospital curves are (top): force.alpha and (bottom) total x/y change squared: a measurement for the amount of movement of nodes in the graph. Note that the bottom curve is inverted, so that when both meet, you're getting close to done. :-)
  • Click on node to expand or collapse. When a node has 'bundled' outgoing links, the first click will expand only those (a.k.a. 2nd display mode / expand state = 1), the next click will then expand the group node itself.
  • Click on hull (which shows up when you expanded a group node) to collapse the group.
  • Drag node to move entire graph around.

Notes

The ability to draw circular references is following the overall force design used here, where helper nodes (and ditto links) are used to produce the bezier-based links; a self-reference is nothing but yet another nodeA -> helper -> nodeB link chain where 'nodeB' just happens to be nodeA again.

This code uses two(2) d3.layout.force() forces: one to layout the original nodes (or their group node equivalent), the second is used to layout all the helper nodes (which are only visible in debug modes 1 and 2): the latter are used to construct the bezier paths representing the links.

Code derived from the d3.js 'force' example:
The miserables.json file contains the weighted network of coappearances of
characters in Victor Hugo's novel /Les Miserables/. Nodes represent characters
as indicated by the labels, and edges connect any pair of characters that
appear in the same chapter of the book. The values on the edges are the number
of such coappearances. The data on coappearances were taken from D. E. Knuth,
"The Stanford GraphBase: A Platform for Combinatorial Computing",
Addison-Wesley, Reading, MA (1993).
The group labels were transcribed from "Finding and evaluating community
structure in networks" by M. E. J. Newman and M. Girvan.
<!DOCTYPE html>
<html>
<head>
<title>Clustered Network</title>
<script src="http://d3js.org/d3.v2.js"></script>
<link href='http://fonts.googleapis.com/css?family=Gudea:700,400italic,400' rel='stylesheet' type='text/css'>
<style type="text/css">
svg {
border: 1px solid #ccc;
}
body {
font: 10px 'Gudea', sans-serif;
}
text {
font-family: 'Gudea', sans-serif;
font-size: 9pt;
}
circle.node {
fill: lightsteelblue;
stroke: #555;
stroke-width: 3px;
}
circle.leaf {
stroke: #fff;
stroke-width: 1.5px;
}
circle.link-expanded {
stroke: #555;
stroke-width: 3px;
stroke-dasharray: 2px 4px;
}
circle.helper {
stroke-width: 0;
fill: #333;
opacity: 0.6;
}
circle.center-of-mass {
fill: white;
stroke: red;
stroke-width: 5px;
}
path.hull {
fill: lightsteelblue;
fill-opacity: 0.3;
}
path.hlink {
stroke: #333;
stroke-opacity: 0.5;
fill: none;
pointer-events: none;
}
path.trace-alpha {
stroke: #0ee;
stroke-opacity: 0.8;
stroke-width: 1.5px;
fill: none;
pointer-events: none;
}
path.trace-c2 {
stroke: #3bb;
stroke-opacity: 0.8;
stroke-width: 1.5px;
fill: none;
pointer-events: none;
}
line.link {
stroke: red;
stroke-opacity: 0.7;
pointer-events: none;
}
</style>
</head>
<body>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="controls" visibility="hidden">
<g visibility="visible">
<rect x="0" y="0.55" opacity="0.6" fill="#FFFFFF" stroke="#231F20" stroke-miterlimit="10" width="192" height="22"/>
<text transform="matrix(1 0 0 1 4.5066 15.343)" >Debug level:</text>
<g id="slider-debug-level">
<line id="slider-x-debug-level" fill="none" stroke="#939598" stroke-width="2" stroke-linecap="round" stroke-miterlimit="10" x1="73.5" y1="11.05" x2="160.5" y2="11.05"/>
<rect id="sliderpos-debug-level" x="77" y="3.55" fill="#58595B" stroke="#231F20" stroke-linecap="round" stroke-miterlimit="10" width="4" height="15"/>
<text id="sliderval-debug-level" transform="matrix(1 0 0 1 164.6638 15.343)" >?????</text>
</g>
</g>
</svg>
<script type="text/javascript" src="http://gerhobbelt.github.com/bl.ocks.org-hack/fixit.js" ></script>
<script type="text/javascript">
var width = 960, // svg width
height = 500, // svg height
dr = 4, // default point radius
off = 15, // cluster hull offset
cycle_curvep = 0.5, // cyclic ~ self-referential links: determines the 'radius' of the bezier path constructed for the link
expand = {}, // expanded clusters
data, net, force1, force2, hullg, hull, linkg, helper_linkg, link, hlink, nodeg, helper_nodeg, node, hnode,
debug = 3, // 0: disable, 1: all, 2: only force2 + curves, 3: curves only
alpha_trace = [], alpha_line, alpha_scale, debug_g,
c2_trace = [], c2_line, c2_scale, debug_g;
//for (var gn = 0; gn < 20; gn++) expand[gn] = 2;
alpha_line = d3.svg.line()
.x(function(d, i) {
return i + 10;
})
.y(function(d, i) {
return d;
});
alpha_scale = d3.scale.pow().exponent(-1.0).domain([5,.005]).range([10, height - 10]).clamp(true);
c2_scale = d3.scale.log().domain([0.01, width * height]).range([10, height - 10]).clamp(true);
c2_line = d3.svg.line()
.x(function(d, i) {
return i + 10;
})
.y(function(d, i) {
return d;
});
var curve = d3.svg.line()
.interpolate("cardinal-closed")
.tension(.85);
var fill = d3.scale.category20();
function noop() { return false; }
function nodeid(n) {
return n.size > 0 ? "_g_" + n.group + "_" + n.expansion : 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; }
function cycleState(d) {
var g = d.group, s = expand[g] || 0;
// it's no use 'expanding the intergroup links only' for nodes which only have 1 outside link for real:
if (d.ig_link_count < 2)
s = (s ? 0 : 2);
else {
s++; s %= 3;
}
return expand[g] = s;
}
// constructs the network to visualize
function network(data, prev) {
expand = expand || {};
var gm = {}, // group map
nm = {}, // node map
nml = {}, // node map for left-side 'link path helper nodes'
nmr = {}, // node map for right-side 'link path helper nodes'
nmc = {}, // node map for cyclic ~ self-referencing 'link path helper nodes'
nmimg = {}, // node map for cloned nodes for force2
lm = {}, // link maps - lm ~ lml-lmm-lmr
lml = {},
lmm = {},
lmr = {},
lmc = {},
gn = {}, // previous group nodes
gc = {}, // previous group centroids
nodes = [], // output nodes
links = [], // output links
helper_nodes = [], // helper force graph nodes
helper_links = []; // helper force graph links
helper_render_links = []; // helper force graph links
var k;
// process previous nodes for reuse or centroid calculation
if (prev) {
prev.nodes.forEach(function(n) {
var i = getGroup(n), o;
if (n.size > 0) {
gn[i] = n;
n.size = 0;
n.ig_link_count = 0;
n.link_count = 0;
n.first_link = null;
n.first_link_target = null;
} else {
o = gc[i] || (gc[i] = {x:0,y:0,count:0});
o.x += n.x;
o.y += n.y;
o.count += 1; // we count regular nodes here, so .count is a measure for the number of nodes in the group
}
});
}
// determine nodes
for (k=0; k<data.nodes.length; ++k) {
var n = data.nodes[k],
i = getGroup(n),
expansion = expand[i] || 0,
l = gm[i] || (gm[i]=gn[i]) || (gm[i]={group:i, size:0, nodes:[], ig_link_count:0, link_count:0, expansion: expansion}),
img;
// we need to create a NEW object when expansion changes from 0->1 for a group node
// in order to break the references from the d3 selections, so that the next time
// this group node will indeed land in the 'enter()' set
if (l.expansion != expansion) {
l = gn[i] = gm[i] = {group:l.group, x:l.x, y: l.y, size:l.size, nodes:l.nodes, ig_link_count:l.ig_link_count, link_count:l.link_count, expansion: expansion};
}
if (expansion == 2) {
// the node should be directly visible
nm[nodeid(n)] = n;
img = {ref: n, x: n.x, y: n.y, size: n.size || 0, fixed: 1, id: nodeid(n)};
nmimg[nodeid(n)] = img;
nodes.push(n);
helper_nodes.push(img);
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[nodeid(n)] = l;
l.size = 1; // hack to make nodeid() work correctly for the new group node
nm[nodeid(l)] = l;
img = {ref: l, x: l.x, y: l.y, size: l.size || 0, fixed: 1, id: nodeid(l)};
nmimg[nodeid(l)] = img;
l.size = 0; // undo hack
nmimg[nodeid(n)] = img;
nodes.push(l);
helper_nodes.push(img);
if (gc[i]) {
l.x = gc[i].x / gc[i].count;
l.y = gc[i].y / gc[i].count;
}
} else {
// have element node point to group node:
nm[nodeid(n)] = l; // l = shortcut for: nm[nodeid(l)];
nmimg[nodeid(n)] = nmimg[nodeid(l)];
}
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;
n.link_count = 0;
n.first_link = null;
n.first_link_target = null;
}
// determine links
for (k=0; k<data.links.length; ++k) {
var e = data.links[k],
u = getGroup(e.source),
v = getGroup(e.target),
rui, rvi, ui, vi, lu, rv, ustate, vstate, uimg, vimg,
i, ix,
l, ll, l_, lr;
if (u != v) {
gm[u].ig_link_count++;
gm[v].ig_link_count++;
}
ustate = expand[u] || 0;
vstate = expand[v] || 0;
// while d3.layout.force does convert link.source and link.target NUMERIC values to direct node references,
// it doesn't for other attributes, such as .real_source, so we do not use indexes in nm[] but direct node
// references to skip the d3.layout.force implicit links conversion later on and ensure that both .source/.target
// and .real_source/.real_target are of the same type and pointing at valid nodes.
rui = nodeid(e.source);
rvi = nodeid(e.target);
u = nm[rui];
v = nm[rvi];
if (u == v) {
// skip non-original links from node to same (A-A); they are rendered as 0-length lines anyhow. Less links in array = faster animation.
if (ustate < 2)
continue;
// self-referential 'links' are produced as 2 links+1 helper node; this is a generalized approach so we
// can support multiple self-referential links as thanks to the force layout
// those helpers will all be in different places, hence the link 'path' for each
// parallel link will be different.
ui = nodeid(u);
i = ui+"|"+ui+"|"+k;
l = lm[i] || (lm[i] = {source:u, target:u, size:0, distance: 0});
l.cyclic = true;
ix = i;
// link(u,v) ==> u -> lu -> u
lu = nmc[ix] || (nmc[ix] = data.helpers.cyclic[ix] || (data.helpers.cyclic[ix] = {ref: u, id: "_ch_" + ix, size: -1, link_ref: l, cyclic_helper: true}));
uimg = nmimg[ui];
l_ = lmc[ix] || (lmc[ix] = {g_ref: l, ref: e, id: "c"+ix, source:uimg, target: lu, real_source:u, size: 0, distance: 0, cyclic: true});
l.size += 1;
l_.size += 1;
continue;
}
// 'links' are produced as 3 links+2 helper nodes; this is a generalized approach so we
// can support multiple links between element nodes and/or groups, always, as each
// 'original link' gets its own set of 2 helper nodes and thanks to the force layout
// those helpers will all be in different places, hence the link 'path' for each
// parallel link will be different.
ui = nodeid(u);
vi = nodeid(v);
i = (ui < vi ? ui+"|"+vi : vi+"|"+ui);
l = lm[i] || (lm[i] = {source:u, target:v, size:0, distance: 0});
if (ustate == 1) {
ui = rui;
}
if (vstate == 1) {
vi = rvi;
}
ix = (ui < vi ? ui+"|"+vi+"|"+ustate+"|"+vstate : vi+"|"+ui+"|"+vstate+"|"+ustate);
ix = (ui < vi ? ui+"|"+vi : vi+"|"+ui);
// link(u,v) ==> u -> lu -> rv -> v
lu = nml[ix] || (nml[ix] = data.helpers.left[ix] || (data.helpers.left[ix] = {ref: u, id: "_lh_" + ix, size: -1, link_ref: l}));
rv = nmr[ix] || (nmr[ix] = data.helpers.right[ix] || (data.helpers.right[ix] = {ref: v, id: "_rh_" + ix, size: -1, link_ref: l}));
uimg = nmimg[ui];
vimg = nmimg[vi];
ll = lml[ix] || (lml[ix] = {g_ref: l, ref: e, id: "l"+ix, source:uimg, target: lu, real_source:u, real_target:v, size:0, distance: 0, left_seg : true});
l_ = lmm[ix] || (lmm[ix] = {g_ref: l, ref: e, id: "m"+ix, source: lu, target: rv, real_source:u, real_target:v, size:0, distance: 0, middle_seg: true});
lr = lmr[ix] || (lmr[ix] = {g_ref: l, ref: e, id: "r"+ix, source: rv, target:vimg, real_source:u, real_target:v, size:0, distance: 0, right_seg : true});
l.size += 1;
ll.size += 1;
l_.size += 1;
lr.size += 1;
// these are only useful for single-linked nodes, but we don't care; here we have everything we need at minimum cost.
if (l.size == 1) {
u.link_count++;
v.link_count++;
u.first_link = l;
v.first_link = l;
u.first_link_target = v;
v.first_link_target = u;
}
}
for (k in lm) { links.push(lm[k]); }
for (k in lml) { helper_links.push(lml[k]); }
for (k in lmm) { helper_links.push(lmm[k]); helper_render_links.push(lmm[k]); }
for (k in lmr) { helper_links.push(lmr[k]); }
for (k in lmc) { helper_links.push(lmc[k]); helper_render_links.push(lmc[k]); }
for (k in nml) { helper_nodes.push(nml[k]); }
for (k in nmr) { helper_nodes.push(nmr[k]); }
for (k in nmc) { helper_nodes.push(nmc[k]); }
//for (k in nmc) { nodes.push(nmc[k]); }
return {nodes: nodes, links: links, helper_nodes: helper_nodes, helper_links: helper_links, helper_render_links: helper_render_links};
}
function convexHulls(nodes, offset) {
var hulls = {};
// create point sets
for (var k=0; k<nodes.length; ++k) {
var n = nodes[k];
if (n.size) continue;
var i = getGroup(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
}
// these functions call init(); by declaring them here,
// they don't have the old init() as a closure any more.
// This should save us some memory and cycles when using
// this in a long-running setting.
function on_hull_click(d) {
if (debug == 1) console.log("node click", d, arguments, this, expand[d.group]);
// clicking on 'path helper nodes' shouln't expand/collapse the group node:
if (d.size < 0)
return;
cycleState(d);
init();
}
function on_node_click(d) {
if (debug == 1) console.log("node click", d, arguments, this, expand[d.group]);
// clicking on 'path helper nodes' shouln't expand/collapse the group node:
if (d.size < 0)
return;
cycleState(d);
init();
}
// --------------------------------------------------------
var vis = d3.select("svg")
.attr("width", width)
.attr("height", height);
var debug_ui = vis.select("#slider-debug-level");
debug_ui.select("#sliderval-debug-level").text(debug);
var dbg = debug_ui.select("#slider-x-debug-level");
x1 = +dbg.attr("x1");
x2 = +dbg.attr("x2");
var debug_ui_scale = d3.scale.linear().domain([0,3]).range([+dbg.attr("x1")+3.5, +dbg.attr("x2")-3.5-4]).clamp(true);
// as per http://bl.ocks.org/1557377; correction for latest d3 lib: origin(Object) doesn't cut it.
var slider_drag = d3.behavior.drag()
/*
.origin(function(d, i) {
// must return {x: value, y: value} object
var owner = this.ownerSVGElement;
var rect = owner.getBoundingClientRect();
// (window.scrollX || window.scrollY) ?
//return {x: rect.left - this.clientLeft, y: rect.top - this.clientTop};
return {x: rect.left, y: rect.top};
})
*/
.on("drag", slider_dragmove);
debug_ui.select("#sliderpos-debug-level")
.attr("x", debug_ui_scale(debug))
.call(slider_drag);
function slider_dragmove(d) {
if (!isFinite(d3.event.x))
return;
var dv = debug_ui_scale.invert(d3.event.x);
dv = Math.round(dv);
d3.select(this)
.attr("x", debug_ui_scale(dv));
debug_ui.select("#sliderval-debug-level").text(dv);
if (dv != debug) {
debug = dv;
helper_nodeg
.selectAll("*")
.remove();
linkg
.selectAll("*")
.remove();
vis.select("#center-of-mass")
.attr("display", (debug == 1) ? null : "none");
debug_g
.attr("display", debug ? null : "none");
init();
}
}
debug_g = vis.append("g");
debug_g
.append("path")
.attr("class", "trace-alpha");
debug_g
.append("path")
.attr("class", "trace-c2");
var pathgen = d3.svg.line().interpolate("basis");
var cycle_pathgen = d3.svg.line().interpolate("basis");
d3.json("miserables-with-cycles.json", function(json) {
/*
JSON layout:
{
"nodes": [
{
"name" : "bla", // in this code, this is expected to be a globally unique string (as it's used for the id via nodeid())
"group" : 1 // group ID (number)
},
...
],
"links": [
{
"source" : 1, // nodes[] index (number; is immediately converted to direct nodes[index] reference)
"target" : 0, // nodes[] index (number; is immediately converted to direct nodes[index] reference)
"value" : 1 // [not used in this force layout]
},
...
]
}
*/
data = json;
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];
}
// prepare data struct to also carry our 'path helper nodes':
data.helpers = {left: {}, right: {}, cyclic: {}};
hullg = vis.append("g");
//if (debug && debug < 3) {
linkg = vis.append("g");
helper_nodeg = vis.append("g");
//}
helper_linkg = vis.append("g");
nodeg = vis.append("g");
//if (debug == 1) {
node = vis.append("g")
.attr("id", "center-of-mass")
.append("circle")
.attr("class", "center-of-mass")
.attr("visibility", (debug == 1) ? "visible" : "hidden")
.attr("r", 10);
//}
init();
vis.attr("opacity", 1e-6)
.transition()
.duration(1000)
.attr("opacity", 1);
});
function init() {
if (force1) force1.stop();
net = network(data, net);
force1 = d3.layout.force()
.nodes(net.nodes)
.links(net.links)
.size([width, height])
.linkDistance(function(l, i) {
//return 300;
var n1 = l.source, n2 = l.target,
g1 = n1.group_data || n1, g2 = n2.group_data || n2,
n1_is_group = n1.size || 0, n2_is_group = n2.size || 0,
rv = 300;
// 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 close.
if (n1.group == n2.group) {
if ((n1.link_count < 2 && !n1_is_group) || (n2.link_count < 2 && !n2_is_group)) {
// 'real node' singles: these don't need a big distance to make the distance, if you whumsayin' ;-)
rv = 2;
} else if (!n1_is_group && !n2_is_group) {
rv = 2;
} else if (g1.link_count < 4 || g2.link_count < 4) {
rv = 100;
}
} else {
if (!n1_is_group && !n2_is_group) {
rv = 50;
} else if ((n1_is_group && n2_is_group) && (g1.link_count < 4 || g2.link_count < 4)) {
// 'real node' singles: these don't need a big distance to make the ditance, if you whumsayin' ;-)
rv = 100;
} else if ((n1_is_group && g1.link_count < 2) || (n2_is_group && g2.link_count < 2)) {
// 'real node' singles: these don't need a big distance to make the ditance, if you whumsayin' ;-)
rv = 30;
} else if (!n1_is_group || !n2_is_group) {
rv = 100;
}
}
return l.distance = rv;
})
.gravity(1.0) // gravity+charge tweaked to ensure good 'grouped' view (e.g. green group not smack between blue&orange, ...
.charge(function(d, i) { // ... charge is important to turn single-linked groups to the outside
if (d.size > 0) {
return -5000; // group node
} else {
// 'regular node'
return -1000;
}
})
.friction(0.7) // friction adjusted to get dampened display: less bouncy bouncy ball [Swedish Chef, anyone?]
.on("end", function() {
// done
})
.on("start", function() {
// zip nada
})
.start();
/*
And here's the crazy idea for allowing AND rendering multiple links between 2 nodes, etc., as the initial attempt
to include the 'helper' nodes in the basic 'force' failed dramatically from a visual PoV: we 'overlay' the basic
nodes+links force with a SECOND force layout which 'augments' the original force layout by having it 'layout' all
the helper nodes (with their links) between the 'fixed' REAL nodes, which are laid out by the original force.
This way, we also have the freedom to apply a completely different force field setup to the helpers (no gravity
as it doesn't make sense for helpers, different charge values, etc.).
*/
force2 = d3.layout.force()
.nodes(net.helper_nodes)
.links(net.helper_links)
.size([width, height])
.linkDistance(function(l, i) {
var n1 = l.real_source, n2 = l.real_target, rv,
lr = l.g_ref,
n1r, n2r,
dx, dy;
if (l.cyclic)
return 15;
if (lr.source.size > 0 || lr.target.size > 0)
return 20;
return 1;
})
.gravity(0.0) // just a tad of gravidy to help keep those curvy buttocks decent
.charge(function(d, i) {
// helper nodes have a medium-to-high charge, depending on the number of links the related force link represents.
// Hence bundles of links fro A->B will have helper nodes with huge charges: better spreading of the link paths.
//
// Unless we're looking at helpers for links between 'real nodes', NOT GROUPS: in that case we want to keep
// the lines are straight as posssible as there would only be one relation for A->B anyway, so we lower the charge
// for such nodes and helpers.
if (d.fixed)
return -10;
var l = d.link_ref,
c = l.link_count || 1;
if (l.source.size > 0 || l.target.size > 0)
return -30;
return -1;
})
.friction(0.95)
.start()
.stop(); // and immediately stop! force.tick will drive this one every tick!
hullg.selectAll("path.hull").remove();
hull = hullg.selectAll("path.hull")
.data(convexHulls(net.nodes, off))
.enter().append("path")
.attr("class", "hull")
.attr("d", drawCluster)
.style("fill", function(d) { return fill(d.group); })
.on("click", on_hull_click);
if (debug == 1) {
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; });
// both existing and enter()ed links may have changed stroke width due to expand state change somewhere:
link.style("stroke-width", function(d) { return d.size || 1; });
}
hlink = helper_linkg.selectAll("path.hlink").data(net.helper_render_links, function(d) {
return d.id;
});
hlink.exit().remove();
hlink.enter().append("path")
.attr("class", "hlink");
// both existing and enter()ed links may have changed stroke width due to expand state change somewhere:
hlink.style("stroke-width", function(d) { return d.size || 1; });
if (debug && debug < 3) {
hnode = helper_nodeg.selectAll("circle.node").data(net.helper_nodes, function(d) {
return d.id;
});
hnode.exit().remove();
hnode.enter().append("circle")
// if (d.size) -- d.size > 0 when d is a group node.
// d.size < 0 when d is a 'path helper node'.
.attr("class", function(d) {
return "node" + (d.size > 0 ? "" : d.size < 0 ? " helper" : " leaf");
})
.attr("r", function(d) {
return d.size > 0 ? d.size + dr : d.size < 0 ? 2 : dr + 1;
})
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.style("fill", function(d) { return fill(d.group); });
}
node = nodeg.selectAll("circle.node").data(net.nodes, nodeid);
node.exit().remove();
node.enter().append("circle")
// if (d.size) -- d.size > 0 when d is a group node.
// d.size < 0 when d is a 'path helper node'.
.attr("class", function(d) {
return "node" + (d.size > 0 ? d.expansion ? " link-expanded" : "" : " leaf");
})
.attr("r", function(d) {
return d.size > 0 ? d.size + dr : dr + 1;
})
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.style("fill", function(d) { return fill(d.group); })
.on("click", on_node_click);
node.call(force1.drag);
var drag_in_progress = false;
var change_squared = width * height;
// CPU load redux for the fix, part 3: jumpstart the annealing process again when the user moves the mouse outside the node,
// when we believe the drag is still going on; even when it isn't anymore, but D3 doesn't inform us about that!
node
.on("mouseout.ger_fix", function(d) {
if (debug == 1) console.log("mouseout.ger_fix", this, arguments, d.fixed, drag_in_progress);
if (drag_in_progress) {
force1.resume();
}
});
var resume_threshold = 0.05;
force1.on("tick", function(e) {
if (debug) {
alpha_trace.push(alpha_scale(e.alpha));
if (alpha_trace.length > width - 20)
alpha_trace.shift();
debug_g.select("path.trace-alpha")
.attr("d", alpha_line(alpha_trace));
c2_trace.push(c2_scale(change_squared));
if (c2_trace.length > width - 20)
c2_trace.shift();
debug_g.select("path.trace-c2")
.attr("d", c2_line(c2_trace));
}
/*
Force all nodes with only one link to point outwards.
To do this, we first calculate the center mass (okay, we wing it, we fake node 'weight'),
then see whether the target node for links from single-link nodes is closer to the
center-of-mass than us, and if it isn't, we push the node outwards.
*/
var center = {x: 0, y: 0, weight: 0}, singles = [], centroids = {},
size, c, k, mx, my, dx, dy, alpha;
drag_in_progress = false;
net.nodes.forEach(function(n) {
var w = Math.max(1, n.size || 0, n.weight || 0),
i = getGroup(n),
gc = centroids[i] || (centroids[i] = { x:0, y:0, weight: 0});
center.x += w * n.x;
center.y += w * n.y;
center.weight += w;
gc.x += w * n.x;
gc.y += w * n.y;
gc.weight += w;
if (n.fixed & 2) {
drag_in_progress = true;
}
if (n.size > 0 ? n.link_count < 4 : n.group_data.link_count < 3)
singles.push(n);
});
size = force1.size();
mx = size[0] / 2;
my = size[1] / 2;
for (k in centroids) {
var gc = centroids[k];
gc.x /= gc.weight;
gc.y /= gc.weight;
}
singles.forEach(function(n) {
var l = n.first_link, n2 = n.first_link_target,
proj, ax, bx, ay, by, k, x, y, alpha, rej, power,
dx, dy,
n_is_group = n.size || 0,
ng = n.group_data || n,
c2,
w = Math.max(1, n.size || 0, n.weight || 0),
i = getGroup(n),
gc = centroids[i];
// haven't decided what to do for unconnected nodes, yet...
if (!l)
return;
// apply amplification of the 'original' alpha:
// 1.0 for singles and double-connected nodes, close to 0 for highly connected nodes, rapidly decreasing.
// Use this as we want to give those 'non-singles' a little bit of the same 'push out' treatment.
// Reduce effect for 'real nodes' which are singles: they need much less encouragement!
power = Math.max(2, n_is_group ? n.link_count : n.group_data.link_count);
power = 2 / power;
alpha = e.alpha * power;
// undo/revert gravity forces (or as near as we can get, here)
//
// revert for truely single nodes, revert just a wee little bit for dual linked nodes,
// only reduce ever so slighty for nodes with few links (~ 3) that made it into this
// 'singles' selection
if (k = alpha * force1.gravity() * (0.8 + power)) {
dx = (mx - n.x) * k;
dy = (my - n.y) * k;
n.x -= dx;
n.y -= dy;
center.x -= dx * w;
center.y -= dy * w;
gc.x -= dx * w;
gc.y -= dy * w;
}
});
// move the entire graph so that its center of mass sits at the center, period.
center.x /= center.weight;
center.y /= center.weight;
if (debug == 1) {
c = vis.selectAll("circle.center-of-mass")
.attr("cx", center.x)
.attr("cy", center.y);
}
dx = mx - center.x;
dy = my - center.y;
alpha = e.alpha * 5;
dx *= alpha;
dy *= alpha;
net.nodes.forEach(function(n) {
n.x += dx;
n.y += dy;
});
change_squared = 0;
// fixup .px/.py so drag behaviour and annealing get the correct values, as
// force.tick() would expect .px and .py to be the .x and .y of yesterday.
net.nodes.forEach(function(n) {
// restrain all nodes to window area
var k, dx, dy,
r = (n.size > 0 ? n.size + dr : dr + 1) + 2 /* styled border outer thickness and a bit */;
dx = 0;
if (n.x < r)
dx = r - n.x;
else if (n.x > size[0] - r)
dx = size[0] - r - n.x;
dy = 0;
if (n.y < r)
dy = r - n.y;
else if (n.y > size[1] - r)
dy = size[1] - r - n.y;
k = 1.2;
n.x += dx * k;
n.y += dy * k;
// restraining completed.......................
// fixes 'elusive' node behaviour when hovering with the mouse (related to force.drag)
if (n.fixed) {
// 'elusive behaviour' ~ move mouse near node and node would take off, i.e. act as an elusive creature.
n.x = n.px;
n.y = n.py;
}
n.px = n.x;
n.py = n.y;
// plus copy for faster stop check
change_squared += (n.qx - n.x) * (n.qx - n.x);
change_squared += (n.qy - n.y) * (n.qy - n.y);
n.qx = n.x;
n.qy = n.y;
});
// also restrain helper nodes to within the visible area --> link paths are almost always ket in-view:
net.helper_nodes.forEach(function(n) {
// restrain all nodes to window area
var k, dx, dy,
r = (n.size > 0 ? n.size : 1) + 5 /* heuristic */;
dx = 0;
if (n.x < r)
dx = r - n.x;
else if (n.x > size[0] - r)
dx = size[0] - r - n.x;
dy = 0;
if (n.y < r)
dy = r - n.y;
else if (n.y > size[1] - r)
dy = size[1] - r - n.y;
k = 1.2;
n.x += dx * k;
n.y += dy * k;
// restraining completed.......................
n.px = n.x;
n.py = n.y;
// plus copy for faster stop check
change_squared += (n.qx - n.x) * (n.qx - n.x);
change_squared += (n.qy - n.y) * (n.qy - n.y);
n.qx = n.x;
n.qy = n.y;
});
if (!isFinite(change_squared))
change_squared = width * height;
// kick the force2 to also do a bit of annealing alongside:
// to make it do something, we need to surround it alpha-tweaking stuff, though.
force2.resume();
force2.tick();
force2.stop();
// fast stop + the drag fix, part 2:
if (change_squared < .05) {
if (debug == 1) console.log("fast stop: CPU load redux");
force1.stop();
// fix part 4: monitor D3 resetting the drag marker:
if (drag_in_progress) {
if (debug == 1) console.log("START monitor drag in progress", drag_in_progress);
d3.timer(function() {
drag_in_progress = false;
net.nodes.forEach(function(n) {
if (n.fixed & 2) {
drag_in_progress = true;
}
});
force1.resume();
if (debug == 1) console.log("monitor drag in progress: drag ENDED", drag_in_progress);
// Quit monitoring as soon as we noticed the drag ENDED.
// Note: we continue to monitor at +500ms intervals beyond the last tick
// as this timer function ALWAYS kickstarts the force layout again
// through force.resume().
// d3.timer() API only accepts an initial delay; we can't set this
// thing to scan, say, every 500msecs until the drag is done,
// so we do it that way, via the revived force.tick process.
return true;
}, 500);
}
} else if (change_squared > net.nodes.length * 0.1 * resume_threshold && e.alpha < resume_threshold) {
// jolt the alpha (and the visual) when there's still a lot of change when we hit the alpha threshold.
force1.alpha(e.alpha *= 2); //force.resume(), but now with decreasing alpha starting value so the jolts don't get so big.
// And 'dampen out' the trigger point, so it becomes harder and harder to trigger the threshold.
// This is done to cope with those instable (forever rotating, etc.) layouts...
resume_threshold *= 0.75;
} else {
//console.log("fast stop tracking: ", change_squared, net.nodes.length * 0.1 * resume_threshold, e.alpha, resume_threshold);
}
//--------------------------------------------------------------------
if (!hull.empty()) {
hull.data(convexHulls(net.nodes, off))
.attr("d", drawCluster);
}
if (debug == 1) {
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("cx", function(d) { return d.x; })
.attr("__name__", function(d) { return d.name || "xxx"; })
.attr("cy", function(d) { return d.y; });
});
var log_count = 100;
force2.on("tick", function(e) {
/*
Update all 'real'=fixed nodes.
*/
net.helper_nodes.forEach(function(n) {
var o;
if (n.fixed) {
o = n.ref;
n.px = n.x = o.x;
n.py = n.y = o.y;
}
});
net.helper_links.forEach(function(l) {
var o = l.g_ref;
l.distance = o.distance;
});
// NOTE: force2 is fully driven by force(1), but still there's need for 'fast stop' handling in here
// as our force2 may be more 'joyous' in animating the links that force is animating the nodes
// themselves. Hence we also take the delta movement of the helper nodes into account!
net.helper_nodes.forEach(function(n) {
// skip the 'fixed' buggers: those are already accounted for in force.tick!
if (n.fixed)
return;
// plus copy for faster stop check
change_squared += (n.qx - n.x) * (n.qx - n.x);
change_squared += (n.qy - n.y) * (n.qy - n.y);
n.qx = n.x;
n.qy = n.y;
});
if (!isFinite(change_squared))
change_squared = width * height;
//--------------------------------------------------------------------
var logged = false;
hlink.attr("d", function(d) {
if (isFinite(d.real_source.x)) {
var linedata, dx, dy, f;
if (d.cyclic) {
// construct round-ish bezier from node to helper and back again:
dx = d.target.x - d.real_source.x;
dy = d.target.y - d.real_source.y;
linedata = [
[d.real_source.x, d.real_source.y],
[d.target.x - cycle_curvep * dy, d.target.y + cycle_curvep * dx],
[d.target.x + cycle_curvep * dx, d.target.y + cycle_curvep * dy],
[d.target.x + cycle_curvep * dy, d.target.y - cycle_curvep * dx],
[d.real_source.x, d.real_source.y]
];
return cycle_pathgen(linedata);
} else {
linedata = [
[d.real_source.x, d.real_source.y],
[d.source.x, d.source.y],
[d.target.x, d.target.y],
[d.real_target.x, d.real_target.y]
];
return pathgen(linedata);
}
} else {
if (!logged) {
console.log("boom");
logged = true;
}
return null;
}
});
if (debug && debug < 3) {
hnode.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
}
});
}
</script>
</body>
</html>
{
"nodes":[
{"name":"Myriel","group":1},
{"name":"Napoleon","group":1},
{"name":"Mlle.Baptistine","group":1},
{"name":"Mme.Magloire","group":1},
{"name":"CountessdeLo","group":1},
{"name":"Geborand","group":1},
{"name":"Champtercier","group":1},
{"name":"Cravatte","group":1},
{"name":"Count","group":1},
{"name":"OldMan","group":1},
{"name":"Labarre","group":2},
{"name":"Valjean","group":2},
{"name":"Marguerite","group":3},
{"name":"Mme.deR","group":2},
{"name":"Isabeau","group":2},
{"name":"Gervais","group":2},
{"name":"Tholomyes","group":3},
{"name":"Listolier","group":3},
{"name":"Fameuil","group":3},
{"name":"Blacheville","group":3},
{"name":"Favourite","group":3},
{"name":"Dahlia","group":3},
{"name":"Zephine","group":3},
{"name":"Fantine","group":3},
{"name":"Mme.Thenardier","group":4},
{"name":"Thenardier","group":4},
{"name":"Cosette","group":5},
{"name":"Javert","group":4},
{"name":"Fauchelevent","group":0},
{"name":"Bamatabois","group":2},
{"name":"Perpetue","group":3},
{"name":"Simplice","group":2},
{"name":"Scaufflaire","group":2},
{"name":"Woman1","group":2},
{"name":"Judge","group":2},
{"name":"Champmathieu","group":2},
{"name":"Brevet","group":2},
{"name":"Chenildieu","group":2},
{"name":"Cochepaille","group":2},
{"name":"Pontmercy","group":4},
{"name":"Boulatruelle","group":6},
{"name":"Eponine","group":4},
{"name":"Anzelma","group":4},
{"name":"Woman2","group":5},
{"name":"MotherInnocent","group":0},
{"name":"Gribier","group":0},
{"name":"Jondrette","group":7},
{"name":"Mme.Burgon","group":7},
{"name":"Gavroche","group":8},
{"name":"Gillenormand","group":5},
{"name":"Magnon","group":5},
{"name":"Mlle.Gillenormand","group":5},
{"name":"Mme.Pontmercy","group":5},
{"name":"Mlle.Vaubois","group":5},
{"name":"Lt.Gillenormand","group":5},
{"name":"Marius","group":8},
{"name":"BaronessT","group":5},
{"name":"Mabeuf","group":8},
{"name":"Enjolras","group":8},
{"name":"Combeferre","group":8},
{"name":"Prouvaire","group":8},
{"name":"Feuilly","group":8},
{"name":"Courfeyrac","group":8},
{"name":"Bahorel","group":8},
{"name":"Bossuet","group":8},
{"name":"Joly","group":8},
{"name":"Grantaire","group":8},
{"name":"MotherPlutarch","group":9},
{"name":"Gueulemer","group":4},
{"name":"Babet","group":4},
{"name":"Claquesous","group":4},
{"name":"Montparnasse","group":4},
{"name":"Toussaint","group":5},
{"name":"Child1","group":10},
{"name":"Child2","group":10},
{"name":"Brujon","group":4},
{"name":"Mme.Hucheloup","group":8}
],"links":[
{"source":1,"target":0,"value":1},
{"source":2,"target":0,"value":8},
{"source":3,"target":0,"value":10},
{"source":3,"target":2,"value":6},
{"source":4,"target":0,"value":1},
{"source":5,"target":0,"value":1},
{"source":6,"target":0,"value":1},
{"source":7,"target":0,"value":1},
{"source":8,"target":0,"value":2},
{"source":9,"target":0,"value":1},
{"source":11,"target":10,"value":1},
{"source":11,"target":3,"value":3},
{"source":11,"target":2,"value":3},
{"source":11,"target":0,"value":5},
{"source":12,"target":11,"value":1},
{"source":13,"target":11,"value":1},
{"source":14,"target":11,"value":1},
{"source":15,"target":11,"value":1},
{"source":17,"target":16,"value":4},
{"source":18,"target":16,"value":4},
{"source":18,"target":17,"value":4},
{"source":19,"target":16,"value":4},
{"source":19,"target":17,"value":4},
{"source":19,"target":18,"value":4},
{"source":20,"target":16,"value":3},
{"source":20,"target":17,"value":3},
{"source":20,"target":18,"value":3},
{"source":20,"target":19,"value":4},
{"source":21,"target":16,"value":3},
{"source":21,"target":17,"value":3},
{"source":21,"target":18,"value":3},
{"source":21,"target":19,"value":3},
{"source":21,"target":20,"value":5},
{"source":22,"target":16,"value":3},
{"source":22,"target":17,"value":3},
{"source":22,"target":18,"value":3},
{"source":22,"target":19,"value":3},
{"source":22,"target":20,"value":4},
{"source":22,"target":21,"value":4},
{"source":23,"target":16,"value":3},
{"source":23,"target":17,"value":3},
{"source":23,"target":18,"value":3},
{"source":23,"target":19,"value":3},
{"source":23,"target":20,"value":4},
{"source":23,"target":21,"value":4},
{"source":23,"target":22,"value":4},
{"source":23,"target":12,"value":2},
{"source":23,"target":11,"value":9},
{"source":24,"target":23,"value":2},
{"source":24,"target":11,"value":7},
{"source":25,"target":24,"value":13},
{"source":25,"target":23,"value":1},
{"source":25,"target":11,"value":12},
{"source":26,"target":24,"value":4},
{"source":26,"target":11,"value":31},
{"source":26,"target":16,"value":1},
{"source":26,"target":25,"value":1},
{"source":27,"target":11,"value":17},
{"source":27,"target":23,"value":5},
{"source":27,"target":25,"value":5},
{"source":27,"target":24,"value":1},
{"source":27,"target":26,"value":1},
{"source":28,"target":11,"value":8},
{"source":28,"target":27,"value":1},
{"source":29,"target":23,"value":1},
{"source":29,"target":27,"value":1},
{"source":29,"target":11,"value":2},
{"source":30,"target":23,"value":1},
{"source":31,"target":30,"value":2},
{"source":31,"target":11,"value":3},
{"source":31,"target":23,"value":2},
{"source":31,"target":27,"value":1},
{"source":32,"target":11,"value":1},
{"source":33,"target":11,"value":2},
{"source":33,"target":27,"value":1},
{"source":34,"target":11,"value":3},
{"source":34,"target":29,"value":2},
{"source":35,"target":11,"value":3},
{"source":35,"target":34,"value":3},
{"source":35,"target":29,"value":2},
{"source":36,"target":34,"value":2},
{"source":36,"target":35,"value":2},
{"source":36,"target":11,"value":2},
{"source":36,"target":29,"value":1},
{"source":37,"target":34,"value":2},
{"source":37,"target":35,"value":2},
{"source":37,"target":36,"value":2},
{"source":37,"target":11,"value":2},
{"source":37,"target":29,"value":1},
{"source":38,"target":34,"value":2},
{"source":38,"target":35,"value":2},
{"source":38,"target":36,"value":2},
{"source":38,"target":37,"value":2},
{"source":38,"target":11,"value":2},
{"source":38,"target":29,"value":1},
{"source":39,"target":25,"value":1},
{"source":40,"target":25,"value":1},
{"source":41,"target":24,"value":2},
{"source":41,"target":25,"value":3},
{"source":42,"target":41,"value":2},
{"source":42,"target":25,"value":2},
{"source":42,"target":24,"value":1},
{"source":43,"target":11,"value":3},
{"source":43,"target":26,"value":1},
{"source":43,"target":27,"value":1},
{"source":44,"target":28,"value":3},
{"source":44,"target":11,"value":1},
{"source":45,"target":28,"value":2},
{"source":47,"target":46,"value":1},
{"source":48,"target":47,"value":2},
{"source":48,"target":25,"value":1},
{"source":48,"target":27,"value":1},
{"source":48,"target":11,"value":1},
{"source":49,"target":26,"value":3},
{"source":49,"target":11,"value":2},
{"source":50,"target":49,"value":1},
{"source":50,"target":24,"value":1},
{"source":51,"target":49,"value":9},
{"source":51,"target":26,"value":2},
{"source":51,"target":11,"value":2},
{"source":52,"target":51,"value":1},
{"source":52,"target":39,"value":1},
{"source":53,"target":51,"value":1},
{"source":54,"target":51,"value":2},
{"source":54,"target":49,"value":1},
{"source":54,"target":26,"value":1},
{"source":55,"target":51,"value":6},
{"source":55,"target":49,"value":12},
{"source":55,"target":39,"value":1},
{"source":55,"target":54,"value":1},
{"source":55,"target":26,"value":21},
{"source":55,"target":11,"value":19},
{"source":55,"target":16,"value":1},
{"source":55,"target":25,"value":2},
{"source":55,"target":41,"value":5},
{"source":55,"target":48,"value":4},
{"source":56,"target":49,"value":1},
{"source":56,"target":55,"value":1},
{"source":57,"target":55,"value":1},
{"source":57,"target":41,"value":1},
{"source":57,"target":48,"value":1},
{"source":58,"target":55,"value":7},
{"source":58,"target":48,"value":7},
{"source":58,"target":27,"value":6},
{"source":58,"target":57,"value":1},
{"source":58,"target":11,"value":4},
{"source":59,"target":58,"value":15},
{"source":59,"target":55,"value":5},
{"source":59,"target":48,"value":6},
{"source":59,"target":57,"value":2},
{"source":60,"target":48,"value":1},
{"source":60,"target":58,"value":4},
{"source":60,"target":59,"value":2},
{"source":61,"target":48,"value":2},
{"source":61,"target":58,"value":6},
{"source":61,"target":60,"value":2},
{"source":61,"target":59,"value":5},
{"source":61,"target":57,"value":1},
{"source":61,"target":55,"value":1},
{"source":62,"target":55,"value":9},
{"source":62,"target":58,"value":17},
{"source":62,"target":59,"value":13},
{"source":62,"target":48,"value":7},
{"source":62,"target":57,"value":2},
{"source":62,"target":41,"value":1},
{"source":62,"target":61,"value":6},
{"source":62,"target":60,"value":3},
{"source":63,"target":59,"value":5},
{"source":63,"target":48,"value":5},
{"source":63,"target":62,"value":6},
{"source":63,"target":57,"value":2},
{"source":63,"target":58,"value":4},
{"source":63,"target":61,"value":3},
{"source":63,"target":60,"value":2},
{"source":63,"target":55,"value":1},
{"source":64,"target":55,"value":5},
{"source":64,"target":62,"value":12},
{"source":64,"target":48,"value":5},
{"source":64,"target":63,"value":4},
{"source":64,"target":58,"value":10},
{"source":64,"target":61,"value":6},
{"source":64,"target":60,"value":2},
{"source":64,"target":59,"value":9},
{"source":64,"target":57,"value":1},
{"source":64,"target":11,"value":1},
{"source":65,"target":63,"value":5},
{"source":65,"target":64,"value":7},
{"source":65,"target":48,"value":3},
{"source":65,"target":62,"value":5},
{"source":65,"target":58,"value":5},
{"source":65,"target":61,"value":5},
{"source":65,"target":60,"value":2},
{"source":65,"target":59,"value":5},
{"source":65,"target":57,"value":1},
{"source":65,"target":55,"value":2},
{"source":66,"target":64,"value":3},
{"source":66,"target":58,"value":3},
{"source":66,"target":59,"value":1},
{"source":66,"target":62,"value":2},
{"source":66,"target":65,"value":2},
{"source":66,"target":48,"value":1},
{"source":66,"target":63,"value":1},
{"source":66,"target":61,"value":1},
{"source":66,"target":60,"value":1},
{"source":67,"target":57,"value":3},
{"source":68,"target":25,"value":5},
{"source":68,"target":11,"value":1},
{"source":68,"target":24,"value":1},
{"source":68,"target":27,"value":1},
{"source":68,"target":48,"value":1},
{"source":68,"target":41,"value":1},
{"source":69,"target":25,"value":6},
{"source":69,"target":68,"value":6},
{"source":69,"target":11,"value":1},
{"source":69,"target":24,"value":1},
{"source":69,"target":27,"value":2},
{"source":69,"target":48,"value":1},
{"source":69,"target":41,"value":1},
{"source":70,"target":25,"value":4},
{"source":70,"target":69,"value":4},
{"source":70,"target":68,"value":4},
{"source":70,"target":11,"value":1},
{"source":70,"target":24,"value":1},
{"source":70,"target":27,"value":1},
{"source":70,"target":41,"value":1},
{"source":70,"target":58,"value":1},
{"source":71,"target":27,"value":1},
{"source":71,"target":69,"value":2},
{"source":71,"target":68,"value":2},
{"source":71,"target":70,"value":2},
{"source":71,"target":11,"value":1},
{"source":71,"target":48,"value":1},
{"source":71,"target":41,"value":1},
{"source":71,"target":25,"value":1},
{"source":72,"target":26,"value":2},
{"source":72,"target":27,"value":1},
{"source":72,"target":11,"value":1},
{"source":73,"target":48,"value":2},
{"source":74,"target":48,"value":2},
{"source":74,"target":73,"value":3},
{"source":75,"target":69,"value":3},
{"source":75,"target":68,"value":3},
{"source":75,"target":25,"value":3},
{"source":75,"target":48,"value":1},
{"source":75,"target":41,"value":1},
{"source":75,"target":70,"value":1},
{"source":75,"target":71,"value":1},
{"source":76,"target":64,"value":1},
{"source":76,"target":65,"value":1},
{"source":76,"target":66,"value":1},
{"source":76,"target":63,"value":1},
{"source":76,"target":62,"value":1},
{"source":76,"target":48,"value":1},
{"source":76,"target":58,"value":1},
{"source":40,"target":40,"value":2,"cyclic":1},
{"source":40,"target":40,"value":3,"cyclic":1},
{"source":65,"target":65,"value":1,"cyclic":1},
{"source":66,"target":66,"value":1,"cyclic":1},
{"source":67,"target":67,"value":1,"cyclic":1},
{"source":68,"target":68,"value":1,"cyclic":1},
{"source":46,"target":46,"value":2,"cyclic":1},
{"source":46,"target":46,"value":3,"cyclic":1},
{"source":46,"target":46,"value":1,"cyclic":1},
{"source":73,"target":73,"value":3,"cyclic":1},
{"source":74,"target":74,"value":1,"cyclic":1},
{"source":47,"target":47,"value":2,"cyclic":1}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment