/**
 * Bind polyfill from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind#Compatibility
 */
  if (!Function.prototype.bind) {
    Function.prototype.bind = function (oThis) {
      if (typeof this !== "function") {
        // closest thing possible to the ECMAScript 5 internal IsCallable function
        throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
      }

      var aArgs = Array.prototype.slice.call(arguments, 1),
      fToBind = this,
      fNOP = function () {},
      fBound = function () {
        return fToBind.apply(this instanceof fNOP && oThis
                             ? this
                             : oThis,
                             aArgs.concat(Array.prototype.slice.call(arguments)));
      };

      fNOP.prototype = this.prototype;
      fBound.prototype = new fNOP();

      return fBound;
    };
  }

var DEPTH_HEIGHT = 100;
var DEPTH_WIDTH = 140;

/**
 * Based off of http://mbostock.github.io/d3/talk/20111018/tree.html by Mike Bostock (@mbostock)
 */
function ImageHistoryTree(namespace, name, images, formatComment, formatTime, formatCommand) {
  /**
   * The namespace of the repo.
   */
  this.repoNamespace_ = namespace;

  /**
   * The name of the repo.
   */
  this.repoName_ = name;

  /**
   * The images to display.
   */
  this.images_ = images;

  /**
   * Method to invoke to format a comment for an image.
   */
  this.formatComment_ = formatComment;

  /**
   * Method to invoke to format the time for an image.
   */
  this.formatTime_ = formatTime;

  /**
   * Method to invoke to format the command for an image.
   */
  this.formatCommand_ = formatCommand;

  /**
   * The current tag (if any).
   */
  this.currentTag_ = null;

  /**
   * The current image (if any).
   */
  this.currentImage_ = null;

  /**
   * The currently highlighted node (if any).
   */
  this.currentNode_ = null;

  /**
   * Counter for creating unique IDs.
   */
  this.idCounter_ = 0;
}


/**
 * Calculates the dimensions of the tree.
 */
ImageHistoryTree.prototype.calculateDimensions_ = function(container) {
  var cw = Math.max(document.getElementById(container).clientWidth, this.maxWidth_ * DEPTH_WIDTH);
  var ch = this.maxHeight_ * (DEPTH_HEIGHT + 10);

  var margin = { top: 40, right: 20, bottom: 20, left: 80 };
  var m = [margin.top, margin.right, margin.bottom, margin.left];
  var w = cw - m[1] - m[3];
  var h = ch - m[0] - m[2];

  return {
    'w': w,
    'h': h,
    'm': m,
    'cw': cw,
    'ch': ch
  };
};


ImageHistoryTree.prototype.setupOverscroll_ = function() {
  var container = this.container_;
  var that = this;
  var overscroll = $('#' + container).overscroll();

  overscroll.on('overscroll:dragstart', function() {
    $(that).trigger({
      'type': 'hideTagMenu'
    });

    $(that).trigger({
      'type': 'hideImageMenu'
    });
  });

  overscroll.on('scroll', function() {
    $(that).trigger({
      'type': 'hideTagMenu'
    });

    $(that).trigger({
      'type': 'hideImageMenu'
    });
  });
};


/**
 * Updates the dimensions of the tree.
 */
ImageHistoryTree.prototype.updateDimensions_ = function() {
  var container = this.container_;
  var dimensions = this.calculateDimensions_(container);
  if (!dimensions) { return; }

  var m = dimensions.m;
  var w = dimensions.w;
  var h = dimensions.h;
  var cw = dimensions.cw;
  var ch = dimensions.ch;

  // Set the height of the container so that it never goes offscreen.
  if (!$('#' + container).removeOverscroll) { return; }

  $('#' + container).removeOverscroll();
  var viewportHeight = $(window).height();
  var boundingBox = document.getElementById(container).getBoundingClientRect();
  document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 150) + 'px';

  this.setupOverscroll_();

  // Update the tree.
  var rootSvg = this.rootSvg_;
  var tree = this.tree_;
  var vis = this.vis_;


  var ow = w + m[1] + m[3];
  var oh = h + m[0] + m[2];
  rootSvg
    .attr("width", ow)
    .attr("height", oh)
    .attr("style", "width: " + ow + "px; height: " + oh + "px");

  tree.size([w, h]);
  vis.attr("transform", "translate(" + m[3] + "," + m[0] + ")");

  return dimensions;
};


/**
 * Draws the tree.
 */
ImageHistoryTree.prototype.draw = function(container) {
  // Build the root of the tree.
  var result = this.buildRoot_();
  this.maxWidth_ = result['maxWidth'];
  this.maxHeight_ = result['maxHeight'];

  // Save the container.
  this.container_ = container;

  if (!$('#' + container)[0]) {
    this.container_ = null;
    return;
  }

  // Create the tree and all its components.
  var tree = d3.layout.tree()
    .separation(function() { return 2; });

  var diagonal = d3.svg.diagonal()
    .projection(function(d) { return [d.x, d.y]; });

  var rootSvg = d3.select("#" + container).append("svg:svg")
    .attr("class", "image-tree");

  var vis = rootSvg.append("svg:g");
  var formatComment = this.formatComment_;
  var formatTime = this.formatTime_;
  var formatCommand = this.formatCommand_;

  var tip = d3.tip()
    .attr('class', 'd3-tip')
    .offset([-1, 24])
    .direction('e')
    .html(function(d) {
      var html = '';
      if (d.virtual) {
        return d.name;
      }

      if (d.collapsed) {
        for (var i = 1; i < d.encountered.length; ++i) {
          html += '<span>' + d.encountered[i].image.id.substr(0, 12) + '</span>';
          html += '<span class="created">' + formatTime(d.encountered[i].image.created) + '</span>';
        }
        return html;
      }

      if (!d.image) {
        return '(This repository is empty)';
      }

      if (d.image.comment) {
        html += '<span class="comment">' + formatComment(d.image.comment) + '</span>';
      }
      if (d.image.command && d.image.command.length) {
        html += '<span class="command info-line"><i class="fa fa-terminal"></i>' + formatCommand(d.image) + '</span>';
      }
      html += '<span class="created info-line"><i class="fa fa-calendar"></i>' + formatTime(d.image.created) + '</span>';

      var tags = d.tags || [];
      html += '<span class="tooltip-tags tags">';
      for (var i = 0; i < tags.length; ++i) {
        var tag = tags[i];
        var kind = 'default';
        html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '">' + tag + '</span>';
      }
      html += '</span>';

      return html;
    })

  vis.call(tip);

  // Save all the state created.
  this.diagonal_ = diagonal;
  this.vis_ = vis;
  this.rootSvg_ = rootSvg;
  this.tip_ = tip;
  this.tree_ = tree;

  // Update the dimensions of the tree.
  var dimensions = this.updateDimensions_();
  if (!dimensions) {
    return this;
  }

  // Populate the tree.
  this.root_.x0 = dimensions.cw / 2;
  this.root_.y0 = 0;

  this.setTag_(this.currentTag_);
  this.setupOverscroll_();

  return this;
};


