blob: eec280b5add0397288d4816153744dde1bc326ae [file] [log] [blame]
/**
* @module modules/branches-sk
* @description <h2><code>branches-sk</code></h2>
*
* Custom element for displaying branches.
*/
import { define } from 'elements-sk/define';
import { $$ } from 'common-sk/modules/dom';
import { html } from 'lit-html';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { truncate } from '../../../infra-sk/modules/string';
import { Commit } from '../util';
import { AutorollerStatus, Branch } from '../rpc';
const MIN_CANVAS_WIDTH = 175;
const commitY = 20; // Vertical pixels used by each commit.
const paddingX = 10; // Left-side padding pixels.
const paddingY = 20; // Top padding pixels.
const radius = 3; // Radius of commit dots.
const columnWidth = commitY; // Pixel width of per-branch colums.
const commitBg = '#FFFFFF'; // Background color of alternating commits.
const commitBgAlt = '#EFEFEF'; // Background color of alternating commits.
const font = '10px monospace'; // Font used for labels.
// This is filled in later.
let palette: Array<string> = [];
const BRANCH_PREFIX = 'origin/';
interface CommitInfo {
hash: string;
timestamp?: string;
parents?: Array<string>;
}
class Point {
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
x: number;
y: number;
}
class DisplayCommit {
constructor(commit: CommitInfo, row: number) {
this.hash = commit.hash;
this.timestamp = new Date(commit.timestamp!);
this.row = row;
this.column = -1;
this.label = [];
this.parents = commit.parents || [];
this.children = [];
}
hash: string;
timestamp: Date;
row: number;
column: number;
label: Array<string>;
parents: Array<string>;
children: Array<string>;
color() {
return palette[this.column % palette.length];
}
// Where to draw this commit.
getBounds() {
return new Point(paddingX, paddingY - commitY / 4 + commitY * this.row);
}
// The center of this commit's dot.
dotCenter() {
const start = this.getBounds();
const centerX = start.x + columnWidth * this.column + radius;
const centerY = start.y - radius - 2;
return new Point(centerX, centerY);
}
// Coordinates for drawing this commit's label.
labelCoords() {
const bounds = this.getBounds();
const 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.
labelText() {
return truncate(this.label.join(','), 24);
}
// Return the estimated width of this commit's label text.
labelWidth(ctx: CanvasRenderingContext2D) {
return ctx.measureText(this.labelText()).width;
}
// Draw an an alternating background color for this commit.
drawBackground(ctx: CanvasRenderingContext2D) {
const startY = commitY * this.row;
const 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.
drawConnection(
ctx: CanvasRenderingContext2D,
parent: DisplayCommit,
allCommits: Map<string, DisplayCommit>,
) {
const center = this.dotCenter();
const 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.
const arcRadius = commitY / 2;
// The direction in which to draw the arc.
const d = center.x > to.x ? 1 : -1;
// We'll reuse these values, so pre-compute them.
const halfPI = 0.5 * Math.PI;
const 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.
let v1_flex = true;
for (const parentHash of this.parents) {
const c = allCommits.get(parentHash);
if (!c) {
console.warn(`Cannot find ${parentHash}`);
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 second arc must end at the
// parent commit: the second vertical line has zero length.
// Otherwise, the length of the second vertical line is flexible.
let v2_flex = true;
for (const childHash of parent.children) {
const c = allCommits.get(childHash)!;
if (this.timestamp > c.timestamp && c.timestamp > parent.timestamp) {
if (parent.column == c.column) {
v2_flex = false;
break;
}
}
}
// Arc information..
const a1 = new Point(center.x - d * arcRadius, to.y - commitY);
const 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.
const dist = Math.sqrt(Math.pow(a2.x - a1.x, 2) + Math.pow(a2.y - a1.y, 2));
// Length of the arc to draw.
const arcLength = Math.PI
- Math.acos((2 * arcRadius) / dist)
- Math.acos((Math.abs(to.x - center.x) - 2 * arcRadius) / dist);
const a1_start = halfPI - d * halfPI;
const 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.
drawLabel(ctx: CanvasRenderingContext2D) {
if (this.label.length <= 0) {
return;
}
const labelCoords = this.labelCoords();
const w = this.labelWidth(ctx);
const h = parseInt(font);
const paddingY = 3;
const 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);
}
draw(ctx: CanvasRenderingContext2D, displayCommits: Map<string, DisplayCommit>) {
const color = this.color();
const center = this.dotCenter();
// Connect the dots.
for (const parentHash of this.parents) {
const parent = displayCommits.get(parentHash);
if (!parent) {
console.warn(`Cannot find ${parentHash}`);
continue;
}
this.drawConnection(ctx, parent, displayCommits);
}
// Draw a dot.
drawDot(ctx, center, radius, color);
// Draw a label, if applicable.
this.drawLabel(ctx);
}
}
export class BranchesSk extends ElementSk {
private _repoUrl: string = '';
private _commits: Array<Commit> = [];
private _branchHeads: Array<Branch> = [];
private _rolls: Array<AutorollerStatus> = [];
// Artificial 'Branches' we use to label autorollers, derived from the above.
private rollLabels: Array<Branch> = [];
private displayCommits: Map<string, DisplayCommit> = new Map();
private canvasWidth: number = 0;
private canvasHeight: number = 0;
// Map of commit index -> link to branch or roller.
private linkMap: Map<number, string> = new Map();
// Map of commit index -> title containing branches and rollers.
private titleMap: Map<number, string> = new Map();
private canvas?: HTMLCanvasElement;
private static template = (el: BranchesSk) => html`
<!-- 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"
@click=${(e: MouseEvent) => el.handleClick(e)}
@mousemove=${(e: MouseEvent) => el.handleMousemove(e)}
></canvas>
`;
constructor() {
super(BranchesSk.template);
}
connectedCallback() {
super.connectedCallback();
document.addEventListener('theme-chooser-toggle', this.draw);
this._render();
this.canvas = $$<HTMLCanvasElement>('#commitCanvas', this)!;
this.draw();
}
disconnectedCallback() {
document.removeEventListener('theme-chooser-toggle', this.draw);
}
get commits(): Array<Commit> {
return this._commits;
}
set commits(value: Array<Commit>) {
this._commits = value;
this.draw();
}
get branchHeads(): Array<Branch> {
return this._branchHeads;
}
set branchHeads(value: Array<Branch>) {
this._branchHeads = value;
this.draw();
}
get rolls(): Array<AutorollerStatus> {
return this._rolls;
}
set rolls(value) {
this._rolls = value;
this.rollLabels = [
...this.rolls.map((roll) => ({ name: `${roll.name} rolled`, head: roll.lastRollRev })),
...this.rolls.map((roll) => ({ name: `${roll.name} rolling`, head: roll.currentRollRev })),
];
}
get repoUrl(): string {
return this._repoUrl;
}
set repoUrl(value) {
this._repoUrl = value;
}
private computeLinkMap() {
this.linkMap.clear();
// Link to generic branch heads.
for (const branch of this.branchHeads) {
let name = branch.name;
if (branch.name.startsWith(BRANCH_PREFIX)) {
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.
const idx = this._indexOfRevision(branch.head);
this.linkMap.set(idx, this.repoUrl + name);
}
// Link to rolls.
for (const roller of this.rolls) {
let idx = this._indexOfRevision(roller.currentRollRev);
this.linkMap.set(idx, roller.url);
idx = this._indexOfRevision(roller.lastRollRev);
this.linkMap.set(idx, roller.url);
}
}
private computeTitleMap() {
this.titleMap.clear();
for (const branch of [...this.branchHeads, ...this.rollLabels]) {
const name = branch.name;
const idx = this._indexOfRevision(branch.head);
if (this.titleMap.has(idx)) {
this.titleMap.set(idx, `${this.titleMap.get(idx)},${name}`);
} else {
this.titleMap.set(idx, name);
}
}
}
_indexOfRevision(revision: string) {
return this.commits.findIndex((c) => c.hash === revision);
}
private handleClick(e: MouseEvent) {
if (!this.linkMap) {
return;
}
const y = (e && e.offsetY) || 1;
const commitIdx = Math.floor(y / commitY);
const link = this.linkMap.get(commitIdx);
if (link) {
window.open(link);
}
}
private handleMousemove(e: MouseEvent) {
if (!this.linkMap) {
return;
}
const commitIdx = Math.floor(e.offsetY / commitY);
const link = this.linkMap.get(commitIdx);
if (link) {
this.canvas!.classList.add('pointer');
} else {
this.canvas!.classList.remove('pointer');
}
const title = this.titleMap.get(commitIdx);
if (title) {
this.canvas!.title = title;
}
}
private draw = () => {
console.time('draw');
// Initialize all commits.
this.displayCommits = prepareCommitsForDisplay(this.commits, this.branchHeads, this.rollLabels);
// 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.
const dummyCtx = document.createElement('canvas').getContext('2d')!;
dummyCtx.font = font;
let longestWidth = 0;
for (const commit of this.commits) {
const c = this.displayCommits.get(commit.hash)!;
let w = c.labelWidth(dummyCtx);
w += commitY * (c.column + 1);
if (w > longestWidth) {
longestWidth = w;
}
}
// Redraw the canvas.
const scale = window.devicePixelRatio || 1.0;
const canvas = this.canvas!;
this.canvasWidth = Math.max(longestWidth + paddingX, MIN_CANVAS_WIDTH);
this.canvasHeight = commitY * this.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;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.font = font;
// Create the color palette for the commits.
palette = [
getComputedStyle(this).getPropertyValue('--branch-color-0'),
getComputedStyle(this).getPropertyValue('--branch-color-1'),
getComputedStyle(this).getPropertyValue('--branch-color-2'),
getComputedStyle(this).getPropertyValue('--branch-color-3'),
getComputedStyle(this).getPropertyValue('--branch-color-4'),
];
// Draw the commits.
for (const commit of this.commits) {
this.displayCommits.get(commit.hash)!.draw(ctx, this.displayCommits);
}
this.computeLinkMap();
this.computeTitleMap();
console.timeEnd('draw');
};
}
// Create Commit objects to be displayed. Assigns rows and columns for each
// commit to assist in producing a nice layout.
function prepareCommitsForDisplay(
commits: Array<Commit>,
branch_heads: Array<Branch>,
rolls: Array<Branch>,
): Map<string, DisplayCommit> {
// Create a Commit object for each commit.
const displayCommits: Map<string, DisplayCommit> = new Map(); // Commit objects by hash.
const remaining: Map<string, DisplayCommit> = new Map(); // Not-yet-processed commits by hash.
for (let i = 0; i < commits.length; i++) {
const c = new DisplayCommit(commits[i], i);
displayCommits.set(c.hash, c);
remaining.set(c.hash, c);
}
// Pre-process the branches. We want main first, and no HEAD.
let mainIdx = -1;
const branches: Array<Branch> = [];
for (let b = 0; b < branch_heads.length; b++) {
if (branch_heads[b].name === 'main') {
mainIdx = b;
branches.push(branch_heads[b]);
}
}
for (let b = 0; b < branch_heads.length; b++) {
const branch = branch_heads[b];
if (b != mainIdx && branch.name != 'HEAD') {
branches.push(branch);
}
}
// Add Autoroller labels.
branches.push(...rolls);
// Trace each branch, placing commits on that branch in an associated column.
let column = 0;
for (const branch of branches) {
// Add a label to commits at branch heads.
const hash = branch.head;
// The branch might have scrolled out of the time window. If so, just
// skip it.
if (!displayCommits.has(hash)) {
continue;
}
displayCommits.get(hash)!.label.push(branch.name);
if (traceCommits(displayCommits, commits, remaining, hash, column)) {
column++;
}
}
// Add the remaining commits to their own columns.
for (const hash in remaining) {
if (traceCommits(displayCommits, commits, remaining, hash, column)) {
column++;
}
}
// Point all parents at their children, for convenience.
for (const [_, commit] of displayCommits) {
for (const parentHash of commit.parents) {
if (!displayCommits.has(parentHash)) {
console.warn(`Cannot find ${parentHash}`);
continue;
}
displayCommits.get(parentHash)!.children.push(commit.hash);
}
}
return displayCommits;
}
// 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: Map<string, DisplayCommit>,
commits: Array<CommitInfo>,
remaining: Map<string, DisplayCommit>,
hash: string,
column: number,
) {
let usedColumn = false;
while (remaining.has(hash)) {
const c = displayCommits.get(hash)!;
c.column = column;
remaining.delete(hash);
hash = c.parents[0];
usedColumn = true;
// Special case for non-displayed parents.
if (!displayCommits.has(hash)) {
const offscreenParent = new DisplayCommit(
{
hash: hash,
},
commits.length,
);
offscreenParent.column = c.column;
displayCommits.set(hash, offscreenParent);
}
}
return usedColumn;
}
// Draws a filled-in dot at the given center with the given radius and color.
function drawDot(ctx: CanvasRenderingContext2D, center: Point, radius: number, color: string) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI, false);
ctx.fill();
ctx.closePath();
}
define('branches-sk', BranchesSk);