From 3a134c7ab1ec7943c88de9a8ee52dd52dd6e6328 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sat, 19 Oct 2013 19:46:30 -0400 Subject: [PATCH] Add a tree view to the image changes view --- static/css/quay.css | 41 +++- static/js/controllers.js | 9 + static/js/graphing.js | 346 ++++++++++++++++++++++++++++++++ static/partials/image-view.html | 76 ++++--- 4 files changed, 442 insertions(+), 30 deletions(-) diff --git a/static/css/quay.css b/static/css/quay.css index cb2578b52..c12083d94 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -557,11 +557,13 @@ p.editable:hover i { .repo-image-view .changes-container .change-side-controls { float: right; clear: both; + margin-top: 10px; + margin-bottom: 10px; } .repo-image-view .changes-container .filter-input { display: inline-block; - width: 200px; + width: 400px; } .repo-image-view .changes-container .result-count { @@ -903,6 +905,7 @@ p.editable:hover i { margin-bottom: 12px; } + #image-history-container { overflow: hidden; min-height: 400px; @@ -959,8 +962,40 @@ p.editable:hover i { cursor: pointer; } -#image-history-container .tags .tag.current { - +#changes-tree-container { + overflow: hidden; +} + +#changes-tree-container .node rect { + cursor: pointer; + fill: #fff; + fill-opacity: 1; + stroke: #fff; + stroke-width: 1.5px; +} + +#changes-tree-container .node .change-icon { + font-size: 14px; +} + +#changes-tree-container .node text { + font: 12px sans-serif; + pointer-events: none; +} + +#changes-tree-container .node.added text { + fill: rgb(32, 163, 32); +} + +#changes-tree-container .node.removed text { + text-decoration: line-through; + fill: rgb(209, 73, 73); +} + +#changes-tree-container path.link { + fill: none; + stroke: #9ecae1; + stroke-width: 1.5px; } /* Overrides for the markdown editor. */ diff --git a/static/js/controllers.js b/static/js/controllers.js index 251699988..316c4f346 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -834,6 +834,15 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, Restangular) { $scope.search['$'] = filter; document.getElementById('change-filter').value = filter; }; + + $scope.initializeTree = function() { + if ($scope.tree) { return; } + + $scope.tree = new ImageFileChangeTree($scope.image, $scope.combinedChanges); + setTimeout(function() { + $scope.tree.draw('changes-tree-container'); + }, 10); + }; // Fetch the image. var imageFetch = Restangular.one('repository/' + namespace + '/' + name + '/image/' + imageid); diff --git a/static/js/graphing.js b/static/js/graphing.js index 4794acbd0..615d2523f 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -713,4 +713,350 @@ ImageHistoryTree.prototype.toggle_ = function(d) { d.children = d._children; d._children = null; } +}; + +///////////////////////////////////////////////////////////////////////////////////////// + +/** + * Based off of http://bl.ocks.org/mbostock/1093025 by Mike Bostock (@mbostock) + */ +function ImageFileChangeTree(image, changes) { + /** + * The parent image. + */ + this.image_ = image; + + /** + * The changes being drawn. + */ + this.changes_ = changes; + + /** + * Counter for creating unique IDs. + */ + this.idCounter_ = 0; + + /** + * Map from file path to associated tree node. + */ + this.nodeMap_ = {}; +} + + +/** + * Calculates the dimensions of the tree. + */ +ImageFileChangeTree.prototype.calculateDimensions_ = function(container) { + var cw = document.getElementById(container).clientWidth; + var barWidth = cw * 0.8; + + var barHeight = 20; + var ch = (this.changes_.length * barHeight) + 40; + + var margin = { top: 40, right: 20, bottom: 20, left: 40 }; + 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, + 'bw': barWidth, + 'bh': barHeight + }; +}; + + +/** + * Draws the tree. + */ +ImageFileChangeTree.prototype.draw = function(container) { + this.container_ = container; + + var dimensions = this.calculateDimensions_(container); + + 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("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. + */ +ImageFileChangeTree.prototype.populateAndDraw_ = function() { + // For each change, generate all the node(s) needed for the folders, as well + // as the final node (if any) for the file. + 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; + } + + // 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. + */ +ImageFileChangeTree.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('/'); + this.nodeMap_[parentPath]._children.push(this.nodeMap_[currentPath]); + } + } + } + return this.nodeMap_[path]; +}; + + +/** + * Calculates the count of visible nodes. + */ +ImageFileChangeTree.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. + */ +ImageFileChangeTree.prototype.getContainerHeight_ = function() { + var dimensions = this.calculateDimensions_(this.container_); + 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. + */ +ImageFileChangeTree.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. + document.getElementById(this.container_).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("y", -barHeight / 2) + .attr("height", barHeight) + .attr("width", barWidth) + .style("fill", color) + .on("click", function(d) { 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("x", function(d) { return d.kind ? barWidth - 18 : 0; }) + .attr("y", -10) + .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('.node-icon') + .html(function(d) { + if (!d.kind) { + var folder = d._children ? 'icon-folder-close' : 'icon-folder-open'; + return ''; + } + + var icon = { + 'added': 'plus-sign-alt', + 'removed': 'minus-sign-alt', + 'changed': 'edit-sign' + }; + + return ''; + }); + + // 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", diagonal); + + // 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. + */ +ImageFileChangeTree.prototype.toggle_ = function(d) { + if (d.children) { + d._children = d.children; + d.children = null; + } else { + d.children = d._children; + d._children = null; + } }; \ No newline at end of file diff --git a/static/partials/image-view.html b/static/partials/image-view.html index 333e6c08b..6c02cf9a1 100644 --- a/static/partials/image-view.html +++ b/static/partials/image-view.html @@ -19,10 +19,13 @@ + +
+ -
-
Full Image ID
-
+
+
Full Image ID
+
@@ -42,32 +45,51 @@
- -
- - -
+ +
File Changes: -
-
- Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results -
-
- -
-
-
-
- No matching changes -
-
- - - - {{folder}}/{{getFilename(change.file)}} - -
+
+
+ +
+ + +
+ +
+
+
+
+ Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results +
+
+ +
+
+
+
+
+ No matching changes +
+
+ + + + {{folder}}/{{getFilename(change.file)}} +
+
+
+
+ + +
+
+
+