/* jshint devel:true, browser:true */ /* global d3, collisionTick, preprocess */ '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 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 }); 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'); 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, 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(); // Set PROFILE=1 to enable profiling when using the button. var ticks = 0, PROFILE = 1; if (/no-auto/.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] */ } else { run(); }