blob: 0df07f322c8b4d1d6230dda6df691d4b447b4a68 [file] [log] [blame]
<!--
This in an HTML Import-able file that contains the definition
of the following elements:
<commits-table-d3-sk>
This uses d3 to draw the commit table. Unlike previous versions which used a table and basically
drew the table row by row, this implementation draws it column by column. Because filtering
happens on a task spec (column) basis, drawing it column by column results in simpler code.
D3 is much much faster than Polymer for this type of data visualization, although this element
still offers a Polymer interface. If any of the inputs change, the table will be redrawn.
To use this file import it:
<link href="/res/imp/commits-table-d3-sk" rel="import" />
Usage:
<commits-table-d3-sk></commits-table-d3-sk>
Properties:
// inputs
task_details: Object, a map of commit hash to an object that has the task results by task spec.
task_specs: Object, a map of the task spec names to an object that has, among other things, category,
subcategory, and comments.
tasks: Object, a map of the task spec names to an object that maps task IDs to task results.
categories: Object, a map of the task spec categories to an object that has the subcategories and
the colspan (total number of included task specs).
category_list: Array<String>, an array of the task spec category names.
commits: Array<Object>, the commit objects, in chronological detail.
commits_map: Object, a map of commit hash to commit objects.
logged_in: Boolean, if the links should be for internal or external pages.
relanded_map: Object, a map of an issue number that was relanded to the commit that relands it.
repo: String, the current repo. Used to direct comments to the right place.
repo_base: The base URL for commits. Commit hashes will be appended to this.
reverted_map: Object, a map of an issue number that was reverted to the commit that reverts it.
swarming_url: String, the URL of the Swarming server.
task_scheduler_url: String, the URL of the Task Scheduler.
Methods:
None.
Events:
None.
-->
<script src="/res/imp/bower_components/d3/d3.min.js"></script>
<link rel="import" href="/res/imp/bower_components/iron-flex-layout/iron-flex-layout-classes.html">
<link rel="import" href="/res/imp/bower_components/iron-icons/iron-icons.html">
<link rel="import" href="/res/imp/bower_components/iron-icons/communication-icons.html">
<link rel="import" href="/res/imp/bower_components/iron-icons/image-icons.html">
<link rel="import" href="/res/imp/bower_components/paper-dialog/paper-dialog.html">
<link rel="import" href="/res/common/imp/styles-sk.html">
<link rel="import" href="task-popup-sk.html">
<link rel="import" href="taskspec-popup-sk.html">
<link rel="import" href="commit-popup-sk.html">
<link rel="stylesheet" href="commits-table-d3.css">
<dom-module id="commits-table-d3-sk">
<template>
<style include="iron-flex styles-sk">
#commits {
min-height: 780px;
min-width: 50px;
}
#tasks {
border: 1px solid blue;
min-height: 400px;
}
#legend {
border-right: 1px solid black;
font-size: 10px;
height: 68px;
}
#table {
/* 80 pixels of header and 35 * 20px of commits.
It is important to preallocate this size, otherwise the flexbox layout
behaves strangely (i.e. wrong) when the data is first added. */
min-height: 780px;
}
iframe {
border: 0;
}
</style>
<div id="container" class="horizontal layout">
<div id="commits">
<div id="legend">
<iron-icon class="tiny" icon="communication:chat"></iron-icon> comment<br/>
<iron-icon class="tiny" icon="image:texture"></iron-icon> flaky<br/>
<iron-icon class="tiny" icon="icons:block"></iron-icon> ignore failure<br/>
<iron-icon class="tiny fg-failure" icon="icons:undo"></iron-icon> reverted<br/>
<iron-icon class="tiny fg-success" icon="icons:redo"></iron-icon> relanded<br/>
</div>
</div>
<div id="table" class="flex horizontal layout"></div>
</div>
<div id="infoDialog"></div>
</template>
<script>
(function(){
var TASK_HEIGHT = 20;
Polymer({
is: 'commits-table-d3-sk',
properties: {
// inputs from data source to render.
task_specs: {
type: Object,
},
task_details: {
type: Object,
},
tasks: {
type: Object,
},
categories: {
type: Object,
},
category_list: {
type: Array,
},
commit_label: {
type: String,
},
commits: {
type: Array,
},
commits_map: {
type: Object,
},
logged_in: {
type: Boolean,
},
relanded_map: {
type: Object,
},
repo: {
type: String,
},
repo_base: {
type: String,
},
reverted_map: {
type: Object,
},
swarming_url: {
type: String,
},
time_points: {
type: Object,
},
task_scheduler_url: {
type: String,
},
// outputs
drawing: {
type: Boolean,
notify: true,
value: false,
}
},
observers: [
"redraw(task_specs, task_details, categories, category_list, commit_label, commits, commits_map, relanded_map, repo, reverted_map, time_points)",
],
ready: function() {
// We redraw, so as to try to better fit the screen size.
d3.select(window).on('resize', this.redraw.bind(this));
this.scopeSubtree(this.$.container, true);
},
redraw:function() {
// This gets called any time one of the values changes. Since the values are
// updated simultaneously, we don't want to try to draw the table many times at the
// same time, so we debounce it. No timeout on the debounce because all the this.set
// calls will happen before a paint call, so this just basically collates all the requests.
this.debounce("redraw-commits-table", function(){
this._redraw();
}.bind(this));
},
_redraw:function() {
this.set("drawing", true);
console.time("d3 rendering");
// Display pictures instead of tasks if there aren't any to display.
var iframe = null;
for (var i = 0; i < this.$.table.childNodes.length; i++) {
var child = this.$.table.childNodes[i];
if (child.tagName == "IFRAME") {
iframe = child;
break;
}
}
if (this.category_list.length === 0) {
if (!iframe) {
iframe = document.createElement("iframe");
iframe.src = "https://clients3.google.com/cast/chromecast/home?slideshow-period=300";
this.$.table.appendChild(iframe);
}
// Images are 1920x1080. Scale to fit the size of the div.
srcWidth = 1920;
srcHeight = 1080;
var scale = Math.min(this.$.table.clientWidth / srcWidth, this.$.table.clientHeight / srcHeight);
iframe.style.width = (srcWidth * scale) + "px";
iframe.style.height = (srcHeight * scale) + "px";
} else {
if (iframe) {
this.$.table.removeChild(iframe);
}
}
this._drawCommitMessages(this.commits, this.relanded_map, this.reverted_map, this.time_points);
this._drawCategories(this.category_list, this.categories);
this._drawSubCategories(this.categories);
this._drawTaskSpecColumns(this.categories, this.task_specs, this.commits.length);
this._drawTasks(this.commits, this.task_details);
console.timeEnd("d3 rendering");
this.set("drawing", false);
},
// Draw all of the commit divs that are on the left.
_drawCommitMessages: function(commits, relanded_map, reverted_map, time_points){
var openCommitDialog = this._openCommitDialog.bind(this);
var commitLabel = this.commit_label;
// It's easiest to delete all .commits and redraw them. Normally, we would only update
// the ones we want, but since we have a composed element and there aren't very many
// commits, starting from scratch is simplest.
d3.select(this.$.commits).selectAll(".commit").remove();
var data = d3.select(this.$.commits).selectAll(".commit")
.data(commits, function(commit, i){
commit.index = i;
return commit.hash;
});
// Create a commit div for each commit with id "patch_storage + codereview_number".
// Eg: "gerrit12345" for Gerrit.
// For posterity: Element selectors choke if an id starts with a number, thus the
// patch_storage prefix helps with that.
var newCommits = data
.enter()
.append("div")
.classed("commit",true)
.attr("id", function(commit){
return commit.patchStorage + commit.issue;
})
.on("tap", function(commit) {
// Stop the propogation so we don't immediately hide the popup we show.
d3.event.stopPropagation();
openCommitDialog(commit);
});
// Sort the commits to be in the order we are presented with them.
data.order();
// Set the mouseover to be the opposite of what the label says.
newCommits
.attr("title", function(commit) {
if (commitLabel == "author") {
return commit.shortSubject;
}
return commit.author;
});
// Add a spacer to every commit where the time bubble could be. This allows all
// the bubbles and non-bubble commits to line up vertically.
var spacers = newCommits
.insert("div")
.classed("time-spacer",true);
spacers.each(function(commit) {
var point = time_points[commit.hash];
if (point) {
d3.select(this)
.append("span")
.classed("time-underline", true);
d3.select(this)
.insert("span")
.classed("time", true)
.attr("title", point.label)
.text(point.label);
}
});
// Insert a span with the commit label.
newCommits
.insert("span")
.classed("author", function(){
return commitLabel == "author";
})
.classed("subject", function(){
return commitLabel == "subject";
})
.text(function(commit) {
if (commitLabel == "author") {
return commit.shortAuthor || "[no author]";
}
return commit.shortSubject || "[no subject]";
});
newCommits
.insert("div")
.classed("flex", true);
newCommits
.insert("span")
.classed("commit-comment", true);
newCommits
.insert("span")
.classed("commit-ignore", true);
newCommits
.insert("span")
.classed("commit-revert", true);
newCommits
.insert("span")
.classed("commit-reland", true);
// On the other side, conditionally draw iron icons for comments, relands, reverts.
this._addIronIcon(newCommits.select(".commit-comment"), "communication:chat", "tiny", function(commit){
return commit.comments && commit.comments.length > 0;
});
this._addIronIcon(newCommits.select(".commit-ignore"), "icons:block", "tiny", function(commit){
return commit.ignoreFailure;
});
var reverts = this._addIronIcon(newCommits.select(".commit-revert"), "icons:undo", "tiny fg-failure", function(commit){
var reverterCommit = reverted_map[commit.issue];
return (reverterCommit !== undefined && reverterCommit.timestamp > commit.timestamp);
});
reverts.on("mouseover", function(commit){
var reverterIssue = reverted_map[commit.issue].issue;
d3.select("#"+commit.patchStorage+reverterIssue).classed("bg-failure", true);
})
reverts.on("mouseleave", function(commit){
d3.selectAll(".commit.bg-failure").classed("bg-failure", false);
})
var relands = this._addIronIcon(newCommits.select(".commit-reland"), "icons:redo", "tiny fg-success", function(commit){
var relanderCommit = relanded_map[commit.issue];
return (relanderCommit !== undefined && relanderCommit.timestamp > commit.timestamp);
});
relands.on("mouseover", function(commit){
var relanderIssue = relanded_map[commit.issue].issue;
d3.select("#"+commit.patchStorage+relanderIssue).classed("bg-success", true);
});
relands.on("mouseleave", function(commit){
d3.selectAll(".commit.bg-success").classed("bg-success", false);
});
},
// Adds iron icons to everything in the selection if shouldAddIcon returns truthy. It
// returns all icons that were added.
_addIronIcon: function(selection, icon, classes, shouldAddIcon) {
return selection
.html(function(commit){
// The Polymer templates will pick this up and make it a real iron-icon element.
// However, if you try to do .insert("iron-icon")... that does not work.
if (shouldAddIcon === undefined || shouldAddIcon(commit)) {
return "<iron-icon class=\""+classes+"\" icon=\"" + icon + "\"></iron-icon>";
}
});
},
// Draw all of the divs for the categories, including the header.
_drawCategories: function(category_list, categories){
var data = d3.select(this.$.table).selectAll(".category")
.data(category_list, function(category){
return category;
});
data.exit().remove();
var newCategories = data
.enter()
.append("div")
.classed("category",true);
data.order();
data
.style("flex-grow", function(category){
// This keeps the rows about as evenly sized as possible.
// colspan is the total number of task specs in this category.
return categories[category].colspan;
});
newCategories
.insert("div")
.classed("category-title", true)
.text(function(category){
return category;
})
// .subcategories will hold all of the .subcategory elements.
newCategories
.insert("div")
.classed("subcategories", true);
},
// Draw all of the divs for the subcategories, including the header
_drawSubCategories: function(categories) {
d3.select(this.$.table).selectAll(".subcategories").each(function(category){
if (!categories[category]) {
return;
}
var list = categories[category].subcategoryList.map(function(subcat){
return {
"category": category,
"subcategory": subcat,
};
});
var data = d3.select(this)
.selectAll(".subcategory")
.data(list, function(d) {
return d.category + d.subcategory;
});
data.exit().remove();
var subcategories = data
.enter()
.insert("div")
.classed("subcategory", true);
data.order();
data
.style("flex-grow", function(d){
// This keeps the rows about as evenly sized as possible.
return categories[d.category].subcategories[d.subcategory].task_specs.length;
});
subcategories
.insert("div")
.classed("subcategory-title", true)
.text(function(d){
return d.subcategory;
});
// .task-specs will hold all of the .task-spec elements.
subcategories
.insert("div")
.classed("task-specs", true);
});
},
// draw all of the task spec columns, including the header box with the flaky, ignore_failure,
// and comment icons
_drawTaskSpecColumns: function(categories, taskSpecs, num_commits) {
var addIronIcon = this._addIronIcon.bind(this);
var openTaskSpecDialog = this._openTaskSpecDialog.bind(this);
d3.select(this.$.table).selectAll(".task-specs").each(function(d){
if (!categories[d.category].subcategories[d.subcategory]) {
return;
}
var list = categories[d.category].subcategories[d.subcategory].task_specs.map(function(taskSpec){
return {
"category": d.category,
"subcategory": d.subcategory,
"taskSpec": taskSpec,
};
});
var data = d3.select(this)
.selectAll(".task-spec")
.data(list, function(d) {
return d.category + d.subcategory + d.taskSpec;
});
data.exit().remove();
var newTaskSpecs = data
.enter()
.insert("div")
.classed("task-spec", true);
data.order();
var titles = newTaskSpecs
.insert("div")
.classed("task-spec-title", true)
.attr("title", function(d){
return d.taskSpec;
})
.on("tap", function(d){
// Stop the propogation so we don't immediately hide the popup we show.
d3.event.stopPropagation();
openTaskSpecDialog(d.taskSpec);
});
titles
.insert("span")
.classed("taskspec-comment", true);
titles
.insert("span")
.classed("taskspec-ignore", true);
titles
.insert("span")
.classed("taskspec-flaky", true);
addIronIcon(data.selectAll(".taskspec-ignore"), "icons:block", "tiny", function(d){
return taskSpecs[d.taskSpec].ignoreFailure;
});
addIronIcon(data.selectAll(".taskspec-flaky"), "image:texture", "tiny", function(d){
return taskSpecs[d.taskSpec].flaky;
});
addIronIcon(data.selectAll(".taskspec-comment"), "communication:chat", "tiny", function(d){
return taskSpecs[d.taskSpec].comments && taskSpecs[d.taskSpec].comments.length > 0;
});
newTaskSpecs
.insert("div")
.classed("task-spec-spacer", true);
// Set the height so that flexing doesn't throw off the multi column alignment.
newTaskSpecs
.insert("div")
.classed("tasks", true)
.style("max-height", function(){
return (TASK_HEIGHT * num_commits) + "px";
});
// .style("min-height", function(){
// return (TASK_HEIGHT * num_commits) + "px";
// });
});
},
// Draws the task divs. We forego flexbox helping us align these. First, we create a task
// div (which is a fixed height) in side of it create a div with the various task classes to
// set the heights/widths/margins etc. This is easier to line up with the adjacent columns
// and allows for more consistent zooming behavior.
_drawTasks: function(commits, task_details) {
var openTaskDialog = this._openTaskDialog.bind(this);
var addIronIcon = this._addIronIcon.bind(this);
d3.select(this.$.table).selectAll(".tasks").each(function(d){
var list = commits.map(function(commit) {
return {
"category": d.category,
"subcategory": d.subcategory,
"taskSpec": d.taskSpec,
"commit": commit,
"displayClass": commit.displayClass[d.taskSpec] || [],
};
});
var data = d3.select(this)
.selectAll(".task")
.data(list, function(d) {
return d.category + d.subcategory + d.taskSpec + d.commit.hash;
});
data.exit().remove();
var newTasks = data
.enter()
.insert("div")
.classed("task",true)
.insert("div")
.on("tap", function(d){
// Stop the propogation so we don't immediately hide the popup we show.
d3.event.stopPropagation();
openTaskDialog(d);
});
newTasks
.insert("span")
.classed("task-comment", true);
data.order();
// Style the inside div.
data.each(function(d){
var task = this.children[0];
// Remove any previously-set classes.
var removeClasses = [];
for (var i = 0; i < task.classList.length; i++) {
var c = task.classList.item(i);
if (c.indexOf("bg-") === 0 || c.indexOf("task_") === 0) {
removeClasses.push(c);
}
}
removeClasses.forEach(function(c) {
task.classList.remove(c);
});
// Add task display class.
for (var i = 0; i < d.displayClass.length; i++) {
task.classList.add(d.displayClass[i]);
}
task.title = d.taskSpec + " @ " + d.commit.shortHash;
var details = task_details[d.commit.hash] && task_details[d.commit.hash][d.taskSpec];
if (details) {
// Add the new class.
task.classList.add(task_details[d.commit.hash][d.taskSpec].colorClass);
addIronIcon(d3.select(task).select(".task-comment"), "communication:chat", "tiny", function(){
return details.comments && details.comments.length > 0 &&
(task.classList.contains(CLASS_TASK_SINGLE)
|| task.classList.contains(CLASS_TASK_TOP));
});
} else {
task.style["background-color"] = "";
task.title = "";
}
});
});
},
_openTaskDialog: function(task) {
if (this._infoPopupOpen()) {
return;
}
var details = this.task_details[task.commit.hash][task.taskSpec];
if (details) {
var taskInfo = document.createElement("task-popup-sk");
taskInfo.task = details;
taskInfo.repo = this.repo;
taskInfo.repo_base = this.repo_base;
taskInfo.commit_details = this.commits_map;
taskInfo.parent = this;
taskInfo.swarming_url = this.swarming_url;
taskInfo.task_scheduler_url = this.task_scheduler_url;
this._openDialog(taskInfo);
}
},
_openTaskSpecDialog: function(taskSpec) {
if (this._infoPopupOpen()) {
return;
}
if (taskSpec) {
var taskSpecInfo = document.createElement("taskspec-popup-sk");
taskSpecInfo.task_spec = this.task_specs[taskSpec];
taskSpecInfo.repo = this.repo;
taskSpecInfo.swarming_url = this.swarming_url;
this._openDialog(taskSpecInfo);
}
},
_openCommitDialog: function(commit) {
if (this._infoPopupOpen()) {
return;
}
var commitInfo = document.createElement("commit-popup-sk");
commitInfo.commit = commit;
commitInfo.repo = this.repo;
commitInfo.repo_base = this.repo_base;
this._openDialog(commitInfo);
},
// Is the info popup open?
_infoPopupOpen: function() {
return this.$.infoDialog.opened;
},
// Set the dialog content and open the dialog.
_openDialog: function(child) {
this.$.infoDialog.innerHTML = '';
this.$.infoDialog.appendChild(child);
child.show();
},
});
})()
</script>
</dom-module>