/** * 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) */ 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 += '' + d.encountered[i].image.id.substr(0, 12) + ''; html += '' + formatTime(d.encountered[i].image.created) + ''; } return html; } if (!d.image) { return '(This repository is empty)'; } if (d.image.comment) { html += '' + formatComment(d.image.comment) + ''; } if (d.image.command && d.image.command.length) { html += '' + formatCommand(d.image) + ''; } html += '' + formatTime(d.image.created) + ''; var tags = d.tags || []; html += ''; for (var i = 0; i < tags.length; ++i) { var tag = tags[i]; var kind = 'default'; html += '' + tag + ''; } html += ''; 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 = '
'; for (var i = 0; i < d.tags.length; ++i) { var tag = d.tags[i]; var kind = 'default'; if (tag == currentTag) { kind = 'success'; } html += '' + tag + ''; } html += '
'; 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 */ 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. */ 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 formatted = (datetime.getMonth() + 1) + '/' + datetime.getDate(); 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) { if (e[0] == '0') { e = e.substr(1); } 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 = ''; // 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; }); };