summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bubble.html139
-rw-r--r--bubble.js440
-rw-r--r--collision.js36
3 files changed, 615 insertions, 0 deletions
diff --git a/bubble.html b/bubble.html
new file mode 100644
index 0000000..c0ed167
--- /dev/null
+++ b/bubble.html
@@ -0,0 +1,139 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>D3 demo</title>
+</head>
+<style>
+html, body {
+ padding: 0;
+ margin: 0;
+}
+#map {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+#map svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+/* style for info panel */
+#infobox {
+ border-radius: 10px;
+ background-color: #f0f0f0;
+ width: 240px;
+ height: 340px;
+ position: fixed;
+ left: 6px;
+ top: 6px;
+ opacity: .5;
+ z-index: 2; /* show above the map */
+ display: flex;
+ flex-direction: column;
+}
+#infobox:hover {
+ opacity: 1;
+}
+#infobox .draggable {
+ background-color: #cfcfcf;
+ width: 100%;
+ height: 20px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ cursor: move;
+}
+#infobox .content {
+ flex: 1; /* fill remaining space */
+ overflow: auto;
+ margin: 6px 3px;
+}
+#infobox .status, #infobox .status li {
+ margin: 0;
+ padding: 0;
+ /* hide too large text unless you hover over it */
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+#infobox .status li:hover {
+ overflow: visible;
+ white-space: normal;
+}
+#infobox:not(.user-locked) .not-auto {
+ display: none;
+}
+#infobox .zoom-level:after {
+ content: "%";
+}
+/* user info */
+#infobox .user-info {
+ /* initially there is no user to show info about */
+ display: none;
+}
+#infobox .user-info .name {
+ margin: 0;
+ padding: 0;
+}
+#infobox .user-info .relations:empty:before {
+ content: "(none)";
+}
+/* style for svg contents */
+.node {
+ stroke: #fff;
+ stroke-width: 0.5px;
+}
+.node.spam {
+ fill: orange;
+}
+.node.ham {
+ fill: green;
+}
+.node.selected {
+ fill: red;
+}
+.link {
+ stroke: #999;
+ stroke-opacity: .6;
+ /* fill none to prevent arcs lines being too thick */
+ fill: none;
+}
+.arrow-head {
+ fill-opacity: .6;
+}
+</style>
+<body>
+
+<div id="infobox">
+ <!-- TODO: make this bar the only initiator of dragging -->
+ <div class="draggable"></div>
+ <div class="content">
+ <ul class="status">
+ <li>Total nodes: <span class="node-count"></span>
+ <li>Total edges: <span class="edge-count"></span>
+ <li>Zoom: <span class="zoom-level"></span> (scroll to zoom)
+ <li>User info does <span class="not-auto">not</span> follow the
+ mouse (double-click node to toggle)
+ </ul>
+ <div class="user-info">
+ <h2 class="name"></h2>
+ <p>
+ Tweets: <span class="tweet-count"></span>.<br>
+ Spam: <span class="spam-status"></span>.<br>
+ Relations (<span class="relations-count"></span>):
+ </p>
+ <ul class="relations"></ul>
+ </div>
+ </div>
+</div>
+
+<div id="map"></div>
+
+<script src="d3.js"></script>
+<script src="collision.js"></script>
+<script src="bubble.js"></script>
+</body>
+</html>
diff --git a/bubble.js b/bubble.js
new file mode 100644
index 0000000..6b735b3
--- /dev/null
+++ b/bubble.js
@@ -0,0 +1,440 @@
+/* jshint devel:true, browser:true */
+/* global d3, collisionTick */
+'use strict';
+
+/* settings */
+// whether to enable expensive features
+var I_GOT_MONEY = 0;
+// whether to ignore lonely users
+var KILL_LONERS = true;
+// 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 preprocess(data) {
+ // map userID to nodes
+ var users = {};
+ data.nodes.forEach(function (user, i) {
+ users[user.group] = user;
+ });
+ console.log('Initial nodes count:', data.nodes.length);
+ console.log('Initial edges count:', data.edges.length);
+
+ var ratelimit_count = 0, ratelimit_max = 10;
+ function ratelimit() {
+ return ratelimit_count <= ratelimit_max;
+ }
+ // filter away invalid edges
+ data.edges = data.edges.filter(function (link, i) {
+ var invalid = false;
+ if (!(link.source in users)) {
+ if (ratelimit()) console.warn('Dropping invalid source user',
+ link.source, 'at line', (i + 1), link);
+ invalid = true;
+ }
+ if (!(link.target in users)) {
+ if (ratelimit()) console.warn('Dropping invalid target user',
+ link.target, 'at line', (i + 1), link);
+ invalid = true;
+ }
+ if (link.source === link.target) {
+ if (ratelimit()) console.warn('Dropping self-referencing user',
+ link.target, 'at line', (i + 1), link);
+ invalid = true;
+ }
+ return !invalid;
+ });
+ if (ratelimit_count > ratelimit_max) {
+ console.log('Supressed', ratelimit_count, 'messages');
+ }
+ console.log('Valid edges count:', data.edges.length);
+
+ // find all related users by userID
+ var hasRelations = {};
+ data.edges.forEach(function (link) {
+ hasRelations[link.target] = 1;
+ hasRelations[link.source] = 1;
+ });
+
+ if (KILL_LONERS) {
+ var hasRelated = {};
+ data.nodes = data.nodes.filter(function (d) {
+ return d.group in hasRelations;
+ });
+ console.log('Nodes count (after dropping loners):', data.nodes.length);
+ }
+
+ // prepare data for force layout: map user IDs to indices
+ var userIds_indices = {};
+ data.nodes.forEach(function (user, i) {
+ users[user.group] = user;
+ userIds_indices[user.group] = i;
+ });
+ console.log('UserID to index map:', userIds_indices);
+
+ // change userID of relation edges to indices
+ data.edges.map(function (link) {
+ link.source = userIds_indices[link.source];
+ link.target = userIds_indices[link.target];
+ });
+}
+
+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);
+ 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');
+ 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) {
+ // only update on hover if no node is selected
+ if (selectedNode === null) {
+ updateInfobox(d, this);
+ }
+ });
+
+ // info panel for each user node
+ var userInfo = infoPane.select('.user-info');
+ 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')
+ .text(d.name);
+ userInfo.select('.tweet-count')
+ .text(d.tweetCount);
+ userInfo.select('.spam-status')
+ .text(d.isSpam ? 'SPAM' : 'ham');
+
+ var selfId = d.index;
+ var links = [];
+ force.links().forEach(function (edge) {
+ // insert related elements, assuming no self-references
+ if (edge.source.index === selfId) {
+ links.push({
+ direction: 'to',
+ node: edge.target
+ });
+ } else if (edge.target.index === selfId) {
+ links.push({
+ direction: 'from',
+ node: edge.source
+ });
+ }
+ });
+ userInfo.select('.relations-count')
+ .text(links.length);
+ var relations = userInfo.select('.relations')
+ .selectAll('li')
+ .data(links, function (d) {
+ // unique keys to group by direction and node (index)
+ return d.direction + ' ' + d.node.index;
+ });
+ relations.enter().append('li')
+ .text(function (d) {
+ return d.direction + ' ' + d.node.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');
+ // SHIT: d3js cannot handle drag with nested elements --> stutter!
+ // BREAKS TEXT SELECTION :(
+ infoPane //.select('.draggable')
+ .call(d3.behavior.drag()
+ .on('drag', function () {
+ 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,
+ related: [] // nodes that link to this
+ };
+ }),
+ // 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();
+if (/no-auto/.test(location.search)) {
+ var ticks = 0;
+ var PROFILE = 1;
+ // 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]
+ */
+} else {
+ run();
+}
diff --git a/collision.js b/collision.js
new file mode 100644
index 0000000..acaf728
--- /dev/null
+++ b/collision.js
@@ -0,0 +1,36 @@
+/* globals d3 */
+'use strict';
+// source: http://mbostock.github.io/d3/talk/20111018/collision.html
+function collisionTick(nodes) {
+ var q = d3.geom.quadtree(nodes),
+ i = 0,
+ n = nodes.length;
+
+ while (++i < n) {
+ q.visit(collide(nodes[i]));
+ }
+}
+
+function collide(node) {
+ var r = node.radius + 16,
+ nx1 = node.x - r,
+ nx2 = node.x + r,
+ ny1 = node.y - r,
+ ny2 = node.y + r;
+ return function(quad, x1, y1, x2, y2) {
+ if (quad.point && (quad.point !== node)) {
+ var x = node.x - quad.point.x,
+ y = node.y - quad.point.y,
+ l = Math.sqrt(x * x + y * y),
+ r = node.radius + quad.point.radius;
+ if (l < r) {
+ l = (l - r) / l * 0.5;
+ node.x -= x *= l;
+ node.y -= y *= l;
+ quad.point.x += x;
+ quad.point.y += y;
+ }
+ }
+ return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
+ };
+}