/**
 * Redraws the image history to fit the new size.
 */
ImageHistoryTree.prototype.notifyResized = function() {
  this.updateDimensions_();
  this.update_(this.root_);
};


/**
 * Sets the current tag displayed in the tree.
 */
ImageHistoryTree.prototype.setTag = function(tagName) {
  this.setTag_(tagName);
};


/**
 * Sets the current image displayed in the tree.
 */
ImageHistoryTree.prototype.setImage = function(imageId) {
  this.setImage_(imageId);
};


/**
 * Updates the highlighted path in the tree.
 */
ImageHistoryTree.prototype.setHighlightedPath_ = function(image) {
  if (this.currentNode_) {
    this.markPath_(this.currentNode_, false);
  }

  var imageByDockerId = this.imageByDockerId_;
  var currentNode = imageByDockerId[image.id];
  if (currentNode) {
    this.markPath_(currentNode, true);
    this.currentNode_ = currentNode;
  }
};


/**
 * Returns the ancestors of the given image.
 */
ImageHistoryTree.prototype.getAncestors_ = function(image) {
  var ancestorsString = image.ancestors;

  // Remove the starting and ending /s.
  ancestorsString = ancestorsString.substr(1, ancestorsString.length - 2);

  // Split based on /.
  ancestors = ancestorsString.split('/');
  return ancestors;
};


/**
 * Sets the current tag displayed in the tree and raises the event that the tag
 * was changed.
 */
ImageHistoryTree.prototype.changeTag_ = function(tagName) {
  $(this).trigger({
    'type': 'tagChanged',
    'tag': tagName
  });
  this.setTag_(tagName);
};


/**
 * Sets the current image displayed in the tree and raises the event that the image
 * was changed.
 */
ImageHistoryTree.prototype.changeImage_ = function(imageId) {
  $(this).trigger({
    'type': 'imageChanged',
    'image': this.findImage_(function(image) { return image.id == imageId; })
  });
  this.setImage_(imageId);
};


/**
 * Expands the given collapsed node in the tree.
 */
ImageHistoryTree.prototype.expandCollapsed_ = function(imageNode) {
  var index = imageNode.parent.children.indexOf(imageNode);
  if (index < 0 || imageNode.encountered.length < 2) {
    return;
  }

  // Note: we start at 1 since the 0th encountered node is the parent.
  imageNode.parent.children.splice(index, 1, imageNode.encountered[1]);
  this.maxHeight_ = this.determineMaximumHeight_(this.root_);
  this.update_(this.root_);
  this.updateDimensions_();
};


/**
 * Returns the level of the node in the tree. Recursively computes and updates
 * if necessary.
 */
ImageHistoryTree.prototype.calculateLevel_ = function(node) {
  if (node['level'] != null) {
    return node['level'];
  }

  if (node['parent'] == null) {
    return node['level'] = 0;
  }

  return node['level'] = (this.calculateLevel_(node['parent']) + 1);
};


/**
 * Builds the root node for the tree.
 */
ImageHistoryTree.prototype.buildRoot_ = function() {
  // Build the formatted JSON block for the tree. It must be of the form:
  //  {
  //    "name": "...",
  //    "children": [...]
  //  }
  var formatted = {"name": "No images found"};

  // Build a node for each image.
  var imageByDockerId = {};
  for (var i = 0; i < this.images_.length; ++i) {
    var image = this.images_[i];

    // Skip images that are currently uploading.
    if (image.uploading) { continue; }

    var imageNode = {
      "name": image.id.substr(0, 12),
      "children": [],
      "image": image,
      "tags": image.tags,
      "level": null
    };
    imageByDockerId[image.id] = imageNode;
  }
  this.imageByDockerId_ = imageByDockerId;

  // For each node, attach it to its immediate parent. If there is no immediate parent,
  // then the node is the root.
  var roots = [];
  var nodeCountsByLevel = {};
  for (var i = 0; i < this.images_.length; ++i) {
    var image = this.images_[i];

    // Skip images that are currently uploading.
    if (image.uploading) { continue; }

    var imageNode = imageByDockerId[image.id];
    var ancestors = this.getAncestors_(image);
    var immediateParent = ancestors[ancestors.length - 1];
    var parent = imageByDockerId[immediateParent];
    if (parent) {
      // Add a reference to the parent. This makes walking the tree later easier.
      imageNode.parent = parent;
      parent.children.push(imageNode);
    } else {
      imageNode['level'] = 0;
      roots.push(imageNode);
    }
  }

  // Calculate each node's level.
  for (var i = 0; i < this.images_.length; ++i) {
    var image = this.images_[i];

    // Skip images that are currently uploading.
    if (image.uploading) { continue; }

    var imageNode = imageByDockerId[image.id];
    var level = this.calculateLevel_(imageNode);
    if (nodeCountsByLevel[level] == null) {
      nodeCountsByLevel[level] = 1;
    } else {
      nodeCountsByLevel[level]++;
    }
  }

  // If there are multiple root nodes, then there is at least one branch without shared
  // ancestry and we use the virtual node. Otherwise, we use the root node found.
  var root = {
    'name': '',
    'children': roots,
    'virtual': true
  };

  if (roots.length == 1) {
    root = roots[0];
  }

  // Determine the maximum number of nodes at a particular level. This is used to size
  // the width of the tree properly.
  var maxChildCount = 0;
  var maxChildHeight = 0;
  Object.keys(nodeCountsByLevel).forEach(function(key){
    maxChildCount = Math.max(maxChildCount, nodeCountsByLevel[key]);
    maxChildHeight = Math.max(maxChildHeight, key);
  });

  // Compact the graph so that any single chain of three (or more) images becomes a collapsed
  // section. We only do this if the max width is > 1 (since for a single width tree, no long
  // chain will hide a branch).
  if (maxChildCount > 1) {
    this.collapseNodes_(root);
  }

  // Determine the maximum height of the tree, with collapsed nodes.
  var maxCollapsedHeight = this.determineMaximumHeight_(root);

  // Finally, set the root node and return.
  this.root_ = root;

  return {
    'maxWidth': maxChildCount + 1,
    'maxHeight': maxCollapsedHeight
  };
};


