This repository has been archived on 2020-03-24. You can view files and clone it, but cannot push or open issues or pull requests.
quay/static/js/graphing.js

1355 lines
34 KiB
JavaScript

/**
* 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)
*/
export function ImageHistoryTree(namespace, name, images, getTagsForImage, formatComment, formatTime,
formatCommand, opt_tagFilter) {
/**
* 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 retrieve the tags for an image.
*/
this.getTagsForImage_ = getTagsForImage;
/**
* 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;
/**
* Method for filtering the tags and image paths displayed in the tree.
*/
this.tagFilter_ = opt_tagFilter || function() { return true; };
/**
* 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 = document.getElementById(container).clientWidth;
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 - 100) + '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": this.getTagsForImage_(image),
"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);
});
// Recursively prune the nodes that are not referenced by a tag
this.pruneUnreferenced_(root);
// 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
};
};
/**
* Prunes images which are not referenced either directly or indirectly by any tag.
*/
ImageHistoryTree.prototype.pruneUnreferenced_ = function(node) {
if (node.children) {
var surviving_children = []
for (var i = 0; i < node.children.length; ++i) {
if (!this.pruneUnreferenced_(node.children[i])) {
surviving_children.push(node.children[i]);
}
}
node.children = surviving_children;
}
if (!node.tags) {
return node.children.length == 0;
}
var tags = [];
for (var i = 0; i < node.tags.length; ++i) {
if (this.tagFilter_(node.tags[i])) {
tags.push(node.tags[i]);
}
}
return (node.children.length == 0 && tags.length == 0);
};
/**
* 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 && 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
&& current.children.length == 1
&& current.tags
&& current.tags.length == 0) {
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;
}
}
if (node.children) {
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 that = this;
var tagImage = this.findImage_(function(image) {
return tagName && (that.getTagsForImage_(image).indexOf(tagName) >= 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/1346410
*/
export 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.
*/
export function LogUsageChart(titleMap) {
this.titleMap_ = titleMap;
this.colorScale_ = d3.scale.category20();
this.entryMap_ = {};
}
/**
* Builds the D3-representation of the data.
*/
LogUsageChart.prototype.buildData_ = function(aggregatedLogs) {
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 entries = [];
for (var i = 0; i < aggregatedLogs.length; ++i) {
var aggregated = aggregatedLogs[i];
var title = this.titleMap_[aggregated.kind] || aggregated.kind;
var datetime = parseDate(aggregated.datetime);
var day = ('0' + datetime.getDate()).slice(-2);
var month = ('0' + (datetime.getMonth() + 1)).slice(-2);
var formatted = month + '/' + day;
var justdate = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate());
var key = title + '_' + formatted;
var entry = {
'kind': aggregated.kind,
'title': title,
'justdate': justdate,
'formatted': datetime.getDate(),
'count': aggregated.count
};
entries.push(entry);
this.entryMap_[key] = entry;
}
// 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.justdate,
'y': entry.count
});
dateMap[entry.justdate.toString()] = entry.justdate;
}
// 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() * 1) - (b['x'].getDate() * 1);
});
}
return this.data_ = dataArray;
};
/**
* Renders the tooltip when hovering over an element in the chart.
*/
LogUsageChart.prototype.renderTooltip_ = function(d, e) {
var key = d + '_' + e;
var entry = this.entryMap_[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: 60})
.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;
});
};