Add a tree view to the image changes view
This commit is contained in:
parent
0afea3a779
commit
3a134c7ab1
4 changed files with 442 additions and 30 deletions
|
@ -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. */
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
|
|
Reference in a new issue