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 --- js/spam.js | 407 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 js/spam.js (limited to 'js/spam.js') 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(); +} -- cgit v1.2.1