| /** |
| * @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'; |
| |
| // Array of dots-sk component instances. A dots-sk instance is present if it has |
| // a pending mousemove update. |
| const dotsSkInstancesWithPendingMouseMoveUpdates = []; |
| |
| // 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); |
| |
| const template = () => html`<canvas></canvas>`; |
| |
| define('dots-sk', class extends ElementSk { |
| constructor() { |
| super(template); |
| this._commits = []; |
| this._value = { tileSize: 0, traces: [] }; |
| this._id = `id${Math.random()}`; |
| |
| // The index of the trace that should be highlighted. |
| this._hoverIndex = -1; |
| |
| // For capturing the last mousemove event, which is later processed in a |
| // timer. |
| this._lastMouseMove = null; |
| |
| this._hasScrolledOnce = false; |
| |
| // 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; |
| } |
| |
| /** |
| * @prop value {Object} An object of the form: |
| * |
| * { |
| * 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() { return this._value; } |
| |
| set value(value) { |
| if (!value || (value.tileSize === 0)) { |
| return; |
| } |
| this._value = value; |
| if (this._connected) { |
| this._draw(); |
| } |
| } |
| |
| /** |
| * 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. |
| _draw() { |
| const w = (this._value.tileSize - 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. |
| _drawTraceDots(colors, y) { |
| 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 = this._getColorSafe(DOT_STROKE_COLORS, c); |
| this._ctx.fillStyle = (this._hoverIndex === y) |
| ? this._getColorSafe(DOT_FILL_COLORS_HIGHLIGHTED, c) |
| : this._getColorSafe(DOT_FILL_COLORS, c); |
| this._ctx.arc( |
| dotToCanvasX(x), dotToCanvasY(y), DOT_RADIUS, 0, Math.PI * 2, |
| ); |
| this._ctx.fill(); |
| this._ctx.stroke(); |
| }); |
| } |
| |
| // 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. |
| _getColorSafe(colorArray, uniqueDigestIndex) { |
| return colorArray[Math.min(colorArray.length - 1, uniqueDigestIndex)]; |
| } |
| |
| // Redraws just the circles for a single trace. |
| _redrawTraceDots(traceIndex) { |
| const trace = this._value.traces[traceIndex]; |
| if (!trace) { |
| return; |
| } |
| this._drawTraceDots(trace.data, traceIndex); |
| } |
| |
| /** |
| * @prop commits {Object} An array of commits, such as: |
| * |
| * [ |
| * { |
| * author: "committer@example.org" |
| * commit_time: 1428445634 |
| * hash: "c654e9016a15985ebeb24f94f819d113ad48a251" |
| * }, |
| * ... |
| * ] |
| */ |
| get commits() { return this._commits; } |
| |
| set commits(commits) { this._commits = commits; } |
| |
| _onMouseLeave() { |
| const oldHoverIndex = this._hoverIndex; |
| this._hoverIndex = -1; |
| this._redrawTraceDots(oldHoverIndex); |
| this._lastMouseMove = null; |
| } |
| |
| _onMouseMove(e) { |
| this._lastMouseMove = { |
| clientX: e.clientX, |
| clientY: e.clientY, |
| }; |
| dotsSkInstancesWithPendingMouseMoveUpdates.push(this); |
| } |
| |
| // Gets the coordinates of the mouse event in dot coordinates. |
| _mouseEventToDotSpace(e) { |
| 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. |
| _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('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--) { |
| if (trace.data[i].x === dotCoords.x) { |
| 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. |
| _onClick(e) { |
| 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('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. |
| _computeBlamelist(trace, x) { |
| 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; |
| } |
| }); |