| /** |
| * @module modules/dots-sk |
| * @description <h2><code>dots-sk</code></h2> |
| * |
| * A custom element for displaying a dot chart of digests by trace, such as: |
| * |
| * ooo-o-o-oo |
| * |
| * @evt showblamelist - Event generated when a dot is clicked. e.detail contains |
| * the blamelist (an array of commits that could have made up that dot). |
| * |
| * @evt hover - Event generated when the mouse hovers over a trace. e.detail is |
| * the trace id. |
| */ |
| |
| import { define } from 'elements-sk/define'; |
| import { html } from 'lit-html'; |
| import { $$ } from 'common-sk/modules/dom'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import { |
| dotToCanvasX, |
| dotToCanvasY, |
| DOT_FILL_COLORS, |
| DOT_FILL_COLORS_HIGHLIGHTED, |
| DOT_OFFSET_X, |
| DOT_OFFSET_Y, |
| DOT_RADIUS, |
| DOT_SCALE_X, |
| DOT_SCALE_Y, |
| DOT_STROKE_COLORS, |
| MISSING_DOT, |
| STROKE_WIDTH, |
| TRACE_LINE_COLOR, |
| } from './constants'; |
| import { |
| Commit, Trace, TraceGroup, TraceID, |
| } from '../rpc_types'; |
| |
| // Array of dots-sk component instances. A dots-sk instance is present if it has a pending |
| // mousemove update. |
| const dotsSkInstancesWithPendingMouseMoveUpdates: DotsSk[] = []; |
| |
| // Periodically process all pending mousemoves. We do not want to do any work on a mouse move event |
| // as that can very easily degrade browser performance, e.g. as the user drags the mouse pointer |
| // over the element. Processing mouse events in batches remedies this. |
| setInterval(() => { |
| while (dotsSkInstancesWithPendingMouseMoveUpdates.length > 0) { |
| const dotsSk = dotsSkInstancesWithPendingMouseMoveUpdates.pop()!; |
| dotsSk.updatePendingMouseMove(); |
| } |
| }, 40); |
| |
| /** |
| * Used to index into the dot color arrays (DOT_STROKE_COLORS, etc.). Returns |
| * the last color in the array if the given unique digest index exceeds |
| * MAX_UNIQUE_DIGESTS. |
| * |
| * This assumes that the color array is of length MAX_UNIQUE_DIGESTS + 1. |
| */ |
| const getColorSafe = (colorArray: string[], uniqueDigestIndex: number): string => colorArray[Math.min(colorArray.length - 1, uniqueDigestIndex)]; |
| |
| export class DotsSk extends ElementSk { |
| private static template = () => html`<canvas></canvas>`; |
| |
| private canvas: HTMLCanvasElement | null = null; |
| |
| private ctx: CanvasRenderingContext2D | null = null; |
| |
| private _commits: Commit[] = []; |
| |
| private _value: TraceGroup = { traces: [], digests: [], total_digests: 0 }; |
| |
| // The index of the trace that should be highlighted. |
| private hoverIndex = -1; |
| |
| private hasScrolledOnce = false; |
| |
| // For capturing the last mousemove event, which is later processed in a timer. |
| private lastMouseMove: MouseEvent | null = null; |
| |
| constructor() { |
| super(DotsSk.template); |
| |
| // Explicitly bind event handler methods to this. |
| this.onMouseMove = this.onMouseMove.bind(this); |
| this.onMouseLeave = this.onMouseLeave.bind(this); |
| this.onClick = this.onClick.bind(this); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| this._render(); |
| this.canvas = $$('canvas', this); |
| this.canvas!.addEventListener('mousemove', this.onMouseMove); |
| this.canvas!.addEventListener('mouseleave', this.onMouseLeave); |
| this.canvas!.addEventListener('click', this.onClick); |
| this.ctx = this.canvas!.getContext('2d'); |
| this.draw(); |
| } |
| |
| disconnectedCallback() { |
| this.canvas!.removeEventListener('mousemove', this.onMouseMove); |
| this.canvas!.removeEventListener('mouseleave', this.onMouseLeave); |
| this.canvas!.removeEventListener('click', this.onClick); |
| this.hasScrolledOnce = false; |
| } |
| |
| /** |
| * The TraceGroup to display, e.g.: |
| * |
| * { |
| * tileSize: 50, |
| * traces: [ |
| * { |
| * label: "some:trace:id", |
| * data: [0, 1, 2, 2, 1, -1, -1, 2, ...], |
| * }, |
| * ... |
| * ] |
| * } |
| * |
| * Where the content of the data array are color codes; 0 is the target digest, while 1-6 |
| * indicate unique digests that are different from the target digest. A code of -1 means no data |
| * for the corresponding commit. A code of 7 means that there are 8 or more unique digests in the |
| * trace and all digests after the first 8 unique digests are represented by this code. The |
| * highest index of data is the most recent data point. |
| */ |
| get value(): TraceGroup { return this._value; } |
| |
| set value(value: TraceGroup) { |
| if (!value || (!value.traces?.length)) { |
| return; |
| } |
| this._value = value; |
| if (this._connected) { |
| this.draw(); |
| } |
| } |
| |
| /** An array of Commits associated with the TraceGroup to display. */ |
| get commits(): Commit[] { return this._commits; } |
| |
| set commits(commits: Commit[]) { this._commits = commits; } |
| |
| /** |
| * Scrolls the traces all the way to the right, showing the newest first. It will only do this |
| * on the first call, so as to avoid undoing the user manually scrolling left to see older |
| * history. |
| */ |
| autoscroll() { |
| if (!this.hasScrolledOnce) { |
| this.hasScrolledOnce = true; |
| this.scroll(this.scrollWidth, 0); |
| } |
| } |
| |
| /** Draws the entire canvas. */ |
| private draw() { |
| if (!this._value.traces || !this._value.traces.length) { |
| return; |
| } |
| const w = (this._value.traces[0].data!.length - 1) * DOT_SCALE_X + 2 * DOT_OFFSET_X; |
| const h = (this._value.traces.length - 1) * DOT_SCALE_Y + 2 * DOT_OFFSET_Y; |
| this.canvas!.setAttribute('width', `${w}px`); |
| this.canvas!.setAttribute('height', `${h}px`); |
| |
| // First clear the canvas. |
| this.ctx!.lineWidth = STROKE_WIDTH; |
| this.ctx!.fillStyle = '#FFFFFF'; |
| this.ctx!.fillRect(0, 0, w, h); |
| |
| // Draw lines and dots. |
| this._value.traces!.forEach((trace, traceIndex) => { |
| this.ctx!.strokeStyle = TRACE_LINE_COLOR; |
| this.ctx!.beginPath(); |
| const firstNonMissingDot = trace.data!.findIndex((dot) => dot !== MISSING_DOT); |
| let lastNonMissingDot = -1; |
| for (let i = trace.data!.length - 1; i >= 0; i--) { |
| if (trace.data![i] !== MISSING_DOT) { |
| lastNonMissingDot = i; |
| break; |
| } |
| } |
| if (firstNonMissingDot < 0 || lastNonMissingDot < 0) { |
| // Trace was all missing data, so nothing to draw. This should never happen, such a trace |
| // would not be included in search results. |
| console.warn(`trace with id ${trace.label} was unexpectedly empty`); |
| return; |
| } |
| this.ctx!.moveTo( |
| dotToCanvasX(firstNonMissingDot), |
| dotToCanvasY(traceIndex), |
| ); |
| this.ctx!.lineTo( |
| dotToCanvasX(lastNonMissingDot), |
| dotToCanvasY(traceIndex), |
| ); |
| this.ctx!.stroke(); |
| this.drawTraceDots(trace.data!, traceIndex); |
| }); |
| } |
| |
| /** Draws the circles for a single trace. */ |
| private drawTraceDots(colors: number[], y: number) { |
| colors.forEach((c, x) => { |
| // We don't draw a dot when it is missing. |
| if (c === MISSING_DOT) { |
| return; |
| } |
| this.ctx!.beginPath(); |
| this.ctx!.strokeStyle = getColorSafe(DOT_STROKE_COLORS, c); |
| this.ctx!.fillStyle = (this.hoverIndex === y) |
| ? getColorSafe(DOT_FILL_COLORS_HIGHLIGHTED, c) |
| : getColorSafe(DOT_FILL_COLORS, c); |
| this.ctx!.arc( |
| dotToCanvasX(x), dotToCanvasY(y), DOT_RADIUS, 0, Math.PI * 2, |
| ); |
| this.ctx!.fill(); |
| this.ctx!.stroke(); |
| }); |
| } |
| |
| /** Redraws just the circles for a single trace. */ |
| private redrawTraceDots(traceIndex: number) { |
| const trace = this._value.traces![traceIndex]; |
| if (!trace) { |
| return; |
| } |
| this.drawTraceDots(trace.data!, traceIndex); |
| } |
| |
| private onMouseLeave() { |
| const oldHoverIndex = this.hoverIndex; |
| this.hoverIndex = -1; |
| this.redrawTraceDots(oldHoverIndex); |
| this.lastMouseMove = null; |
| } |
| |
| private onMouseMove(e: MouseEvent) { |
| this.lastMouseMove = e; |
| dotsSkInstancesWithPendingMouseMoveUpdates.push(this); |
| } |
| |
| /** Gets the coordinates of the mouse event in dot coordinates. */ |
| private mouseEventToDotSpace(e: MouseEvent) { |
| const rect = this.canvas!.getBoundingClientRect(); |
| const x = (e.clientX - rect.left - DOT_OFFSET_X + STROKE_WIDTH + DOT_RADIUS) |
| / DOT_SCALE_X; |
| const y = (e.clientY - rect.top - DOT_OFFSET_Y + STROKE_WIDTH + DOT_RADIUS) |
| / DOT_SCALE_Y; |
| return { x: Math.floor(x), y: Math.floor(y) }; |
| } |
| |
| /** |
| * We look at the mousemove event, if one occurred, to determine which trace to highlight. |
| * |
| * Not part of the public API. |
| */ |
| updatePendingMouseMove() { |
| if (!this.lastMouseMove) { |
| return; |
| } |
| const dotCoords = this.mouseEventToDotSpace(this.lastMouseMove); |
| this.lastMouseMove = null; |
| // If the focus has moved to a different trace then draw the two changing |
| // traces. |
| if (this.hoverIndex !== dotCoords.y) { |
| const oldIndex = this.hoverIndex; |
| this.hoverIndex = dotCoords.y; |
| if (this.hoverIndex >= 0 |
| && this.hoverIndex < this._value.traces!.length) { |
| this.dispatchEvent(new CustomEvent<TraceID>('hover', { |
| bubbles: true, |
| detail: this._value.traces![this.hoverIndex].label, |
| })); |
| } |
| // Just update the dots of the traces that have changed. |
| this.redrawTraceDots(oldIndex); |
| this.redrawTraceDots(this.hoverIndex); |
| } |
| |
| // Set the cursor to a pointer if you are hovering over a dot. |
| let found = false; |
| const trace = this._value.traces![dotCoords.y]; |
| if (trace) { |
| for (let i = trace.data!.length - 1; i >= 0; i--) { |
| const dot = trace.data![dotCoords.x]; |
| if (dot !== undefined && dot !== MISSING_DOT) { |
| found = true; |
| break; |
| } |
| } |
| } |
| this.style.cursor = (found) ? 'pointer' : 'auto'; |
| } |
| |
| /** |
| * When a dot is clicked on, produce the showblamelist event with the blamelist; that is, all the |
| * commits that are included up to and including that dot. |
| */ |
| private onClick(e: MouseEvent) { |
| const dotCoords = this.mouseEventToDotSpace(e); |
| const trace = this._value.traces![dotCoords.y]; |
| if (!trace) { |
| return; // Misclick, likely. |
| } |
| const blamelist = this.computeBlamelist(trace, dotCoords.x); |
| if (!blamelist) { |
| return; // No blamelist if there's no dot at that X coord, i.e. misclick. |
| } |
| this.dispatchEvent(new CustomEvent<Commit[]>('showblamelist', { |
| bubbles: true, |
| detail: blamelist, |
| })); |
| } |
| |
| /** |
| * Takes a trace and the X coordinate of a dot in that trace and returns the blamelist for that |
| * dot. The blamelist includes the commit corresponding to the dot, and if the dot is preceded by |
| * any missing dots, then their corresponding commits will be included as well. |
| */ |
| private computeBlamelist(trace: Trace, x: number) { |
| if (trace.data![x] === MISSING_DOT) { |
| // Can happen if there's no dot at that X coord, e.g. misclick. |
| return null; |
| } |
| // Look backwards in the trace for the previous commit with data. If none, |
| // 0 is a fine index to compute the blamelist from. |
| let lastNonMissingIndex = 0; |
| for (let i = x - 1; i >= 0; i--) { |
| if (trace.data![i] !== MISSING_DOT) { |
| // We include the last non-missing data in our slice because the slice of commits that |
| // Gold returns is not the complete history - Gold elides commits that have no data. |
| // This is potentially a problem in the following scenario: |
| // - commit 1 has correct data |
| // - commit 2 has correct data |
| // - commit 3 has no data, but introduced a bug (and would have produced incorrect data) |
| // - commit 4 has incorrect data |
| // In this case, we need to make sure we can create a blamelist that starts on the first |
| // real commit after commit 2. Therefore, we include commit 2 in the list, which GitHub |
| // and googlesource will automatically elide when we ask for a range of history (in |
| // blamelist-panel-sk). If there were no preceding missing digests, this will equal x-1. |
| lastNonMissingIndex = i; |
| break; |
| } |
| } |
| const blamelist = this._commits.slice(lastNonMissingIndex, x + 1); |
| blamelist.reverse(); |
| return blamelist; |
| } |
| } |
| |
| define('dots-sk', DotsSk); |