Add a tree view to the image changes view

This commit is contained in:
Joseph Schorr 2013-10-19 19:46:30 -04:00
parent 0afea3a779
commit 3a134c7ab1
4 changed files with 442 additions and 30 deletions

View file

@ -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. */

View file

@ -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);

View file

@ -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 '<i class="' + folder + '"></i>';
}
var icon = {
'added': 'plus-sign-alt',
'removed': 'minus-sign-alt',
'changed': 'edit-sign'
};
return '<i class="change-icon icon-' + icon[d.kind] + '"></i>';
});
// 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;
}
};

View file

@ -19,10 +19,13 @@
</h3>
</div>
<!-- Comment -->
<blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote>
<!-- Information -->
<dl class="dl-normal">
<dt>Full Image ID</dt>
<dd>
<dl class="dl-normal">
<dt>Full Image ID</dt>
<dd>
<div>
<div class="id-container">
<div class="input-group">
@ -42,32 +45,51 @@
<dd am-time-ago="parseDate(image.created)"></dd>
</dl>
<!-- Comment -->
<blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote>
<!-- Changes -->
<div class="changes-container full-changes-container" ng-show="combinedChanges.length > 0">
<!-- Changes tabs -->
<div ng-show="combinedChanges.length > 0">
<b>File Changes:</b>
<div class="change-side-controls">
<div class="result-count">
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
</div>
<div class="filter-input">
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
</div>
</div>
<div class="changes-list well well-sm">
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0">
No matching changes
</div>
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
<i ng-class="{'added': 'icon-plus-sign-alt', 'removed': 'icon-minus-sign-alt', 'changed': 'icon-edit-sign'}[change.kind]"></i>
<span title="{{change.file}}">
<span style="color: #888;">
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
</span>
</div>
<br>
<br>
<ul class="nav nav-tabs">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li>
</ul>
</div>
<!-- Changes tab content -->
<div class="tab-content" ng-show="combinedChanges.length > 0">
<!-- Filterable view -->
<div class="tab-pane active" id="filterable">
<div class="changes-container full-changes-container">
<div class="change-side-controls">
<div class="result-count">
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
</div>
<div class="filter-input">
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
</div>
</div>
<div style="height: 28px;"></div>
<div class="changes-list well well-sm">
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0">
No matching changes
</div>
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
<i ng-class="{'added': 'icon-plus-sign-alt', 'removed': 'icon-minus-sign-alt', 'changed': 'icon-edit-sign'}[change.kind]"></i>
<span title="{{change.file}}">
<span style="color: #888;">
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
</span>
</div>
</div>
</div>
</div>
<!-- Tree view -->
<div class="tab-pane" id="tree">
<div id="changes-tree-container" class="changes-container"></div>
</div>
</div>
</div>