/**
 * Determines the height of the tree at its longest chain.
 */
ImageHistoryTree.prototype.determineMaximumHeight_ = function(node) {
  var maxHeight = 0;
  if (node.children) {
    for (var i = 0; i < node.children.length; ++i) {
      maxHeight = Math.max(this.determineMaximumHeight_(node.children[i]), maxHeight);
    }
  }
  return maxHeight + 1;
};


/**
 * Collapses long single chains of nodes (3 or more) into single nodes to make the graph more
 * compact.
 */
ImageHistoryTree.prototype.collapseNodes_ = function(node) {
  if (node.children.length == 1) {
    // Keep searching downward until we find a node with more than a single child.
    var current = node;
    var previous = node;
    var encountered = [];
    while (current.children.length == 1) {
      encountered.push(current);
      previous = current;
      current = current.children[0];
    }

    if (encountered.length >= 3) {
      // Collapse the node.
      var collapsed = {
        "name": '(' + (encountered.length - 1) + ' images)',
        "children": [current],
        "collapsed": true,
        "encountered": encountered
      };
      node.children = [collapsed];

      // Update the parent relationships.
      collapsed.parent = node;
      current.parent = collapsed;
      return;
    }
  }

  for (var i = 0; i < node.children.length; ++i) {
    this.collapseNodes_(node.children[i]);
  }
};


/**
 * Determines the maximum child count for the node and its children.
 */
ImageHistoryTree.prototype.determineMaximumChildCount_ = function(node) {
  var children = node.children;
  var myLevelCount = children.length;
  var nestedCount = 0;

  for (var i = 0; i < children.length; ++i) {
    nestedCount += children[i].children.length;
  }

  return Math.max(myLevelCount, nestedCount);
};


/**
 * Finds the image where the checker function returns true and returns it or null
 * if none.
 */
ImageHistoryTree.prototype.findImage_ = function(checker) {
  for (var i = 0; i < this.images_.length; ++i) {
    var image = this.images_[i];
    if (checker(image)) {
      return image;
    }
  }

  return null;
};


/**
 * Marks the full node path from the given starting node on whether it is highlighted.
 */
ImageHistoryTree.prototype.markPath_ = function(startingNode, isHighlighted) {
  var currentNode = startingNode;
  currentNode.current = isHighlighted;
  while (currentNode != null) {
    currentNode.highlighted = isHighlighted;
    currentNode = currentNode.parent;
  }
};


/**
 * Sets the current tag displayed in the tree.
 */
ImageHistoryTree.prototype.setTag_ = function(tagName) {
  if (tagName == this.currentTag_) {
    return;
  }

  var imageByDockerId = this.imageByDockerId_;

  // Save the current tag.
  var previousTagName = this.currentTag_;
  this.currentTag_ = tagName;
  this.currentImage_ = null;

  // Update the path.
  var tagImage = this.findImage_(function(image) {
    return image.tags.indexOf(tagName || '(no tag specified)') >= 0;
  });

  if (tagImage) {
    this.setHighlightedPath_(tagImage);
  }

  // Ensure that the children are in the correct order.
  for (var i = 0; i < this.images_.length; ++i) {
    var image = this.images_[i];

    // Skip images that are currently uploading.
    if (image.uploading) { continue; }

    var imageNode = this.imageByDockerId_[image.id];
    var ancestors = this.getAncestors_(image);
    var immediateParent = ancestors[ancestors.length - 1];
    var parent = imageByDockerId[immediateParent];
    if (parent && imageNode.highlighted) {
      var arr = parent.children;
      if (parent._children) {
        arr = parent._children;
      }

      if (arr[0] != imageNode) {
        var index = arr.indexOf(imageNode);
        if (index > 0) {
          arr.splice(index, 1);
          arr.splice(0, 0, imageNode);
        }
      }
    }
  }

  // Update the tree.
  this.update_(this.root_);
};


/**
 * Sets the current image highlighted in the tree.
 */
ImageHistoryTree.prototype.setImage_ = function(imageId) {
  // Find the new current image.
  var newImage = this.findImage_(function(image) {
    return image.id == imageId;
  });

  if (newImage == this.currentImage_) {
    return;
  }

  this.setHighlightedPath_(newImage);
  this.currentImage_ = newImage;
  this.currentTag_ = null;
  this.update_(this.root_);
};


/**
 * Updates the tree in response to a click on a node.
 */
