civio/quienmanda.es

View on GitHub
app/assets/javascripts/viz/network-graph.js

Summary

Maintainability
F
6 days
Test Coverage
// We define $j instead of $ to avoid conflict with annotorious.js 
// (see https://learn.jquery.com/using-jquery-core/avoid-conflicts-other-libraries/)
$j = jQuery.noConflict();

function NetworkGraph(selector, infobox, undoBtn, redoBtn, historyParams) {

  var _this = this;

  // Basic D3.js SVG container setup
  var $cont = $j(selector),
      width = $cont.width(),
      height = $cont.height(),
      centerx = width / 2,
      centery = height / 2,
      linkDistance = height * 0.2,
      isFullscreen = false;

  var infobox = d3.select(infobox);

  // History to store actions over graph
  var history = new NetworkHistory(undoBtn, redoBtn);
  var historyArr = [];

  // I keep track of panning+zooming and use the 'drag' behaviour plus two buttons.
  // The mousewheel behaviour of the 'zoom' behaviour was annoying, and couldn't 
  // find a way of disabling it.
  var scale = 1;
  var viewport_origin_x = 0;
  var viewport_origin_y = 0;
  var viewport_x = 0;
  var viewport_y = 0;
  var viewport_dx = 0;
  var viewport_dy = 0;

  var svg = d3.select(selector).append("svg")
      .attr("width", width)
      .attr("height", height)
      .call(d3.behavior.drag().on("drag", onDrag).on("dragstart", onDragStart).on("dragend", onDragEnd))
      .append("g");   // Removing this breaks zooming/panning

  rescale();  // translate svg

  // Force layout configuration
  var force = d3.layout.force()
      .on("tick", tick)
      .charge(-linkDistance * 5)
      .gravity(0.02)
      .linkDistance(linkDistance)
      .linkStrength(0.6)
      .size([width, height]);

  // Node drag behaviour
  var drag = force.drag()
      .on("dragstart", onNodeDragStart)
      .on("dragend", onNodeDragEnd);
  var drag_x = 0;
  var drag_y = 0;

  // Visualization data
  var nodes = {};
  var links = {};
  var connectedNodes = {};  // Convenience data, to highlight neighbours

  // D3.js selectors, available for tick() method
  var nodeRootUrl,
      node,
      path,
      text;

  // Arrow marker
  svg.append("svg:defs")
    .append("svg:marker")
      .attr("id", 'relation')
      .attr("viewBox", "0 -5 15 10")
      .attr("refX", 20)
      .attr("refY", -0.5)
      .attr("markerWidth", 5)
      .attr("markerHeight", 5)
      .attr("orient", "auto")
    .append("svg:path")
      .attr("d", "M0,-5L10,0L0,5");

  // Create two separate containers for links and nodes, to make sure
  // nodes come always after nodes, even when loading data dynamically
  svg.append("g")
    .attr("id", "linksContainer");
  svg.append("g")
    .attr("id", "nodesContainer");


  /*** PUBLIC mehods ***/

  this.getCont = function(){
    return $cont;
  };

  // Load the root node
  this.loadRootNode = function(url) {
    nodeRootUrl = url;
    loadNode(url);
  };

  // Zoom controls
  this.zoomIn = function() {
    scale *= 1.2;
    rescale();
  };
  this.zoomOut = function() {
    scale /= 1.2;
    rescale();
  };
  this.zoomReset = function() {
    scale = 1;
    viewport_x = 0;
    viewport_y = 0;
    rescale();
  };

  // History Undo/redo
  this.historyUndo = function() {
    var item = history.undo();
    historyCall( item.action, item.args, true );
  };
  this.historyRedo = function() {
    var item = history.redo();
    historyCall( item.action, item.args, false );
  };
  this.getHistoryParams = function(){
    var str = '',
        done = history.getDone();

    // convert history.done array in a string like that:
    // "action1|argKey,argValue|argKey,argValue|argKey,argValue!action2|argKey,argValue!"
    done.forEach(function(d){
      str += d.action;
      for (var key in d.args) {
        if (d.args.hasOwnProperty(key)) {
          str += '¡'+key+','+d.args[key];
        }
      }
      str += '!';
    });

    // add scale if is not one
    if (scale !== 1) {
      str += 'scale¡s,'+scale+'!';
    }

    return encodeURIComponent(str);
  };

  function historyCall( action, args, undo ) {
    switch (action) {
      case 'nodeMove':
        nodeMove(args, undo);
        break;
      case 'nodeExpand':
        nodeExpand(args, undo);
        break;
      case 'nodeContract':
        nodeExpand(args, !undo);
        break;
      case 'viewportMove':
        viewportMove(args, undo);
        break;
      case 'scale':
        scale = +args.s;
        rescale();
        break;
    }
  }

  function setHistoryArray( params ) {
    var args;
    params = params.split('!');
    params.forEach(function(d){
      args = {};
      d = d.split('¡');
      d.forEach(function(e,i){
        if (i>0) {
          e = e.split(',');
          args[e[0]] = e[1];
        }
      });
      if(d[0]){
        historyArr.push({action:d[0],args:args});
      }
    });
  }

  function historySetup() {
    var d, url;
    while (historyArr.length > 0) {
      url = historyArr[0].args.url;
      if (url !== undefined && nodes[url] === undefined) {
        return; // We try to apply an action to an element that is not yet loaded
      } else {
        d = historyArr.shift();
        historyCall(d.action,d.args,false);
      }
    }
  }

  // Resize visualization
  this.resize = function() {

    width = $cont.width();
    height = $cont.height();

    viewport_origin_x = (width * 0.5) - centerx;
    viewport_origin_y = (height * 0.5) - centery;
    linkDistance = height * 0.2;

    // update svg size
    d3.select(selector).select('svg')
      .attr('width', width)
      .attr('height', height);

    rescale();
  };


  /*** PRIVATE mehods ***/

  // Update canvas after nodes are added
  function display() {
    // Create force layout
    force
        .nodes(d3.values(nodes))
        .links(d3.values(links))
        .start();

    // Relations
    path = svg.select("#linksContainer").selectAll(".link")
        .data(force.links(), function(d) { return d.id; });

    path.enter().append("svg:path")
        .attr("id", function(d){ return 'link-'+d.id; })
        .attr("class", "link")
        .on("mouseover", onLinkMouseOver)
        .on("mouseout", onLinkMouseOut)
        .attr("marker-end", function(d) { return "url(#relation)"; });

    // Nodes
    node = svg
        .select("#nodesContainer")
        .selectAll(".node")
        .data(force.nodes(), function(d) { return d.url; });

    node.enter().append("g")
      .call(drag)
      .call(displayNode)
        .attr("id", function(d){ return 'node'+d.url.replace(/\//g,'-'); })
        .attr("class", getNodeClass)
        .on('click', onNodeClick)
      .append("text")
        .attr("dx", 11)
        .attr("dy", ".35em")
        .text(function(d) { return d.name; });
  }

  // NODE METHODS

  // Load a node .json & add its related nodes & links
  function loadNode(url, posx, posy) {

    // If a position is given, preposition child nodes around that.
    // Otherwise just put them in the middle of the screen
    if(typeof(posx)==='undefined') posx = 0;
    if(typeof(posy)==='undefined') posy = 0;

    var spinner = showSpinner();

    // Adding '.json' is not strictly necessary, since Rails and jQuery will understand
    // the Content Type headers of the request. But the Rails cache is mixing up the 
    // HTML and JSON responses, so the quickest way of fixing that is making sure the
    // request URLs are different.
    $j.getJSON(url+'.json', function(data) {

      // Add the retrieved nodes to the network graph
      $j.each(data.nodes, function(key, node) {
        addNode(node, url, posx, posy);
      });

      // Add the retrieved links to the network graph
      $j.each(data.links, function(key, link) {
        addLink(link);
      });

      // Go through the relations of child nodes, looking for relations among them
      $j.each(data.child_links, function(key, link) {
        if ( nodes[link.source] == null ) {
          nodes[link.target]['expandable'] = true;
        } else if ( nodes[link.target] == null ) {
          nodes[link.source]['expandable'] = true;
        } else {
          addLink(link);
        }
      });

      spinner.stop();
      display();

      if (url === nodeRootUrl && historyParams !== undefined && historyParams !== '') {
        setHistoryArray( decodeURIComponent(historyParams) );
        historySetup();
      } else if(historyArr.length > 0) {  // Check if we have actions waiting to be called
        historySetup();
      }
    });
  }

  // Remove related nodes & its links
  function unloadNode(url) {
    $j.each(nodes, function(key,node) {
      // filter all nodes to get only nodes loadedBy url
      if (node.url !== url && node.url !== nodeRootUrl && node.loadedBy && node.loadedBy.indexOf(url) > -1) {
        // check if node has been loadedBy other nodes
        if (node.loadedBy.length < 2) {
          // Remove links from this node
          $j.each(links, function(key,link) {
              if (link.source.url == node.url || link.target.url == node.url){
                removeLink(link);
              }
          });
          removeNode(node);
        }
        // update node.loadedBy
        node.loadedBy.splice(node.loadedBy.indexOf(url), 1);
      }
    });

    // Update force after remove nodes & links
    force
      .nodes(d3.values(nodes))
      .links(d3.values(links))
      .start();
  }

  // Add a node
  function addNode(node, url, posx, posy) {
    presetNode(node, posx, posy);
    nodes[node.url] = nodes[node.url] || node;
    // update loadedBy array
    if (node.url !== url && node.url !== nodeRootUrl) {
      (nodes[node.url].loadedBy = (nodes[node.url].loadedBy || [])).push( url );
    }
  }

  // Remove a node
  function removeNode(node) {
    var nodeToDelete = getNodeByUrl(node.url);

    // Check if node to delete has child to remove recursively
    if( nodeToDelete.classed('expanded') ){
      unloadNode( node.url );
    }

    // Delete node
    nodeToDelete.remove();  // from DOM
    delete nodes[node.url]; // from data
  }

  // Display a node in canvas
  function displayNode(node) {
    node.append("circle")
      .on("mouseover", onNodeMouseOver)
      .on("mouseout", onNodeMouseOut)
      .attr("r", 9);

    // We add an image with a expand sign; will be visible only when applicable
    node.append("image")
      .attr("xlink:href", "/img/plus-sign.png")
      .attr("x", -8)
      .attr("y", -8)
      .attr("class", "expand-icon")
      .attr("width", 16)
      .attr("height", 16);
  }

  // Get the node class (root|expandable|expanded)
  function getNodeClass(node) {
    
    var nodeClass = node.root ?
                    "node root" :
                    (node['expandable'] ? "node expandable" :
                    (node['expanded'] ? "node expanded" : "node") );

    nodeClass += ' cat-'+node.group;

    return nodeClass;
  }

  function expandNode(node) {
    node['expandable'] = false;                // Not anymore expandable
    node['expanded'] = true;                   // Expanded    
    getNodeByUrl(node.url)                     // Update class & change image icon (less)
      .attr('class', getNodeClass)
      .select('image')
        .attr("xlink:href", "/img/less-sign.png");
    node.fixed = true;                         // Fix after 'exploding', feels better
    loadNode(node.url, node.x, node.y);        // add linked nodes
  }

  function contractNode(node) {
    node['expandable'] = true;
    node['expanded'] = false;
    getNodeByUrl(node.url)                     // Update class & change image icon (plus)
      .attr('class', getNodeClass)
      .select('image')
        .attr("xlink:href", "/img/plus-sign.png");
    node.fixed = false;
    unloadNode(node.url);                      // remove linked nodes
  }

  // When load a node, put the related nodes randomly in a circle around 
  // the existing one to avoid the dizzying start where all the nodes fly around.
  function presetNode(node, posx, posy) {
    if (node['root']) {
      node['fixed'] = true;
      node['x'] = posx;
      node['y'] = posy;
    } else {
      var angle = 2 * Math.PI * Math.random();
      node['x'] = posx + linkDistance * Math.sin(angle);
      node['y'] = posy + linkDistance * Math.cos(angle);
    }
  }

  // LINK METHODS

  // Add a link
  function addLink(link) {
    // Keep track of neighboring nodes
    connectedNodes[link.source + "," + link.target] = 1;

    link.source = nodes[link.source];
    link.target = nodes[link.target];
    links[link.id] = links[link.id] || link;
  }

  // Remove a link
  function removeLink(link) {
    d3.select('#link-'+link.id).remove(); // from DOM
    delete links[link.id];                // from data
  }

  // EVENT HANDLERS

  // Canvas drag handler
  function onDrag() {
    viewport_x += d3.event.dx;
    viewport_y += d3.event.dy;
    viewport_dx += d3.event.dx;
    viewport_dy += d3.event.dy;
    rescale();
    d3.event.sourceEvent.stopPropagation(); // silence other listeners
  }
  function onDragStart() {
    d3.select(svg.node().parentNode).style('cursor','move');
  }
  function onDragEnd() {
    if (viewport_dx === 0 && viewport_dy === 0){ return; } // Skip if viewport has no translation
    // add viewportMove action to history
    history.add({
      action: 'viewportMove',
      args: { x: viewport_dx, y:viewport_dy }
    });
    viewport_dx = viewport_dy = 0;
    d3.select(svg.node().parentNode).style('cursor','default');
  }

  // Node drag handlers
  function onNodeDragStart(d) {
    d3.event.sourceEvent.stopPropagation(); // silence other listeners
    drag_x = d.x;
    drag_y = d.y;
  }
  function onNodeDragEnd(d) {
    if (drag_x === d.x && drag_y === d.y){ return; } // Skip if has no translation
    
    // add nodeMove action to history
    history.add({
      action: 'nodeMove',
      args: { url:d.url, x:drag_x, y:drag_y, dx:drag_x-d.x, dy:drag_y-d.y, fixed:d.fixed }
    });
    // fix the node position when the node is dragged
    d.fixed = true;
  }

  // Node click handler
  function onNodeClick(d) {

    if (d3.event.defaultPrevented) return;  // click suppressed
  
    // Check if expand or contract
    if ( d['expandable'] ) {
      expandNode(d);
      // add nodeExpand action to history
      history.add({
        action: 'nodeExpand',
        args: { url:d.url }
      });

    } else if ( d['expanded'] ) {
      contractNode(d);
      // add nodeContract action to history
      history.add({
        action: 'nodeContract',
        args: { url:d.url }
      });
    }
  }

  // Node over/out handlers
  function onNodeMouseOver(d) {
    if (d3.select( d3.event.target.parentNode ).classed('root')) { return; }

    highlightRelatedNodes(d, 0.2);  // Fade-out non-neighbouring nodes

    // Display node information
    infobox.html('');
    renderNodeName(infobox, d);
    if ( d.description !== null ) {
      infobox.append('span')
        .text(' '+d.description+' ');
    }
  }
  function onNodeMouseOut(d) {
    if (d3.select( d3.event.target.parentNode ).classed('root')) { return; }

    highlightRelatedNodes(d, 1);   // Fade-in the whole graph
  }

  // Link over/out handlers
  function onLinkMouseOver(link) {
    highlightRelatedNodes(link.source, 0.2);  // Fade-out non-neighbouring nodes

    // Display basic relation information
    infobox.html('');
    renderNodeName(infobox, link.source);
    infobox.append('span')
      .attr('class', 'separator')
      .text(link.type);
    renderNodeName(infobox, link.target);

    // Display date information
    if ( link.at !== null ) {
      infobox.append('span')
        .attr('class', 'separator')
        .text('('+link.at+')');
    } else if ( link.from !== null || link.to !== null ) {
      infobox.append('span')
        .attr('class', 'separator')
        .text('('+(link.from||'')+' - '+(link.to||'')+')');
    }

    // Display sources
    if ( link.via.length > 0 ) {
      sources = infobox.append('span').attr('class', 'sources').text('Fuente: ');
      sources.selectAll('.via').data(link.via)
        .enter().append('a')
          .attr('target', '_blank')
          .attr('href', function(d) { return d; })
          .text(function(d, i) { return (i+1); });
    }
  }
  function onLinkMouseOut(link) {
    highlightRelatedNodes(link.source, 1);   // Fade-in the whole graph
  }

  // HISTORY METHODS

  function nodeMove(args, undo) {
    var node = nodes[args.url];
    node.x = node.px = (undo === true) ? +args.x : (+args.x) - (+args.dx);
    node.y = node.py = (undo === true) ? +args.y : (+args.y) - (+args.dy);
    node.fixed = (undo === true) ? args.fixed : true;
    force.start();  // Restart force layout to update position
  }

  function nodeExpand(args, undo) {
    if (undo === true) {
      contractNode( nodes[args.url] );
    } else {
      expandNode( nodes[args.url] );
    }
  }

  function viewportMove(args, undo) {
    args.x = +args.x;
    args.y = +args.y;
    if (undo === true) {
      viewport_x -= args.x;
      viewport_y -= args.y;
    } else {
      viewport_x += args.x;
      viewport_y += args.y;
    }
    rescale();
  }

  // AUXILIAR METHODS

  // Load spinner, using spin.js
  function showSpinner() {
    var opts = {
      lines: 12, // The number of lines to draw
      length: 15, // The length of each line
      width: 8, // The line thickness
      radius: 22, // The radius of the inner circle
      corners: 0.5, // Corner roundness (0..1)
      rotate: 59, // The rotation offset
      direction: 1, // 1: clockwise, -1: counterclockwise
      color: '#414141', // #rgb or #rrggbb or array of colors
      speed: 1, // Rounds per second
      trail: 63, // Afterglow percentage
      shadow: false, // Whether to render a shadow
      hwaccel: false, // Whether to use hardware acceleration
      className: 'spinner', // The CSS class to assign to the spinner
      zIndex: 2e9, // The z-index (defaults to 2000000000)
      top: 'auto', // Top position relative to parent in px
      left: 'auto' // Left position relative to parent in px
    };
    return new Spinner(opts).spin($cont[0]);
  }

  // Force layout iteration
  function tick() {
    path.attr("d", function(d) {
      var dx = d.target.x - d.source.x,
          dy = d.target.y - d.source.y,
          dr = Math.sqrt(dx * dx + dy * dy) * 1.5;
      return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
    });

    node.attr("transform", function(d) {
      return "translate(" + d.x + "," + d.y + ")";
    });
  }

  // Update translate/scale of canvas
  function rescale() {
    svg.attr("transform", "translate(" + (centerx + viewport_origin_x + viewport_x) + ","+ (centery + viewport_origin_y + viewport_y) +")scale("+scale+")");
  }

  // Add node label to infobox on hover
  function renderNodeName(parent, node) {
    parent.append('a')
      .attr('href', node.url)
      .attr('target', '_blank')
      .append('strong')
        .text(node.name)
      .append('i')
        .attr('class', 'icon-external-link');
  }

  // Highlighting related nodes on hover
  function highlightRelatedNodes(d, opacity) {
    node.style("stroke-opacity", function(o) {
      thisOpacity = isConnected(d, o) ? 1 : opacity;
      this.setAttribute('fill-opacity', thisOpacity);
      return thisOpacity;
    });

    d3.selectAll('.expand-icon')
      .style('opacity', opacity);

    path.style("stroke-opacity", function(o) {
      return o.source === d || o.target === d ? 1 : opacity;
    });

    if (d3.event.type == "mouseover")
      path.attr("marker-end", function(o) { return o.source === d || o.target === d ? "url(#relation)" : null; });
    else
      path.attr("marker-end", "url(#relation)");
  }

  // Check if two nodes are connected
  function isConnected(a, b) {
    return  connectedNodes[a.url + "," + b.url] ||
            connectedNodes[b.url + "," + a.url] ||
            a.url == b.url;
  }

  function getNodeByUrl(url) {
    return d3.select('#node'+url.replace(/\//g,'-'));
  }
}