From 7561200fd09d4e741f20ceb4dc6068fbb36e9ff7 Mon Sep 17 00:00:00 2001 From: Peter Wu Date: Tue, 20 May 2014 23:40:49 +0200 Subject: Initial commit of users/tweets D3 demo --- bubble.html | 139 +++++++++++++++++++ bubble.js | 440 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ collision.js | 36 +++++ 3 files changed, 615 insertions(+) create mode 100644 bubble.html create mode 100644 bubble.js create mode 100644 collision.js diff --git a/bubble.html b/bubble.html new file mode 100644 index 0000000..c0ed167 --- /dev/null +++ b/bubble.html @@ -0,0 +1,139 @@ + + + + +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/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; + }; +} -- cgit v1.2.1