ImageHistoryTree.prototype.update_ = function(source) {
  var tree = this.tree_;
  var vis = this.vis_;
  var diagonal = this.diagonal_;
  var tip = this.tip_;
  var currentTag = this.currentTag_;
  var currentImage = this.currentImage_;
  var repoNamespace = this.repoNamespace_;
  var repoName = this.repoName_;
  var maxHeight = this.maxHeight_;

  var that = this;

  var duration = 500;

  // Compute the new tree layout.
  var nodes = tree.nodes(this.root_).reverse();

  // Normalize for fixed-depth.
  nodes.forEach(function(d) { d.y = (maxHeight - d.depth - 1) * DEPTH_HEIGHT; });

  // Update the nodes...
  var node = vis.selectAll("g.node")
    .data(nodes, function(d) { return d.id || (d.id = that.idCounter_++); });

  // Enter any new nodes at the parent's previous position.
  var nodeEnter = node.enter().append("svg:g")
    .attr("class", "node")
    .attr("transform", function(d) { return "translate(" + source.x0 + "," + source.y0 + ")"; });

  nodeEnter.append("svg:circle")
    .attr("r", 1e-6)
    .style("fill", function(d) { return d._children ? "lightsteelblue" : "#fff"; })
    .on("click", function(d) { that.toggle_(d); that.update_(d); });

  // Create the group that will contain the node name and its tags.
  var g = nodeEnter.append("svg:g").style("fill-opacity", 1e-6);

  // Add the repo ID.
  g.append("svg:text")
    .attr("x", function(d) { return d.children || d._children ? -10 : 10; })
    .attr("dy", ".35em")
    .attr("text-anchor", function(d) { return d.children || d._children ? "end" : "start"; })
    .text(function(d) { return d.name; })
    .on("click", function(d) {
      if (d.image) { that.changeImage_(d.image.id); }
      if (d.collapsed) { that.expandCollapsed_(d); }
    })
    .on('mouseover', tip.show)
    .on('mouseout', tip.hide)
    .on("contextmenu", function(d, e) {
      d3.event.preventDefault();

      if (d.image) {
        $(that).trigger({
          'type': 'showImageMenu',
          'image': d.image.id,
          'clientX': d3.event.clientX,
          'clientY': d3.event.clientY
        });
      }
    });

  nodeEnter.selectAll("tags")
    .append("svg:text")
    .text("bar");

  // Create the foreign object to hold the tags (if any).
  var fo = g.append("svg:foreignObject")
    .attr("class", "fo")
    .attr("x", 14)
    .attr("y", 12)
    .attr("width", 110)
    .attr("height", DEPTH_HEIGHT - 20);

  // Add the tags container.
  fo.append('xhtml:div')
    .attr("class", "tags")
    .style("display", "none");

  // Translate the foreign object so the tags are under the ID.
  fo.attr("transform", function(d, i) {
    return "translate(" + [-130, 0 ] + ")";
  });

  // Transition nodes to their new position.
  var nodeUpdate = node.transition()
    .duration(duration)
    .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; });

  // Update the node circle.
  nodeUpdate.select("circle")
    .attr("r", 4.5)
    .attr("class", function(d) {
      return (d._children ? "closed " : "open ") + (d.current ? "current " : "") + (d.highlighted ? "highlighted " : "");
    })
    .style("fill", function(d) {
      if (d.current) {
        return "";
      }
      return d._children ? "lightsteelblue" : "#fff";
    });

  // Update the repo text.
  nodeUpdate.select("text")
    .attr("class", function(d) {
      if (d.collapsed) {
        return 'collapsed';
      }
      if (d.virtual) {
        return 'virtual';
      }
      if (!currentImage) {
	return '';
      }
      return d.image.id == currentImage.id ? 'current' : '';
    });

  // Ensure that the node is visible.
  nodeUpdate.select("g")
    .style("fill-opacity", 1);

  // Update the tags.
  node.select(".tags")
    .html(function(d) {
      if (!d.tags) {
        return '';
      }

      var html = '<div style="width: ' + DEPTH_HEIGHT + 'px">';
      for (var i = 0; i < d.tags.length; ++i) {
        var tag = d.tags[i];
        var kind = 'default';
        if (tag == currentTag) {
          kind = 'success';
        }
        html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '" title="' + tag + '" style="max-width: ' + DEPTH_HEIGHT + 'px">' + tag + '</span>';
      }
      html += '</div>';
      return html;
    });

  // Listen for click events on the labels.
  node.selectAll(".tag")
    .on("click", function(d, e) {
      var tag = this.getAttribute('data-tag');
      if (tag) {
        that.changeTag_(tag);
      }
    })
    .on("contextmenu", function(d, e) {
      d3.event.preventDefault();

      var tag = this.getAttribute('data-tag');
      if (tag) {
        $(that).trigger({
          'type': 'showTagMenu',
          'tag': tag,
          'clientX': d3.event.clientX,
          'clientY': d3.event.clientY
        });
      }
    });

  // Ensure the tags are visible.
  nodeUpdate.select(".tags")
    .style("display", "")

  // There is a bug in Chrome which sometimes prevents the foreignObject from redrawing. To that end,
  // we force a redraw by adjusting the height of the object ever so slightly.
  nodeUpdate.select(".fo")
    .attr('height', function(d) {
      return DEPTH_HEIGHT - 20 + Math.random() / 10;
    });

  // Transition exiting nodes to the parent's new position.
  var nodeExit = node.exit().transition()
    .duration(duration)
    .attr("transform", function(d) { return "translate(" + source.x + "," + source.y + ")"; })
    .remove();

  nodeExit.select("circle")
    .attr("r", 1e-6);

  nodeExit.select(".tags")
    .style("display", "none");

  nodeExit.select("g")
    .style("fill-opacity", 1e-6);

  // Update the links...
  var link = vis.selectAll("path.link")
    .data(tree.links(nodes), function(d) { return d.target.id; });

  // Enter any new links at the parent's previous position.
  link.enter().insert("svg:path", "g")
    .attr("class", function(d) {
      var isHighlighted = d.target.highlighted;
      return "link " + (isHighlighted ? "highlighted": "");
    })
    .attr("d", function(d) {
      var o = {x: source.x0, y: source.y0};
      return diagonal({source: o, target: o});
    })
    .transition()
    .duration(duration)
    .attr("d", diagonal);

  // Transition links to their new position.
  link.transition()
    .duration(duration)
    .attr("d", diagonal)
    .attr("class", function(d) {
      var isHighlighted = d.target.highlighted;
      return "link " + (isHighlighted ? "highlighted": "");
    });

  // Transition exiting nodes to the parent's new position.
  link.exit().transition()
    .duration(duration)
    .attr("d", function(d) {
      var o = {x: source.x, y: source.y};
      return diagonal({source: o, target: o});
    })
    .remove();

  // Stash the old positions for transition.
  nodes.forEach(function(d) {
    d.x0 = d.x;
    d.y0 = d.y;
  });
};


