blob: 3b9e49629cda018b6536e4b61e21df5dbf613247 [file] [log] [blame]
<!--
The res/js/status.js file must be included before this file.
This in an HTML Import-able file this contains the definition
of the following elements:
<commits-canvas-sk>
To use this file import it:
<link href="/res/imp/commits-canvas-sk.html" rel="import" />
Usage:
<commits-canvas-sk></commits-canvas-sk>
Properties:
//input
branch_heads: Array<Object>, an array of Objects with the following attributes:
head: String, the last commit revision in this branch. If it is off the screen, it will
not be drawn.
name: String, the name of the branch. Will be drawn next to the "head" commit.
commits: Array<Object>, an array of commit objects, in chronological order with (at least), the
following attributes:
hash: String, the commit's revision
parent: Array<String>, the hashes of any parents.
timestamp: String, the commit's timestamp, formatted like: "2016-02-22T06:34:47-08:00"
roll_statuses: Array of Objects, which is used to link to the recent rolls. Each object contains at least:
currentRollRev: String, git hash of current roll
lastRollRev: String, git hash of previous roll
//output
commits_to_load: Number, the number of commits to load.
reload: Number, the number of seconds that the status page should be reloaded.
Methods:
None.
Events:
None.
-->
<link rel="import" href="/res/imp/bower_components/paper-input/paper-input.html">
<link rel="import" href="/res/common/imp/styles-sk.html">
<dom-module id="commits-canvas-sk">
<template>
<style include="styles-sk">
.refresh {
font-size: 12px;
font-weight: normal;
height: 108px;
width: 160px;
padding: 5px;
}
paper-input {
display: inline-block;
}
:host{
--paper-input-container-input: {
font-size: 1.0em;
};
}
div[prefix] {
font-size: 1.0em;
}
#commitCanvas.pointer {
cursor: pointer;
}
</style>
<div id="container">
<div id="reload" class="refresh">
<paper-input type="number" value="{{reload}}" auto-validate no-label-float>
<div prefix>Reload (s):&nbsp;</div>
</paper-input>
<paper-input type="number" value="{{commits_to_load}}" auto-validate no-label-float>
<div prefix>Commits:&nbsp;</div>
</paper-input>
<div>Loaded {{_lastLoaded}}</div>
</div>
<!-- The tap event (which was originally used) does not always produce offsetY.
on-click works for the Pixels (even when touching), so we use that.-->
<canvas id="commitCanvas" on-click="handleclick" on-mousemove="handlemouseover"></canvas>
</div>
</template>
<script>
(function() {
var MIN_CANVAS_WIDTH = 175;
var commitY = 20; // Vertical pixels used by each commit.
var paddingX = 10; // Left-side padding pixels.
var paddingY = 20; // Top padding pixels.
var radius = 3; // Radius of commit dots.
var columnWidth = commitY; // Pixel width of per-branch colums.
// This is filled in later.
var palette = [];
var commitBg = "#FFFFFF"; // Background color of alternating commits.
var commitBgAlt = "#EFEFEF"; // Background color of alternating commits.
var font = "10px monospace"; // Font used for labels.
var BRANCH_PREFIX = "origin/";
// Draws a filled-in dot at the given center with the given radius and color.
function drawDot(ctx, center, radius, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(center.x, center.y, radius, 0, 2*Math.PI, false);
ctx.fill();
ctx.closePath();
}
// Object with an x and y-value.
function Point(x, y) {
this.x = x;
this.y = y;
}
// Object representing a commit used for creating layout and drawing.
function Commit(commitInfo, row) {
this.hash = commitInfo.hash;
this.timestamp = new Date(commitInfo.timestamp);
this.row = row;
this.column = -1;
this.label = [];
this.parents = commitInfo.parent || [];
// The color for this commit.
this.color = function() {
return palette[this.column % palette.length];
};
// Where to draw this commit.
this.getBounds = function() {
return new Point(paddingX, paddingY - commitY/4 + commitY * this.row);
};
// The center of this commit's dot.
this.dotCenter = function() {
var start = this.getBounds();
var centerX = start.x + columnWidth * this.column + radius;
var centerY = start.y - radius - 2;
return new Point(centerX, centerY);
};
// Coordinates for drawing this commit's label.
this.labelCoords = function() {
var bounds = this.getBounds();
var center = this.dotCenter();
return new Point(center.x + 3 * radius, bounds.y - 1);
};
// Return the text for this commit's label, truncated to 24 characters.
this.labelText = function() {
return sk.truncate(this.label.join(","), 24);
};
// Return the estimated width of this commit's label text.
this.labelWidth = function(ctx) {
return ctx.measureText(this.labelText()).width;
};
// Draw an an alternating background color for this commit.
this.drawBackground = function(ctx) {
var startY = commitY * this.row;
var bgColor = this.row % 2 ? commitBg : commitBgAlt;
ctx.fillStyle = bgColor;
ctx.fillRect(0, startY, ctx.canvas.clientWidth, startY + commitY);
};
// Draw a line connecting this commit to one of its parents.
this.drawConnection = function(ctx, parent, allCommits) {
var center = this.dotCenter();
var to = parent.dotCenter();
ctx.beginPath();
ctx.moveTo(center.x, center.y);
if (this.column == parent.column) {
// Draw a straight line.
ctx.lineTo(to.x, to.y);
} else {
// Draw a connector composed of five segments: a vertical line, an
// arc, a horizontal line, another arc, and another vertical line.
// One or more of the lines may have zero length.
var arcRadius = commitY / 2;
// The direction in which to draw the arc.
var d = center.x > to.x ? 1 : -1;
// We'll reuse these values, so pre-compute them.
var halfPI = 0.5 * Math.PI;
var oneAndHalfPI = 1.5 * Math.PI;
// If there is at least one commit in the current commit's column
// between the current commit and this parent, the first arc must
// begin at the current commit: the first vertical line has zero
// length. Otherwise, the length of the first vertical line is
// flexible.
var v1_flex = true;
for (var i = 0; i < this.parents.length; i++) {
var c = allCommits[this.parents[i]];
if (!c) {
console.warn("Cannot find " + this.parents[i]);
continue;
}
if (this.timestamp > c.timestamp && c.timestamp > parent.timestamp) {
if (this.column == c.column) {
v1_flex = false;
break;
}
}
}
// If there is at least one commit in the parent's column between the
// current commit and this parent, the secon arc must end at the
// parent commit: the second vertical line has zero length.
// Otherwise, the length of the second vertical line is flexible.
var v2_flex = true;
for (var i = 0; i < parent.children.length; i++) {
var c = allCommits[parent.children[i]];
if (this.timestamp > c.timestamp && c.timestamp > parent.timestamp) {
if (parent.column == c.column) {
v2_flex = false;
break;
}
}
}
// Arc information..
var a1 = new Point(center.x - d * arcRadius, to.y - commitY);
var a2 = new Point(to.x + d * arcRadius, to.y);
// If both vertical lines are flexible, arbitrarily choose where to
// put the arcs and horizontal line (eg. next to the parent).
if (v1_flex && v2_flex) {
a1.y = to.y - commitY;
a2.y = to.y;
}
// If exactly one vertical line is flexible, put the arcs and
// horizontal line where they must go.
else if (v1_flex && !v2_flex) {
a1.y = to.y - commitY;
a2.y = to.y;
} else if (!v1_flex && v2_flex) {
a1.y = center.y;
a2.y = center.y + commitY;
}
// If neither vertical line is flexible, then we have to place arcs
// at both commits and the "horizontal" line becomes diagonal.
else {
a1.y = center.y;
a2.y = to.y;
}
// Distance between the two arc centers.
var dist = Math.sqrt(Math.pow(a2.x - a1.x, 2) + Math.pow(a2.y - a1.y, 2));
// Length of the arc to draw.
var arcLength = Math.PI - Math.acos(2*arcRadius / dist) - Math.acos((Math.abs(to.x - center.x) - 2*arcRadius) / dist);
var a1_start = halfPI - d * halfPI;
var a2_start = oneAndHalfPI - d * (halfPI - arcLength);
// Draw the connector: vertical line, arc, horizontal line, arc,
// vertical line.
ctx.lineTo(a1.x + d*arcRadius, a1.y);
ctx.arc(a1.x, a1.y, arcRadius, a1_start, a1_start + d*arcLength, d < 0);
// The middle line doesn't need to be explicitly drawn.
ctx.arc(a2.x, a2.y, arcRadius, a2_start, a2_start - d*arcLength, d > 0);
ctx.lineTo(to.x, to.y);
}
ctx.strokeStyle = this.color();
ctx.stroke();
};
// Draw this commit's label.
this.drawLabel = function(ctx) {
if (this.label.length <= 0) {
return;
}
labelCoords = this.labelCoords();
var w = this.labelWidth(ctx);
var h = parseInt(font);
var paddingY = 3;
var paddingX = 3;
ctx.fillStyle = this.color();
ctx.fillRect(labelCoords.x - paddingX, labelCoords.y - h, w + 2 * paddingX, h + paddingY);
ctx.fillStyle = "#FFFFFF";
ctx.fillText(this.labelText(), labelCoords.x, labelCoords.y);
};
this.draw = function(ctx, displayCommits) {
var color = this.color();
var center = this.dotCenter();
// Connect the dots.
for (var p = 0; p < this.parents.length; p++) {
var parent = displayCommits[this.parents[p]];
if (!displayCommits[this.parents[p]]) {
console.warn("Cannot find " + this.parents[p]);
continue;
}
this.drawConnection(ctx, parent, displayCommits);
}
// Draw a dot.
drawDot(ctx, center, radius, color);
// Draw a label, if applicable.
this.drawLabel(ctx);
};
}
// Follow commits by first parent, assigning the given column until we get
// to a commit that we aren't going to draw.
function traceCommits(displayCommits, commits, remaining, hash, column) {
var usedColumn = false;
while(remaining[hash]) {
var c = displayCommits[hash];
c.column = column;
delete remaining[hash];
hash = c.parents[0];
usedColumn = true;
// Special case for non-displayed parents.
if (!displayCommits[hash]) {
var parent = new Commit({
hash: hash,
}, commits.length);
parent.column = c.column;
displayCommits[hash] = parent;
}
}
return usedColumn;
}
// Create Commit objects to be displayed. Assigns rows and columns for each
// commit to assist in producing a nice layout.
function prepareCommitsForDisplay(commits, branch_heads) {
// Create a Commit object for each commit.
var displayCommits = {}; // Commit objects by hash.
var remaining = {}; // Not-yet-processed commits by hash.
for (var i = 0; i < commits.length; i++) {
var c = new Commit(commits[i], i)
displayCommits[c.hash] = c;
remaining[c.hash] = c;
}
// Pre-process the branches. We want master first, and no HEAD.
var masterIdx = -1;
var branches = [];
for (var b = 0; b < branch_heads.length; b++) {
if (branch_heads[b].name === "master") {
masterIdx = b;
branches.push(branch_heads[b]);
}
}
for (var b = 0; b < branch_heads.length; b++) {
var branch = branch_heads[b];
if (b != masterIdx && branch.name != "HEAD") {
branches.push(branch);
}
}
// Trace each branch, placing commits on that branch in an associated column.
var column = 0;
for (var b = 0; b < branches.length; b++) {
// Add a label to commits at branch heads.
var hash = branches[b].head
// The branch might have scrolled out of the time window. If so, just
// skip it.
if (!displayCommits[hash]) {
continue
}
displayCommits[hash].label.push(branches[b].name);
if (traceCommits(displayCommits, commits, remaining, hash, column)) {
column++;
}
}
// Add the remaining commits to their own columns.
for (var hash in remaining) {
if (traceCommits(displayCommits, commits, remaining, hash, column)) {
column++;
}
}
// Point all parents at their children, for convenience.
for (var hash in displayCommits) {
displayCommits[hash].children = [];
}
for (var hash in displayCommits) {
var c = displayCommits[hash];
if (c.parents) {
for (var i = 0; i < c.parents.length; i++) {
if (!displayCommits[c.parents[i]]) {
console.warn("Cannot find " + c.parents[i]);
continue;
}
displayCommits[c.parents[i]].children.push(c.hash);
}
}
}
return [displayCommits, column];
}
Polymer({
is: "commits-canvas-sk",
properties: {
// input
cr_roll: {
type: Object,
},
android_roll: {
type: Object,
},
branch_heads: {
type: Array,
},
commits: {
type: Array,
},
repo_base: {
type: String,
},
// output
commits_to_load: {
type: Number,
value: 35,
notify: true,
},
reload: {
type: Number,
value: 60,
notify: true,
},
// private
_linkMap: {
// Number: String, number is index of commit in display, String is URL to link to.
type: Object,
computed: "_computeLinkMap(commits.*,roll_statuses.*,branch_heads.*)",
},
_titleMap: {
// Number: String, number is index of commit in display, String is the title of the label.
type: Object,
computed: "_computeTitleMap(commits.*,roll_statuses.*,branch_heads.*)",
},
_lastLoaded: {
type: String,
value: "(not yet loaded)",
},
},
observers: [
"draw(commits.*, branch_heads.*)"
],
draw: function() {
var commits = this.commits;
var branch_heads = this.branch_heads;
this._lastLoaded = new Date().toLocaleTimeString();
console.time("draw");
// Initialize all commits.
var prep = prepareCommitsForDisplay(commits, branch_heads);
this.displayCommits = prep[0];
var numColumns = prep[1];
// Calculate the required canvas width based on the commit columns and
// labels.
// TODO(borenet): Further minimize this width by reordering the columns
// based on which has the longest label.
var dummyCtx = document.createElement("canvas").getContext("2d");
dummyCtx.font = font;
var longestWidth = 0;
for (var i = 0; i < commits.length; i++) {
var c = this.displayCommits[commits[i].hash];
var w = c.labelWidth(dummyCtx);
w += commitY * (c.column + 1);
if (w > longestWidth) {
longestWidth = w;
}
}
// Redraw the canvas.
var scale = window.devicePixelRatio || 1.0;
var canvas = this.$.commitCanvas;
this.canvasWidth = Math.max(longestWidth + paddingX, MIN_CANVAS_WIDTH);
this.canvasHeight = commitY * commits.length;
canvas.style.width = Math.floor(this.canvasWidth) + "px";
canvas.style.height = Math.floor(this.canvasHeight) + "px";
canvas.width = this.canvasWidth * scale;
canvas.height = this.canvasHeight * scale;
var ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.font = font;
// Shade an alternating background.
for (var i = 0; i < commits.length; i++) {
this.displayCommits[commits[i].hash].drawBackground(ctx);
}
// Create the color palette for the commits.
palette = [
this.getComputedStyleValue("--color-bluegreen"),
this.getComputedStyleValue("--color-redorange"),
this.getComputedStyleValue("--color-purple"),
this.getComputedStyleValue("--color-pink"),
this.getComputedStyleValue("--color-lightgreen"),
this.getComputedStyleValue("--color-lightorange"),
this.getComputedStyleValue("--color-burntorange"),
this.getComputedStyleValue("--color-gray"),
];
// Draw the commits.
for (var i = 0; i < commits.length; i++) {
this.displayCommits[commits[i].hash].draw(ctx, this.displayCommits);
}
console.timeEnd("draw");
},
// _computeLinkMap looks through all the branch_heads, finds the index of each
// commit belonging to a branch and makes a map of index -> URL. Then, it handles
// the special case of current autoroll and previous autoroll.
// The last link added to the map will be the one used. So when there are multiple
// links added we need prioritize which one should displayed.
// Here is the current order: Chrome roll links have priority over Android roll links
// which in turn have priority over branch links.
_computeLinkMap: function() {
var linkMap = {};
// Link to generic branch heads.
this.branch_heads.forEach(function(branch) {
var name = branch.name;
if (branch.name.startsWith(BRANCH_PREFIX)) {
var name = branch.name.slice(BRANCH_PREFIX.length);
}
// If the commit is not found in the range of commits, we will just be
// overwriting the value at key "-1", which won't actually get used.
var idx = this._indexOfRevision(branch.head);
linkMap[idx] = this.repo_base + name;
}.bind(this));
// Link to rolls.
for (var i = 0; i < this.roll_statuses.length; i++) {
var roller = this.roll_statuses[i];
var idx = this._indexOfRevision(roller.currentRollRev);
linkMap[idx] = roller.url;
idx = this._indexOfRevision(roller.lastRollRev);
linkMap[idx] = roller.url;
}
return linkMap;
},
// _computeTitleMap looks through all the branch heads, find the index of each
// commit belonging to a branch and makes a map of index -> label title.
_computeTitleMap: function() {
var titleMap = {};
this.branch_heads.forEach(function(branch) {
var name = branch.name;
var idx = this._indexOfRevision(branch.head);
if (titleMap[idx]) {
titleMap[idx] = titleMap[idx] + "," + name;
} else {
titleMap[idx] = name;
}
}.bind(this));
return titleMap;
},
_indexOfRevision: function(revision) {
var idx = -1;
this.commits.forEach(function(c,i){
if (c.hash === revision){
idx = i;
}
});
return idx;
},
// handleclick converts the click's offsetY (Y location relative to the tapped canvas) into index
handleclick: function(e) {
if (!this._linkMap) {
return;
}
var y = (e && e.offsetY) || 1;
var commitIdx = Math.floor(y / commitY);
var link = this._linkMap[commitIdx];
if (link){
window.open(link);
}
},
handlemouseover: function(e) {
if (!this._linkMap) {
return;
}
var commitIdx = Math.floor(e.offsetY / commitY);
var link = this._linkMap[commitIdx];
if (link){
this.$.commitCanvas.classList.add("pointer");
} else {
this.$.commitCanvas.classList.remove("pointer");
}
var title = this._titleMap[commitIdx];
if (title) {
this.$.commitCanvas.title = title;
}
},
});
})();
</script>
</dom-module>