summaryrefslogtreecommitdiff
path: root/js/spam.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/spam.js')
-rw-r--r--js/spam.js407
1 files changed, 407 insertions, 0 deletions
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();
+}