/**
 * Toggles children of a node.
 */
ImageHistoryTree.prototype.toggle_ = function(d) {
  if (d.children) {
    d._children = d.children;
    d.children = null;
  } else {
    d.children = d._children;
    d._children = null;
  }
};


/**
 * Disposes of the tree.
 */
ImageHistoryTree.prototype.dispose = function() {
  var container =  this.container_ ;
  $('#' + container).removeOverscroll();
  $('#' + container).html('');
};

////////////////////////////////////////////////////////////////////////////////

/**
 * Based off of http://bl.ocks.org/mbostock/1093025 by Mike Bostock (@mbostock)
 */
function FileTreeBase() {
  /**
   * Counter for creating unique IDs.
   */
  this.idCounter_ = 0;

  /**
   * Map from file path to associated tree node.
   */
  this.nodeMap_ = {};
}


/**
 * Calculates the dimensions of the tree.
 */
FileTreeBase.prototype.calculateDimensions_ = function(container) {
  var containerElm = document.getElementById(container);
  if (!containerElm) {
    return null;
  }

  var cw = containerElm ? containerElm.clientWidth : 1200;
  var barHeight = 20;
  var ch = (this.getNodesHeight() * barHeight) + 40;

  var margin = { top: 40, right: 00, bottom: 20, left: 20 };
  var m = [margin.top, margin.right, margin.bottom, margin.left];
  var w = Math.max(0, cw - m[1] - m[3]);
  var h = Math.max(0, ch - m[0] - m[2]);

  var barWidth = Math.max(0, cw * 0.8 - m[1] - m[3]);

  return {
    'w': w,
    'h': h,
    'm': m,
    'cw': cw,
    'ch': ch,
    'bw': barWidth,
    'bh': barHeight
  };
};


/**
 * Updates the dimensions of the tree.
 */
FileTreeBase.prototype.updateDimensions_ = function() {
  if (!this.rootSvg_) { return; }

  var container = this.container_;
  var dimensions = this.calculateDimensions_(container);
  if (!dimensions) { return; }

  var w = dimensions.w;
  var h = dimensions.h;
  var m = dimensions.m;

  // Update the tree.
  var rootSvg = this.rootSvg_;
  var tree = this.tree_;
  var vis = this.vis_;

  rootSvg
    .attr("width", w + m[1] + m[3])
    .attr("height", h + m[0] + m[2]);

  tree.size([h, 100]);
  vis.attr("transform", "translate(" + m[3] + "," + m[0] + ")");

  this.barWidth_ = dimensions.bw;
  this.barHeight_ = dimensions.bh;

  return dimensions;
};


/**
 * Redraws the image change tree to fit the new size.
 */
FileTreeBase.prototype.notifyResized = function() {
  this.updateDimensions_();
  this.update_(this.root_);
};


/**
 * Disposes of the tree.
 */
FileTreeBase.prototype.dispose = function() {
  var container =  this.container_ ;
  document.getElementById(container).innerHTML = '';
};


/**
 * Draws the tree.
 */
FileTreeBase.prototype.draw = function(container) {
  this.container_ = container;

  var dimensions = this.calculateDimensions_(container);
  if (!dimensions) { return; }

  var w = dimensions.w;
  var h = dimensions.h;
  var m = dimensions.m;

  this.barWidth_ = dimensions.bw;
  this.barHeight_ = dimensions.bh;

  var tree = d3.layout.tree()
    .size([h, 100]);

  var diagonal = d3.svg.diagonal()
    .projection(function(d) { return [d.y, d.x]; });

  var rootSvg = d3.select("#" + container).append("svg:svg")
    .attr('class', 'file-tree-base')
    .attr("width", w)
    .attr("height", h);

  var vis = rootSvg
    .append("svg:g")
    .attr("transform", "translate(" + m[3] + "," + m[0] + ")");

  this.rootSvg_ = rootSvg;
  this.tree_ = tree;
  this.diagonal_ = diagonal;
  this.vis_ = vis;

  this.populateAndDraw_();
};


/**
 * Populates the tree and then draws it.
 */
FileTreeBase.prototype.populateAndDraw_ = function() {
  // Build all the nodes for the file tree.
  this.buildAllNodes();

  // Sort the children of each node so that folders are on top.
  var sortByName = function (a, b) {
    if (a.name > b.name) {
      return 1;
    }
    if (a.name < b.name) {
      return -1;
    }
    return 0;
  };

  var sortFunction = function(a, b) {
    var hasA = a._children.length > 0;
    var hasB = b._children.length > 0;
    if (hasA == hasB) {
      // Sort alphabetically.
      return sortByName(a, b);
    }
    if (hasA) { return -1; }
    return 1;
  };

  for (var path in this.nodeMap_) {
    if (!this.nodeMap_.hasOwnProperty(path) || !this.nodeMap_[path]._children) {
      continue;
    }

    this.nodeMap_[path]._children.sort(sortFunction);
  }

  this.root_ = this.nodeMap_[''];
  this.root_.x0 = 0;
  this.root_.y0 = 0;
  this.toggle_(this.root_);
  this.update_(this.root_);
};


/**
 * Builds all the nodes in the tree.
 */
