From 28641ec5e1a5e819f92c715ba297d2cf2305941f Mon Sep 17 00:00:00 2001 From: Peter Wu Date: Wed, 21 May 2014 15:20:33 +0200 Subject: Better names / title --- bubble.html | 150 ---------------------- js/bubble.js | 407 ----------------------------------------------------------- js/spam.js | 407 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ spamviz.html | 150 ++++++++++++++++++++++ 4 files changed, 557 insertions(+), 557 deletions(-) delete mode 100644 bubble.html delete mode 100644 js/bubble.js create mode 100644 js/spam.js create mode 100644 spamviz.html diff --git a/bubble.html b/bubble.html deleted file mode 100644 index 697a65d..0000000 --- a/bubble.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - -D3 demo - - - - -
-
-
-
    -
  • Total nodes: -
  • Total edges: -
  • Zoom: (scroll to zoom) -
  • User info does not follow the - mouse (double-click node to toggle) -
- -
-
- -
- - - - - - - diff --git a/js/bubble.js b/js/bubble.js deleted file mode 100644 index c37426e..0000000 --- a/js/bubble.js +++ /dev/null @@ -1,407 +0,0 @@ -/* jshint devel:true, browser:true */ -/* global d3, collisionTick, preprocess */ -'use strict'; - -/* settings */ -// whether to enable expensive features -var I_GOT_MONEY = 1; -// whether to ignore lonely users -var KILL_LONERS = true; -// if positive, then users with fewer than this number of tweets will be ignored -var USER_MIN_TWEET_COUNT = 2; -// if true, enable sticky nodes unless Ctrl is held. If false, stick only if -// ctrl is held (the inverse). -var STICKY_DEFAULT = true; - - -/* functions */ - -function getEdgesNodes(nodesCsv, edgesCsv, completeCallback) { - var results = {}; - var checker = function (what) { - return function (error, rows) { - if (error) { - console.log('Cannot complete requests!', error); - return; - } - results[what] = rows; - // all data available, call callback function - if (results.nodes && results.edges) { - completeCallback(results); - } - }; - }; - // fetch both files, asynchronously. - nodesCsv.get(checker('nodes')); - edgesCsv.get(checker('edges')); -} - -function initForce(width, height) { - console.log('Initing force with dimensions', width, height); - var force = d3.layout.force() - .charge(-10) // default -30 - //.linkDistance(20) // default 20 - .size([width, height]); - - // sticky positions after dragging if CTRL is held down - function sticky() { - return STICKY_DEFAULT ^ d3.event.sourceEvent.ctrlKey; - } - force.drag() - .on('dragstart', function (d) { - // if ctrl is pressed, sticky... - if (sticky()) { - d.fixed = true; - } - }) - .on('dragend', function (d) { - // release if not sticky, otherwise keep sticky - d.fixed = sticky(); - }); - - return force; -} - -function initZoom(svg) { - var zoomBehavior = d3.behavior.zoom() - .scaleExtent([0.1, 10]) // min/max zoom - .on('zoomstart', zoomStart) - .on('zoom', zoom); - var container = svg - .call(zoomBehavior) - .on('dblclick.zoom', null) - // the zoom transformations apply here. - .append('g'); - var zoomInfo = d3.select('#infobox .zoom-level'); - zoomInfo.text('100'); - - var oldTranslation, oldScale, panAllowed = false; - // pan is only allowed if the source is not an element - function zoomStart() { - var ev = d3.event.sourceEvent; - panAllowed = ev.target === svg.node(); - oldTranslation = zoomBehavior.translate(); - oldScale = zoomBehavior.scale(); - } - function zoom() { - // disallow pan only if not zooming - if (!panAllowed && d3.event.scale === oldScale) { - d3.event.translate = oldTranslation; - zoomBehavior.translate(oldTranslation); - } - container.attr('transform', - 'translate(' + d3.event.translate + ')' + - 'scale(' + d3.event.scale + ')'); - // save old translation for later restore if disabled. - oldTranslation = d3.event.translate; - oldScale = d3.event.scale; - zoomInfo.text(Math.floor(100 * oldScale)); - } - return container; -} - -// contents holds the actual nodes and edges and exists to allow pan/zoom -var contents; -// force layout configuration -var force; - -function initSvg() { - var svg = d3.select('#map').append('svg'); - contents = initZoom(svg); - - svg.append('defs') - // definition for an arrow head. Note: affected by stroke width of the path - .append('marker') - .attr('id', 'arrow') - .attr('markerWidth', 6) - .attr('markerHeight', 6) - .attr('orient', 'auto') - // make dimensions relative to this box instead of absolute pixels - // NOTE: capital 'B'!!! view *B* ox!!! Spent two hours on that... - // viewBox="x y width height" - .attr('viewBox', '-10 -5 10 10') - .attr('markerUnits', 'userSpaceOnUse') - .append('path') - .attr('class', 'arrow-head') - // M x,y - absolute moveTo - // L x,y - relative lineTo - // arrow head '>' as in: (edge) --- '>' o (node) - .attr('d', 'M-10,-5 L0,0 L-10,5'); - - // default the space of the force layout to the svg canvas dimension. If you - // want to allow pan / zoom with more data out of view, multiply this by - // some factor. - var dim = d3.select('#map').node().getBoundingClientRect(); - // begin simulation - force = initForce(dim.width, dim.height); -} - -function processData(data) { - var infoPane = d3.select('#infobox'); - - preprocess(data, { - kill_loners: KILL_LONERS, - minTweetCount: USER_MIN_TWEET_COUNT - }); - infoPane.select('.node-count').text(data.nodes.length); - infoPane.select('.edge-count').text(data.edges.length); - force.nodes(data.nodes) - .links(data.edges) - .start(); - - // element 'g' groups SVG elements, useful to apply a single transf. to all - /* edges */ - var link = contents.append('g').selectAll('path') - .data(force.links()) - .enter().append('path') - .attr('class', 'link') - .attr('marker-end', 'url(#arrow)') - .style('stroke-width', function (d) { - return Math.sqrt(d.value); - }); - link.append('title') - .text(function (d) { - return d.value; - }); - - /* nodes */ - var node = contents.append('g').selectAll('circle') - .data(force.nodes()) - .enter().append('circle') - .attr('class', function (d) { - return 'node ' + (d.isSpam ? 'spam' : 'ham'); - }) - .attr('r', function (d) { - return d.radius; - }) - .call(force.drag); - node.append('title') - .text(function (d) { - return d.name; - }); - - var infoBox = d3.select('#infobox'); - - // double-click locks selection of a user on hover - var selectedNode = null; - node.on('dblclick', function (d) { - if (selectedNode === d) { - // no update needed, unmark for dynamic update - selectedNode = null; - d3.select(this).classed('selected', false); - } else { - selectedNode = d; - updateInfobox(d, this); - } - console.log(selectedNode, d); - infoBox.classed('user-locked', selectedNode === d); - }) - .on('mouseover', function (d) { - console.log('Hovering over', d); - // only update on hover if no node is selected - if (selectedNode === null) { - updateInfobox(d, this); - } - // always update neighboring edges - contents.selectAll('.link') - .classed('neighbor', function (edge) { - return edge.source === d || edge.target === d; - }); - // ... and also update neighboring nodes - contents.selectAll('.node') - .classed('neighbor', function (node) { - return node.relatedTo.indexOf(d.index) >= 0 || - node.relatedFrom.indexOf(d.index) >= 0; - }); - }) - .on('mouseout', function (d) { - contents.selectAll('.neighbor') - .classed('neighbor', false); - }); - - - // info panel for each user node - var userInfo = infoPane.select('.user-info'); - // highlight selected node and update the user info in infobox - function updateInfobox(d, nodeElm) { - // unselect other nodes, mark self as selected. - contents.select('.node.selected').classed('selected', false); - d3.select(nodeElm).classed('selected', true); - - // display user block - userInfo.style('display', 'block'); - - userInfo.select('.name a').remove(); - userInfo.select('.name').append('a') - .attr('target', '_blank') - .attr('href', 'https://twitter.com/' + d.name) - .text(d.name); - userInfo.select('.userid').text(d.group); - userInfo.select('.tweet-count') - .text(d.tweetCount); - userInfo.select('.spam-status') - .text(d.isSpam ? 'SPAM' : 'ham'); - - var nodes = { - 'from': force.nodes().filter(function (edge) { - return edge.relatedTo.indexOf(d.index) >= 0; - }), - 'to': force.nodes().filter(function (edge) { - return edge.relatedFrom.indexOf(d.index) >= 0; - }) - }; - Object.keys(nodes).forEach(function (dir) { - var related = nodes[dir]; - related.sort(function sort_by_name(a, b) { - return a.name.localeCompare(b.name); - }); - userInfo.select('.relations-' + dir + '-count') - .text(related.length); - var relations = userInfo.select('.relations-' + dir) - .selectAll('li') - .data(related, function key_func_links(d) { - // unique keys to group by node (index) - return d.index; - }); - relations.enter().append('li') - .text(function (d) { - return dir + ' ' + d.name; - }); - relations.exit().remove(); - }); - } - - force.on('tick', function() { - // based on http://bl.ocks.org/mbostock/1153292 - link.attr('d', function force_tick(d) { - // https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Arcs - // A rx ry x-axis-rotation large-arc-flag sweep-flag x y - // a rx ry x-axis-rotation large-arc-flag sweep-flag dx dy - // rx and ry are the offsets from the center between current - // position and (last-x, last-y) - // large-arc-flag 0 means draw the arc on the angle < 180 degree - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - r = Math.sqrt(dx * dx + dy * dy), - // curve radius is based on two circles, with their radius being - // an offset from the center between the start and end point - cr = r; - // remove the radius such that the arrow head just hits the node - var ratio = (r - d.target.radius) / r; - dx *= ratio; - dy *= ratio; - return 'M' + d.source.x + ',' + d.source.y + ' ' + - 'a' + cr + ',' + cr + ' 0 0 0 ' + - dx + ',' + dy; - }); - - // warning: expensive! - if (I_GOT_MONEY) { - collisionTick(force.nodes()); - } - - node.attr('cx', function(d) { return d.x; }) - .attr('cy', function(d) { return d.y; }); - // PROFILE - ticks++; - }); -} - -// initialize information panel -(function () { - var infoPane = d3.select('#infobox'); - infoPane.select('.draggable') - .on('mousedown', function () { - infoPane.call(d3.behavior.drag().on('drag', dragger)); - }) - .on('mouseup', function () { - infoPane.on('mousedown.drag', null); - }); - - function dragger() { - var changes = { - 'left': d3.event.dx, - 'top': d3.event.dy - }; - // add the differences to the old positions - for (var name in changes) { - var newValue = parseInt(infoPane.style(name)); - newValue += changes[name]; - infoPane.style(name, newValue + 'px'); - } - } -}()); - -function run() { - /* fetch CSV files and render the result */ - getEdgesNodes( - // userid,name,tweetCount - d3.csv('users.csv') - .row(function (d) { - return { - name: d.name, - group: +d.userid, - tweetCount: d.tweetcount, - radius: Math.sqrt(d.tweetcount), - isSpam: +d.isspam, - // indices of nodes that link to this one - relatedTo: [], relatedFrom: [], - }; - }), - // source,target,value - d3.csv('links.csv') - .row(function (d) { - return { - source: +d.source, - target: +d.target, - value: +d.value - }; - }), - processData /* callback function when data is ready */ - ); -} - -// initialize SVG element and force -initSvg(); - -// Set PROFILE=1 to enable profiling when using the button. -var ticks = 0, PROFILE = /\bprofile\b/.test(location.search); -if (/\bno-auto\b/.test(location.search)) { - // advanced stuff here: profiling! - d3.select('body').append('button') - .style('position', 'absolute') - .style('z-index', 2) - .style('font-size', '20em') - .text('RUN') - .on('click', function () { - d3.select(this).remove(); - if (PROFILE) { - ticks = 0; - console.time('Run'); - console.timeline('Run'); - console.profile('Run'); - } - run(); - if (PROFILE) { - setTimeout(function () { - console.log('Ticks:', ticks); - console.timelineEnd('Run'); - console.profileEnd('Run'); - console.timeEnd('Run'); - }, 30000); - } - }); -/* notes -X 30 ticks, firefox -X 66 ticks, chromium -X disabled edge positioning, 84 ticks, chromium - -70 ticks in 31.3s, chromium (disabled title elements) [69,30.3] -73 ticks in 30.8s, chromium (title elements enabled) [68,30.4] -62 ticks in 30.2s, chromium (removed 2x 'g' elements) [60,31.8] - -55 ticks in 32.9s, chromium (with collision check) [54,30.4] - */ -} else { - run(); -} diff --git a/js/spam.js b/js/spam.js new file mode 100644 index 0000000..c37426e --- /dev/null +++ b/js/spam.js @@ -0,0 +1,407 @@ +/* jshint devel:true, browser:true */ +/* global d3, collisionTick, preprocess */ +'use strict'; + +/* settings */ +// whether to enable expensive features +var I_GOT_MONEY = 1; +// whether to ignore lonely users +var KILL_LONERS = true; +// if positive, then users with fewer than this number of tweets will be ignored +var USER_MIN_TWEET_COUNT = 2; +// if true, enable sticky nodes unless Ctrl is held. If false, stick only if +// ctrl is held (the inverse). +var STICKY_DEFAULT = true; + + +/* functions */ + +function getEdgesNodes(nodesCsv, edgesCsv, completeCallback) { + var results = {}; + var checker = function (what) { + return function (error, rows) { + if (error) { + console.log('Cannot complete requests!', error); + return; + } + results[what] = rows; + // all data available, call callback function + if (results.nodes && results.edges) { + completeCallback(results); + } + }; + }; + // fetch both files, asynchronously. + nodesCsv.get(checker('nodes')); + edgesCsv.get(checker('edges')); +} + +function initForce(width, height) { + console.log('Initing force with dimensions', width, height); + var force = d3.layout.force() + .charge(-10) // default -30 + //.linkDistance(20) // default 20 + .size([width, height]); + + // sticky positions after dragging if CTRL is held down + function sticky() { + return STICKY_DEFAULT ^ d3.event.sourceEvent.ctrlKey; + } + force.drag() + .on('dragstart', function (d) { + // if ctrl is pressed, sticky... + if (sticky()) { + d.fixed = true; + } + }) + .on('dragend', function (d) { + // release if not sticky, otherwise keep sticky + d.fixed = sticky(); + }); + + return force; +} + +function initZoom(svg) { + var zoomBehavior = d3.behavior.zoom() + .scaleExtent([0.1, 10]) // min/max zoom + .on('zoomstart', zoomStart) + .on('zoom', zoom); + var container = svg + .call(zoomBehavior) + .on('dblclick.zoom', null) + // the zoom transformations apply here. + .append('g'); + var zoomInfo = d3.select('#infobox .zoom-level'); + zoomInfo.text('100'); + + var oldTranslation, oldScale, panAllowed = false; + // pan is only allowed if the source is not an element + function zoomStart() { + var ev = d3.event.sourceEvent; + panAllowed = ev.target === svg.node(); + oldTranslation = zoomBehavior.translate(); + oldScale = zoomBehavior.scale(); + } + function zoom() { + // disallow pan only if not zooming + if (!panAllowed && d3.event.scale === oldScale) { + d3.event.translate = oldTranslation; + zoomBehavior.translate(oldTranslation); + } + container.attr('transform', + 'translate(' + d3.event.translate + ')' + + 'scale(' + d3.event.scale + ')'); + // save old translation for later restore if disabled. + oldTranslation = d3.event.translate; + oldScale = d3.event.scale; + zoomInfo.text(Math.floor(100 * oldScale)); + } + return container; +} + +// contents holds the actual nodes and edges and exists to allow pan/zoom +var contents; +// force layout configuration +var force; + +function initSvg() { + var svg = d3.select('#map').append('svg'); + contents = initZoom(svg); + + svg.append('defs') + // definition for an arrow head. Note: affected by stroke width of the path + .append('marker') + .attr('id', 'arrow') + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + // make dimensions relative to this box instead of absolute pixels + // NOTE: capital 'B'!!! view *B* ox!!! Spent two hours on that... + // viewBox="x y width height" + .attr('viewBox', '-10 -5 10 10') + .attr('markerUnits', 'userSpaceOnUse') + .append('path') + .attr('class', 'arrow-head') + // M x,y - absolute moveTo + // L x,y - relative lineTo + // arrow head '>' as in: (edge) --- '>' o (node) + .attr('d', 'M-10,-5 L0,0 L-10,5'); + + // default the space of the force layout to the svg canvas dimension. If you + // want to allow pan / zoom with more data out of view, multiply this by + // some factor. + var dim = d3.select('#map').node().getBoundingClientRect(); + // begin simulation + force = initForce(dim.width, dim.height); +} + +function processData(data) { + var infoPane = d3.select('#infobox'); + + preprocess(data, { + kill_loners: KILL_LONERS, + minTweetCount: USER_MIN_TWEET_COUNT + }); + infoPane.select('.node-count').text(data.nodes.length); + infoPane.select('.edge-count').text(data.edges.length); + force.nodes(data.nodes) + .links(data.edges) + .start(); + + // element 'g' groups SVG elements, useful to apply a single transf. to all + /* edges */ + var link = contents.append('g').selectAll('path') + .data(force.links()) + .enter().append('path') + .attr('class', 'link') + .attr('marker-end', 'url(#arrow)') + .style('stroke-width', function (d) { + return Math.sqrt(d.value); + }); + link.append('title') + .text(function (d) { + return d.value; + }); + + /* nodes */ + var node = contents.append('g').selectAll('circle') + .data(force.nodes()) + .enter().append('circle') + .attr('class', function (d) { + return 'node ' + (d.isSpam ? 'spam' : 'ham'); + }) + .attr('r', function (d) { + return d.radius; + }) + .call(force.drag); + node.append('title') + .text(function (d) { + return d.name; + }); + + var infoBox = d3.select('#infobox'); + + // double-click locks selection of a user on hover + var selectedNode = null; + node.on('dblclick', function (d) { + if (selectedNode === d) { + // no update needed, unmark for dynamic update + selectedNode = null; + d3.select(this).classed('selected', false); + } else { + selectedNode = d; + updateInfobox(d, this); + } + console.log(selectedNode, d); + infoBox.classed('user-locked', selectedNode === d); + }) + .on('mouseover', function (d) { + console.log('Hovering over', d); + // only update on hover if no node is selected + if (selectedNode === null) { + updateInfobox(d, this); + } + // always update neighboring edges + contents.selectAll('.link') + .classed('neighbor', function (edge) { + return edge.source === d || edge.target === d; + }); + // ... and also update neighboring nodes + contents.selectAll('.node') + .classed('neighbor', function (node) { + return node.relatedTo.indexOf(d.index) >= 0 || + node.relatedFrom.indexOf(d.index) >= 0; + }); + }) + .on('mouseout', function (d) { + contents.selectAll('.neighbor') + .classed('neighbor', false); + }); + + + // info panel for each user node + var userInfo = infoPane.select('.user-info'); + // highlight selected node and update the user info in infobox + function updateInfobox(d, nodeElm) { + // unselect other nodes, mark self as selected. + contents.select('.node.selected').classed('selected', false); + d3.select(nodeElm).classed('selected', true); + + // display user block + userInfo.style('display', 'block'); + + userInfo.select('.name a').remove(); + userInfo.select('.name').append('a') + .attr('target', '_blank') + .attr('href', 'https://twitter.com/' + d.name) + .text(d.name); + userInfo.select('.userid').text(d.group); + userInfo.select('.tweet-count') + .text(d.tweetCount); + userInfo.select('.spam-status') + .text(d.isSpam ? 'SPAM' : 'ham'); + + var nodes = { + 'from': force.nodes().filter(function (edge) { + return edge.relatedTo.indexOf(d.index) >= 0; + }), + 'to': force.nodes().filter(function (edge) { + return edge.relatedFrom.indexOf(d.index) >= 0; + }) + }; + Object.keys(nodes).forEach(function (dir) { + var related = nodes[dir]; + related.sort(function sort_by_name(a, b) { + return a.name.localeCompare(b.name); + }); + userInfo.select('.relations-' + dir + '-count') + .text(related.length); + var relations = userInfo.select('.relations-' + dir) + .selectAll('li') + .data(related, function key_func_links(d) { + // unique keys to group by node (index) + return d.index; + }); + relations.enter().append('li') + .text(function (d) { + return dir + ' ' + d.name; + }); + relations.exit().remove(); + }); + } + + force.on('tick', function() { + // based on http://bl.ocks.org/mbostock/1153292 + link.attr('d', function force_tick(d) { + // https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Arcs + // A rx ry x-axis-rotation large-arc-flag sweep-flag x y + // a rx ry x-axis-rotation large-arc-flag sweep-flag dx dy + // rx and ry are the offsets from the center between current + // position and (last-x, last-y) + // large-arc-flag 0 means draw the arc on the angle < 180 degree + var dx = d.target.x - d.source.x, + dy = d.target.y - d.source.y, + r = Math.sqrt(dx * dx + dy * dy), + // curve radius is based on two circles, with their radius being + // an offset from the center between the start and end point + cr = r; + // remove the radius such that the arrow head just hits the node + var ratio = (r - d.target.radius) / r; + dx *= ratio; + dy *= ratio; + return 'M' + d.source.x + ',' + d.source.y + ' ' + + 'a' + cr + ',' + cr + ' 0 0 0 ' + + dx + ',' + dy; + }); + + // warning: expensive! + if (I_GOT_MONEY) { + collisionTick(force.nodes()); + } + + node.attr('cx', function(d) { return d.x; }) + .attr('cy', function(d) { return d.y; }); + // PROFILE + ticks++; + }); +} + +// initialize information panel +(function () { + var infoPane = d3.select('#infobox'); + infoPane.select('.draggable') + .on('mousedown', function () { + infoPane.call(d3.behavior.drag().on('drag', dragger)); + }) + .on('mouseup', function () { + infoPane.on('mousedown.drag', null); + }); + + function dragger() { + var changes = { + 'left': d3.event.dx, + 'top': d3.event.dy + }; + // add the differences to the old positions + for (var name in changes) { + var newValue = parseInt(infoPane.style(name)); + newValue += changes[name]; + infoPane.style(name, newValue + 'px'); + } + } +}()); + +function run() { + /* fetch CSV files and render the result */ + getEdgesNodes( + // userid,name,tweetCount + d3.csv('users.csv') + .row(function (d) { + return { + name: d.name, + group: +d.userid, + tweetCount: d.tweetcount, + radius: Math.sqrt(d.tweetcount), + isSpam: +d.isspam, + // indices of nodes that link to this one + relatedTo: [], relatedFrom: [], + }; + }), + // source,target,value + d3.csv('links.csv') + .row(function (d) { + return { + source: +d.source, + target: +d.target, + value: +d.value + }; + }), + processData /* callback function when data is ready */ + ); +} + +// initialize SVG element and force +initSvg(); + +// Set PROFILE=1 to enable profiling when using the button. +var ticks = 0, PROFILE = /\bprofile\b/.test(location.search); +if (/\bno-auto\b/.test(location.search)) { + // advanced stuff here: profiling! + d3.select('body').append('button') + .style('position', 'absolute') + .style('z-index', 2) + .style('font-size', '20em') + .text('RUN') + .on('click', function () { + d3.select(this).remove(); + if (PROFILE) { + ticks = 0; + console.time('Run'); + console.timeline('Run'); + console.profile('Run'); + } + run(); + if (PROFILE) { + setTimeout(function () { + console.log('Ticks:', ticks); + console.timelineEnd('Run'); + console.profileEnd('Run'); + console.timeEnd('Run'); + }, 30000); + } + }); +/* notes +X 30 ticks, firefox +X 66 ticks, chromium +X disabled edge positioning, 84 ticks, chromium + +70 ticks in 31.3s, chromium (disabled title elements) [69,30.3] +73 ticks in 30.8s, chromium (title elements enabled) [68,30.4] +62 ticks in 30.2s, chromium (removed 2x 'g' elements) [60,31.8] + +55 ticks in 32.9s, chromium (with collision check) [54,30.4] + */ +} else { + run(); +} diff --git a/spamviz.html b/spamviz.html new file mode 100644 index 0000000..acebd5e --- /dev/null +++ b/spamviz.html @@ -0,0 +1,150 @@ + + + + +Network visualization + + + + +
+
+
+
    +
  • Total nodes: +
  • Total edges: +
  • Zoom: (scroll to zoom) +
  • User info does not follow the + mouse (double-click node to toggle) +
+ +
+
+ +
+ + + + + + + -- cgit v1.2.1