| /** |
| * @module modules/branches-sk |
| * @description <h2><code>branches-sk</code></h2> |
| * |
| * Custom element for displaying branches. |
| */ |
| import { html } from 'lit/html.js'; |
| import { define } from '../../../elements-sk/modules/define'; |
| import { $$ } from '../../../infra-sk/modules/dom'; |
| 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> = []; |
| let textColor: string = '#000000'; |
| |
| 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 = textColor; |
| 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'), |
| ]; |
| |
| textColor = getComputedStyle(this).getPropertyValue('--surface'); |
| |
| // 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); |