FileTreeBase.prototype.buildNodes_ = function(path) {
  var parts = path.split('/');
  for (var i = 0; i < parts.length; ++i) {
    var currentPath = parts.slice(0, i + 1).join('/');
    if (!this.nodeMap_[currentPath]) {
      this.nodeMap_[currentPath] = { 'name': parts[i] || '/', 'path': currentPath, '_children': [] };

      if (currentPath.length > 0) {
        var parentPath = parts.slice(0, i).join('/');
        var parent = this.buildNodes_(parentPath);
        parent._children.push(this.nodeMap_[currentPath]);
      }
    }
  }
  return this.nodeMap_[path];
};


/**
 * Calculates the count of visible nodes.
 */
FileTreeBase.prototype.getVisibleCount_ = function(node) {
  if (node.children) {
    var count = 1;
    for (var  i = 0; i < node.children.length; ++i) {
      count += this.getVisibleCount_(node.children[i]);
    }
    return count;
  }
  return 1;
};


/**
 * Calculates the height for the container.
 */
FileTreeBase.prototype.getContainerHeight_ = function() {
  var dimensions = this.calculateDimensions_(this.container_);
  if (!dimensions) { return; }

  var barHeight = this.barHeight_;
  var height = (this.getVisibleCount_(this.root_) * (barHeight + 2));
  return height + dimensions.m[0] + dimensions.m[2];
};


/**
 * Updates the tree starting at the given source node.
 */
FileTreeBase.prototype.update_ = function(source) {
  var that = this;
  var tree = this.tree_;
  var vis = this.vis_;
  var svg = this.rootSvg_;
  var diagonal = this.diagonal_;
  var barWidth = this.barWidth_;
  var barHeight = this.barHeight_;

  var duration = 400;

  var color = function(d) {
    if (d.kind) {
      return '';
    }
    return d._children ? "#E9E9E9" : "#c6dbef";
  };

  // Update the height of the container and the SVG.
  var containerElm = document.getElementById(this.container_);
  if (!containerElm) {
    return;
  }

  containerElm.style.height = this.getContainerHeight_() + 'px';
  svg.attr('height', this.getContainerHeight_());

  // Compute the flattened node list.
  var nodes = tree.nodes(this.root_);

  // Compute the "layout".
  nodes.forEach(function(n, i) {
    n.x = i * barHeight;
  });

  // Update the nodes...
  var node = vis.selectAll("g.node")
    .data(nodes, function(d) { return d.id || (d.id = that.idCounter_++); });

  var nodeEnter = node.enter().append("svg:g")
    .attr("class", function(d) {
      return "node " + (d.kind ? d.kind : 'folder');
    })
    .attr("transform", function(d) { return "translate(" + source.y0 + "," + source.x0 + ")"; })
    .style("opacity", 1e-6);

  // Enter any new nodes at the parent's previous position.
  nodeEnter.append("svg:rect")
    .attr("class", "main-rect")
    .attr("y", -barHeight / 2)
    .attr("height", barHeight)
    .attr("width", barWidth)
    .style("fill", color)
    .on("click", function(d) {
      if (d.kind) {
        $(that).trigger({
          'type': 'fileClicked',
          'path': d.path
        });
        return;
      }
      that.toggle_(d);
      that.update_(source);
    });

  nodeEnter.append("svg:text")
    .attr("dy", 3.5)
    .attr("dx", 5.5 + 18)
    .text(function(d) { return d.name; });

  var body = nodeEnter.append('svg:foreignObject')
    .attr("class", "fo")
    .attr("width", 18)
    .attr("height", barHeight)
    .append('xhtml:body');

  body.append('div')
    .attr('class', 'node-icon');

  // Transition nodes to their new position.
  nodeEnter.transition()
    .duration(duration)
    .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
    .style("opacity", 1);

  node.transition()
    .duration(duration)
  // TODO: reenable for full animation
  //.attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
    .style("opacity", 1)
    .select("rect")
    .style("fill", color);

  // TODO: remove if full animation.
  node.attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; });

  node.select('.main-rect')
    .attr("y", -barHeight / 2)
    .attr("height", barHeight)
    .attr("width", barWidth);

  node.select('.fo')
    .attr("x", function(d) { return d.kind ? barWidth - 18 : 0; })
    .attr("y", -10)

  node.select('.node-icon')
    .html(function(d) {
      if (!d.kind) {
        var folder = d._children ? 'fa fa-folder' : 'fa fa-folder-open';
        return '<i class="' + folder + '"></i>';
      }

      return that.determineIcon(d);
    });

  // Transition exiting nodes to the parent's new position.
  node.exit().transition()
    .duration(duration)
  // TODO: reenable for full animation
  // .attr("transform", function(d) { return "translate(" + source.y + "," + source.x + ")"; })
    .style("opacity", 1e-6)
    .remove();

  // Update the links...
  var link = vis.selectAll("path.link")
    .data(tree.links(nodes), function(d) { return d.target.id; });

  // Enter any new links at the parent's previous position.
  link.enter().insert("svg:path", "g")
    .attr("class", "link")
    .attr("d", function(d) {
      var o = {x: source.x0, y: source.y0};
      return diagonal({source: o, target: o});
    })
    .transition()
    .duration(duration)
    .attr("d", diagonal);

  // Transition links to their new position.
  link.transition()
    .duration(duration)
    .attr("d", function(d) {
      var s = {x: d.source.x + 14, y: d.source.y + 9};
      var t = d.target;
      return diagonal({source: s, target: t});
    });

  // Transition exiting nodes to the parent's new position.
  link.exit().transition()
    .duration(duration)
    .attr("d", function(d) {
      var o = {x: source.x, y: source.y};
      return diagonal({source: o, target: o});
    })
    .remove();

  // Stash the old positions for transition.
  nodes.forEach(function(d) {
    d.x0 = d.x;
    d.y0 = d.y;
  });
};

/**
 * Toggles children of a node.
 */
FileTreeBase.prototype.toggle_ = function(d) {
  if (d.children) {
    d._children = d.children;
    d.children = null;
  } else {
    d.children = d._children;
    d._children = null;
  }
};


