var DEPTH_HEIGHT = 100; /** * Based off of http://mbostock.github.io/d3/talk/20111018/tree.html by Mike Bostock (@mbostock) */ function ImageHistoryTree(namespace, name, images, current, formatComment, formatTime) { /** * The namespace of the repo. */ this.repoNamespace_ = namespace; /** * The name of the repo. */ this.repoName_ = name; /** * The images to display. */ this.images_ = images; /** * The current tag. */ this.currentTag_ = current.name; /** * The current image. */ this.currentImage_ = current.image; /** * Counter for creating unique IDs. */ this.idCounter_ = 0; /** * 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; } /** * Draws the tree. */ ImageHistoryTree.prototype.draw = function(container) { // Build the root of the tree. this.maxHeight_ = this.buildRoot_(); // Determine the size of the SVG container. var width = document.getElementById(container).clientWidth; var height = Math.max(width * 0.625, this.maxHeight_ * (DEPTH_HEIGHT + 10)); var margin = { top: 40, right: 60, bottom: 20, left: 60 }; var m = [margin.top, margin.right, margin.bottom, margin.left]; var w = width - m[1] - m[3]; var h = height - m[0] - m[2]; // Create the tree and all its components. var tree = d3.layout.tree() .size([h, w]); var diagonal = d3.svg.diagonal() .projection(function(d) { return [d.x, d.y]; }); var vis = d3.select("#" + container).append("svg:svg") .attr("width", w + m[1] + m[3]) .attr("height", h + m[0] + m[2]) .append("svg:g") .attr("transform", "translate(" + m[3] + "," + m[0] + ")"); var formatComment = this.formatComment_; var formatTime = this.formatTime_; var tip = d3.tip() .attr('class', 'd3-tip') .offset([-1, 24]) .direction('e') .html(function(d) { var html = ''; if (d.image.comment) { html += '' + formatComment(d.image.comment) + ''; } html += '' + formatTime(d.image.created) + ''; html += '' + d.image.id + ''; return html; }) vis.call(tip); // Save all the state created. this.fullWidth_ = width; this.width_ = w; this.height_ = h; this.diagonal_ = diagonal; this.vis_ = vis; this.tip_ = tip; this.tree_ = tree; // Populate the tree. this.populate_(); }; /** * 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; }; /** * Marks the image node with whether it is the current image and/or highlighted. */ ImageHistoryTree.prototype.markWithState_ = function(image, imageNode) { var currentAncestors = this.getAncestors(this.currentImage_); var isCurrent = image.id == this.currentImage_.id; var isHighlighted = currentAncestors.indexOf(image.dbid.toString()) >= 0; imageNode.highlighted = isHighlighted || isCurrent; imageNode.current = isCurrent; }; /** * 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 = {}; // Build a node for each image. var imageByDBID = {}; for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; var imageNode = { "name": image.id.substr(0, 12), "children": [], "image": image, "tags": image.tags }; this.markWithState_(image, imageNode); imageByDBID[image.dbid] = imageNode; } this.imageByDBID_ = imageByDBID; // For each node, attach it to its immediate parent. If there is no immediate parent, // then the node is the root. var maxAncestorCount = 0; for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; var imageNode = imageByDBID[image.dbid]; var ancestors = this.getAncestors(image); var immediateParent = ancestors[ancestors.length - 1] * 1; var parent = imageByDBID[immediateParent]; if (parent) { // If the image node is highlighted, ensure it is at the front // of the child list. if (imageNode.highlighted) { parent.children.splice(0, 0, imageNode); } else { parent.children.push(imageNode); } } else { formatted = imageNode; } maxAncestorCount = Math.max(maxAncestorCount, ancestors.length); } this.root_ = formatted; return maxAncestorCount + 1; }; /** * Sets the current tag displayed in the tree. */ ImageHistoryTree.prototype.setTag_ = function(tagName) { this.currentTag_ = tagName; // Find the new current image. for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; if (image.tags.indexOf(tagName) >= 0) { this.currentImage_ = image; } } // Update the state of each node. var imageByDBID = this.imageByDBID_; var currentAncestors = this.getAncestors(this.currentImage_); for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; var imageNode = this.imageByDBID_[image.dbid]; this.markWithState_(image, imageNode); } // Ensure that the children are in the correct order. for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; var imageNode = this.imageByDBID_[image.dbid]; var ancestors = this.getAncestors(image); var immediateParent = ancestors[ancestors.length - 1] * 1; var parent = imageByDBID[immediateParent]; if (parent && imageNode.highlighted) { var arr = parent.children; if (parent._children) { arr = parent._children; } if (arr[0] != imageNode) { var index = arr.indexOf(imageNode); arr.splice(index, 1); arr.splice(0, 0, imageNode); } } } // Finally, update the tree. this.update_(this.root_); }; /** * Populates the tree. */ ImageHistoryTree.prototype.populate_ = function() { // Set the position of the initial node. this.root_.x0 = this.fullWidth_ / 2; this.root_.y0 = 0; // Initialize the display to show the current path of nodes. 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 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) { that.toggle_(d); that.update_(d); }) .on('mouseover', tip.show) .on('mouseout', tip.hide); 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) { bbox = this.getBBox() 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"; }); // Ensure that the node is visible. nodeUpdate.select("g") .style("fill-opacity", 1); // Update the tags. node.select(".tags") .html(function(d) { var html = ''; for (var i = 0; i < d.tags.length; ++i) { var tag = d.tags[i]; var kind = 'default'; if (tag == currentTag) { kind = 'success'; } html += '' + tag + ''; } 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.setTag_(tag); } }); // 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; } };