////////////////////////////////////////////////////////////////////////////////

function ImageFileChangeTree(image, changes) {
  FileTreeBase.apply(this);

  /**
   * The parent image.
   */
  this.image_ = image;

  /**
   * The changes being drawn.
   */
  this.changes_ = changes;
}

$.extend(ImageFileChangeTree.prototype, FileTreeBase.prototype);

/**
 * Builds all the file nodes from the changes.
 */
ImageFileChangeTree.prototype.buildAllNodes = function() {
  for (var i = 0; i < this.changes_.length; ++i) {
    var filepath = this.changes_[i].file;
    var node = this.buildNodes_(filepath);
    node.kind = this.changes_[i].kind;
  }
};


/**
 * Determines the icon for a node.
 */
ImageFileChangeTree.prototype.determineIcon = function(d) {
  var icon = {
    'added': 'plus-square',
    'removed': 'minus-square',
    'changed': 'pencil-square'
  };

  return '<i class="change-icon fa fa-' + icon[d.kind]  + '"></i>';
};


/**
 * Returns the max height of the tree.
 */
ImageFileChangeTree.prototype.getNodesHeight = function() {
  return this.changes_.length;
};


////////////////////////////////////////////////////////////////////////////////

function FileTree(files) {
  FileTreeBase.apply(this);

  /**
   * The set of file paths found in the tree.
   */
  this.files_ = files;
}

$.extend(FileTree.prototype, FileTreeBase.prototype);


/**
 * Builds all the file nodes from the paths.
 */
FileTree.prototype.buildAllNodes = function() {
  for (var i = 0; i < this.files_.length; ++i) {
    var filepath = this.files_[i];
    if (filepath[filepath.length - 1] == '/') { continue; }
    var node = this.buildNodes_(filepath);
    node.kind = 'file';
  }
};


/**
 * Determines the icon for a node.
 */
FileTree.prototype.determineIcon = function(d) {
  return '';
};


/**
 * Returns the max height of the tree.
 */
FileTree.prototype.getNodesHeight = function() {
  return this.files_.length;
};


////////////////////////////////////////////////////////////////////////////////

/**
 * Based off of http://bl.ocks.org/mbostock/1346410
 */
function UsageChart() {
  this.total_ = null;
  this.count_ = null;
  this.drawn_ = false;
}


/**
 * Updates the chart with the given count and total of number of repositories.
 */
UsageChart.prototype.update = function(count, total) {
  if (!this.g_) { return; }
  this.total_ = total;
  this.count_ = count;
  this.drawInternal_();
};


/**
 * Conducts the actual draw or update (if applicable).
 */
UsageChart.prototype.drawInternal_ = function() {
  // If the total is null, then we have not yet set the proper counts.
  if (this.total_ === null) { return; }

  var duration = 750;

  var arc = this.arc_;
  var pie = this.pie_;
  var arcTween = this.arcTween_;

  var color = d3.scale.category20();
  var count = this.count_;
  var total = this.total_;

  var data = [Math.max(count, 1), Math.max(0, total - count)];

  var arcTween = function(a) {
    var i = d3.interpolate(this._current, a);
    this._current = i(0);
    return function(t) {
      return arc(i(t));
    };
  };

  if (!this.drawn_) {
    var text = this.g_.append("svg:text")
      .attr("dy", 10)
      .attr("dx", 0)
      .attr('dominant-baseline', 'auto')
      .attr('text-anchor', 'middle')
      .attr('class', 'count-text')
      .text(this.count_ + ' / ' + this.total_);

    var path = this.g_.datum(data).selectAll("path")
      .data(pie)
      .enter().append("path")
      .attr("fill", function(d, i) { return color(i); })
      .attr("class", function(d, i) { return 'arc-' + i; })
      .attr("d", arc)
      .each(function(d) { this._current = d; }); // store the initial angles

    this.path_ = path;
    this.text_ = text;
  } else {
    pie.value(function(d, i) { return data[i]; }); // change the value function
    this.path_ = this.path_.data(pie); // compute the new angles
    this.path_.transition().duration(duration).attrTween("d", arcTween); // redraw the arcs

    // Update the text.
    this.text_.text(this.count_ + ' / ' + this.total_);
  }

  this.drawn_ = true;
};


/**
 * Draws the chart in the given container.
 */
UsageChart.prototype.draw = function(container) {
  var cw = 200;
  var ch = 200;
  var radius = Math.min(cw, ch) / 2;

  var pie = d3.layout.pie().sort(null);

  var arc = d3.svg.arc()
    .innerRadius(radius - 50)
    .outerRadius(radius - 25);

  var svg = d3.select("#" + container).append("svg:svg")
    .attr("width", cw)
    .attr("height", ch);

  var g = svg.append("g")
    .attr("transform", "translate(" + cw / 2 + "," + ch / 2 + ")");

  this.svg_ = svg;
  this.g_ = g;
  this.pie_ = pie;
  this.arc_ = arc;
  this.width_ = cw;
  this.drawInternal_();
};


////////////////////////////////////////////////////////////////////////////////

/**
 * A chart which displays the last seven days of actions in the account.
 */
function LogUsageChart(titleMap) {
  this.titleMap_ = titleMap;
  this.colorScale_ = d3.scale.category20();
}

/**
 * Builds the D3-representation of the data.
 */
LogUsageChart.prototype.buildData_ = function(logs) {
  var parseDate = d3.time.format("%a, %d %b %Y %H:%M:%S %Z").parse

  // Build entries for each kind of event that occurred, on each day. We have one
  // entry per {kind, day} pair.
  var map = {};
  var entries = [];
  for (var i = 0; i < logs.length; ++i) {
    var log = logs[i];
    var title = this.titleMap_[log.kind] || log.kind;
    var datetime = parseDate(log.datetime);
    var dateDay = datetime.getDate();
    if (dateDay < 10) {
      dateDay = '0' + dateDay;
    }

    var formatted = (datetime.getMonth() + 1) + '/' + dateDay;
    var adjusted = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate());
    var key = title + '_' + formatted;
    var found = map[key];
    if (!found) {
      found = {
        'kind': log.kind,
        'title': title,
        'adjusted': adjusted,
        'formatted': datetime.getDate(),
        'count': 0
      };

      map[key] = found;
      entries.push(found);
    }
    found['count']++;
  }

  this.entries_ = map;

  // Build the data itself. We create a single entry for each possible kind of data, and then add (x, y) pairs
  // for the number of times that kind of event occurred on a particular day.
  var dataArray = [];
  var dataMap = {};
  var dateMap = {};

  for (var i = 0; i < entries.length; ++i) {
    var entry = entries[i];
    var key = entry.title;
    var found = dataMap[key];
    if (!found) {
      found = {'key': key, 'values': [], 'kind': entry.kind};
      dataMap[key] = found;
      dataArray.push(found);
    }

    found.values.push({
      'x': entry.adjusted,
      'y': entry.count
    });

    dateMap[entry.adjusted.toString()] = entry.adjusted;
  }

  // Note: nvd3 has a bug that causes d3 to fail if there is not an entry for every single
  // kind on each day that has data. Therefore, we pad those days with 0-length entries for each
  // kind.
  for (var i = 0; i < dataArray.length; ++i) {
    var datum = dataArray[i];
    for (var sDate in dateMap) {
      if (!dateMap.hasOwnProperty(sDate)) {
        continue;
      }

      var cDate = dateMap[sDate];
      var found = false;
      for (var j = 0; j < datum.values.length; ++j) {
        if (datum.values[j]['x'].getDate() == cDate.getDate()) {
          found = true;
          break;
        }
      }

      if (!found) {
        datum.values.push({
          'x': cDate,
          'y': 0
        });
      }
    }

    datum.values.sort(function(a, b) {
      return a['x'].getDate() - b['x'].getDate();
    });
  }

  return this.data_ = dataArray;
};


/**
 * Renders the tooltip when hovering over an element in the chart.
 */
LogUsageChart.prototype.renderTooltip_ = function(d, e) {
  if (e[0] == '0') {
    e = e.substr(1);
  }

  var key = d + '_' + e;
  var entry = this.entries_[key];
  if (!entry) {
    entry = {'count': 0};
  }

  var s = entry.count == 1 ? '' : 's';
  return d + ' - ' + entry.count + ' time' + s + ' on ' + e;
};


/**
 * Returns the color used in the chart for log entries of the given
 * kind.
 */
LogUsageChart.prototype.getColor = function(kind) {
  var colors = this.colorScale_.range();
  var index = 0;
  for (var i = 0; i < this.data_.length; ++i) {
    var datum = this.data_[i];
    var key = this.titleMap_[kind] || kind;
    if (datum.key == key) {
      index = i;
      break;
    }
  }

  return colors[index];
};


/**
 * Handler for when an element in the chart has been clicked.
 */
LogUsageChart.prototype.handleElementClicked_ = function(e) {
  var key = e.series.key;
  var kind = e.series.kind;
  var disabled = [];

  var enabledCount = 0;
  var d = this.chart_.multibar.disabled();
  for (var i = 0; i < this.data_.length; ++i) {
    enabledCount += (d[i] ? 0 : 1);
  }

  for (var i = 0; i < this.data_.length; ++i) {
    disabled.push(enabledCount == 1 ? false : this.data_[i].key != key);
  }

  var allowed = {};
  allowed[kind] = true;

  this.chart_.dispatch.changeState({ 'disabled': disabled });
  $(this).trigger({
    'type': 'filteringChanged',
    'allowed': enabledCount == 1 ? null : allowed
  });
};


/**
 * Handler for when the state of the chart has changed.
 */
LogUsageChart.prototype.handleStateChange_ = function(e) {
  var allowed = {};
  var disabled = e.disabled;
  for (var i = 0; i < this.data_.length; ++i) {
    if (!disabled[i]) {
      allowed[this.data_[i].kind] = true;
    }
  }

  $(this).trigger({
    'type': 'filteringChanged',
    'allowed': allowed
  });
};


/**
 * Draws the chart in the given container element.
 */
LogUsageChart.prototype.draw = function(container, logData, startDate, endDate) {
  // Reset the container's contents.
  var containerElm = document.getElementById(container);
  if (!containerElm) {
    return;
  }

  containerElm.innerHTML = '<svg></svg>';

  // Returns a date offset from the given date by "days" Days.
  var offsetDate = function(d, days) {
    var copy = new Date(d.getTime());
    copy.setDate(copy.getDate() + days);
    return copy;
  };

  var that = this;
  var data = this.buildData_(logData);
  nv.addGraph(function() {
    // Build the chart itself.
    var chart = nv.models.multiBarChart()
      .margin({top: 30, right: 30, bottom: 50, left: 30})
      .stacked(false)
      .staggerLabels(false)
      .tooltip(function(d, e) {
        return that.renderTooltip_(d, e);
      })
      .color(that.colorScale_.range())
      .groupSpacing(0.1);

    chart.multibar.delay(0);

    // Create the x-axis domain to encompass the full date range.
    var domain = [];
    var datetime = startDate;
    while (datetime <= endDate) {
      domain.push(datetime);
      datetime = offsetDate(datetime, 1);
    }

    chart.xDomain(domain);

    // Finish setting up the chart.
    chart.xAxis
      .tickFormat(d3.time.format("%m/%d"));

    chart.yAxis
      .tickFormat(d3.format(',f'));

    d3.select('#bar-chart svg')
      .datum(data)
      .transition()
      .duration(500)
      .call(chart);

    nv.utils.windowResize(chart.update);

    chart.multibar.dispatch.on('elementClick', function(e) { that.handleElementClicked_(e); });
    chart.dispatch.on('stateChange', function(e) { that.handleStateChange_(e); });
    return that.chart_ = chart;
  });
};
