[gold] Port dots-sk to TypeScript.
Bug: skia:10246
Change-Id: I3c022c8847c1cdcbb743c42001d9dc8e93a1bf70
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/397220
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/modules/dots-sk/constants.js b/golden/modules/dots-sk/constants.ts
similarity index 92%
rename from golden/modules/dots-sk/constants.js
rename to golden/modules/dots-sk/constants.ts
index 5fa6916..e995a49 100644
--- a/golden/modules/dots-sk/constants.js
+++ b/golden/modules/dots-sk/constants.ts
@@ -5,8 +5,8 @@
export const DOT_OFFSET_Y = 10;
// Functions that go from dot space to canvas coordinates.
-export const dotToCanvasX = (x) => x * DOT_SCALE_X + DOT_OFFSET_X;
-export const dotToCanvasY = (y) => y * DOT_SCALE_Y + DOT_OFFSET_Y;
+export const dotToCanvasX = (x: number) => x * DOT_SCALE_X + DOT_OFFSET_X;
+export const dotToCanvasY = (y: number) => y * DOT_SCALE_Y + DOT_OFFSET_Y;
// Maximum number of unique digests to display. If the number of unique digests
// exceeds this, they will be grouped together with the last color.
diff --git a/golden/modules/dots-sk/demo_data.js b/golden/modules/dots-sk/demo_data.ts
similarity index 75%
rename from golden/modules/dots-sk/demo_data.js
rename to golden/modules/dots-sk/demo_data.ts
index f1bd629..9601f01 100644
--- a/golden/modules/dots-sk/demo_data.js
+++ b/golden/modules/dots-sk/demo_data.ts
@@ -1,18 +1,22 @@
-// The trace below is based on a subset of a real trace found in Skia Gold.
-// This is what the dots diagram should look like (trace length = 20):
-//
-// +--------------------+
-// |**765334433332211100|
-// | 11-1-1100--0000 |
-// | 22221111110000|
-// +--------------------+
-//
-// Where the numbers represent different colors, and a star (*) represents the
-// special color used for unique digests in excess of MAX_UNIQUE_DIGESTS.
-//
-// Additionally, The numbers above correspond to traces.traces[i].data[j].
-// -1 means "missing digest".
-export const traces = {
+import { Commit, TraceGroup } from '../rpc_types';
+
+/**
+ * The trace below is based on a subset of a real trace found in Skia Gold.
+ * This is what the dots diagram should look like (trace length = 20):
+ *
+ * +--------------------+
+ * |**765334433332211100|
+ * | 11-1-1100--0000 |
+ * | 22221111110000|
+ * +--------------------+
+ *
+ * Where the numbers represent different colors, and a star (*) represents the
+ * special color used for unique digests in excess of MAX_UNIQUE_DIGESTS.
+ *
+ * Additionally, The numbers above correspond to traces.traces[i].data[j].
+ * -1 means "missing digest".
+ */
+export const traces: TraceGroup = {
tileSize: 20,
traces: [{
// Note: the backend tops out at 8, but we should handle values greater than
@@ -24,6 +28,7 @@
beta: 'hello',
gamma: 'world',
},
+ comment_indices: null,
}, {
data: [-1, -1, -1, 1, 1, -1, 1, -1, 1, 1, 0, 0, -1, -1, 0, 0, 0, 0, -1, -1],
label: ',alpha=second-trace,beta=foo,gamma=bar,',
@@ -32,6 +37,7 @@
beta: 'foo',
gamma: 'bar',
},
+ comment_indices: null,
}, {
data: [-1, -1, -1, -1, -1, -1, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
label: ',alpha=third-trace,beta=baz,gamma=qux,',
@@ -40,6 +46,7 @@
beta: 'baz',
gamma: 'qux',
},
+ comment_indices: null,
}],
digests: [{
digest: 'ce0a9d2b546b25e00e39a33860cb72b6',
@@ -72,86 +79,127 @@
digest: '8ad66f50b755d82cd1c08b22e984bbef',
status: 'untriaged',
}],
+ total_digests: 10,
};
-export const commits = [{
+export const commits: Commit[] = [{
commit_time: 1576186931,
hash: '46a331b93f54d8b3bce88792dd8679beef11a751',
author: 'Alpha (alpha@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576186932,
hash: '1521e6b24c19f30eda383bb00b26862894ae9182',
author: 'Beta (beta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576189965,
hash: 'f46d5ca49221113497d41e8f2a3c0c59151f4010',
author: 'Gamma (gamma@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576190315,
hash: 'dcd8e9389d8aa79a389aebad570d340ec012f367',
author: 'Beta (beta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576191335,
hash: '2fc9fa6d08df3b12c764d88f4458d28d4352de9b',
author: 'Epsilon (epsilon@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576192005,
hash: '4d3b4a1bf31afb9d50ac84221a5852fea29a30df',
author: 'Beta (beta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576197935,
hash: '0678df30b5a56375ff6a4c21e6f0ecadb3493b7c',
author: 'Delta (delta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576200535,
hash: '39cdc37bdd0fd63556357a86b43f83fa4211ce0f',
author: 'Beta (beta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576211413,
hash: '252a03454d382b387c8f42aa75cfd63756816713',
author: 'Beta (beta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576213625,
hash: '415bce89a49abb6f53b2d3634159f6d304c8c8b5',
author: 'Beta (beta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576213773,
hash: '7fb7134e7d946d80741f779cfdb10cfd40a1f7a3',
author: 'Beta (beta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576246423,
hash: 'd0840ecf583171e55025d2808dba017910b7a54f',
author: 'Zeta (zeta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576255153,
hash: '81b98978bced13406df91c2f5917cc2b82772f1e',
author: 'Eta (Eta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576258473,
hash: 'a6069a154d66b2620bea1907b0eebf5d1afd02e7',
author: 'Theta (Theta@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576260733,
hash: '1c5be7b19707c54ff859aa9f834a92e14d6ab5b9',
author: 'Epsilon (epsilon@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576260973,
hash: 'ab51c2ce0884a2bb1693d0f15d9eb674800e18ba',
author: 'Iota (iota@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576262533,
hash: 'c9b4d279d235c8db48875f0d0854bfe25c631ff6',
author: 'Kappa (kappa@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576264923,
hash: '1cc767bd0d915bd0f3f5b40dcb282367c9fd9271',
author: 'Gamma (gamma@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576265403,
hash: 'a072b7b2758d644fbd5483a9716f581d270c7560',
author: 'Lambda (lambda@example.com)',
+ message: '',
+ cl_url: '',
}, {
commit_time: 1576265853,
hash: '17e7dfa37734347215f0b6bacb72c06ec85dbfdc',
author: 'Epsilon (epsilon@example.com)',
+ message: '',
+ cl_url: '',
}];
diff --git a/golden/modules/dots-sk/dots-sk-demo.js b/golden/modules/dots-sk/dots-sk-demo.js
deleted file mode 100644
index 857b308..0000000
--- a/golden/modules/dots-sk/dots-sk-demo.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import './index';
-import { $$ } from 'common-sk/modules/dom';
-import { isPuppeteerTest } from '../demo_util';
-import { traces, commits } from './demo_data';
-
-const logEventDetail = (e) => {
- const log = $$('#event-log');
- const entry = `Timestamp: ${new Date().toISOString()}\n`
- + `Event type: ${e.type}\n`
- + `Event detail: ${JSON.stringify(e.detail)}\n\n`;
- log.value = entry + log.value;
-};
-
-const dots = document.createElement('dots-sk');
-dots.value = traces;
-dots.commits = commits;
-dots.addEventListener('show-commits', logEventDetail);
-dots.addEventListener('hover', logEventDetail);
-$$('#container').appendChild(dots);
-
-// Hide event log if we're within a Puppeteer test. We don't need the event log
-// to appear in any screenshots uploaded to Gold.
-if (isPuppeteerTest()) {
- $$('#event-log-container').style.display = 'none';
-}
diff --git a/golden/modules/dots-sk/dots-sk-demo.ts b/golden/modules/dots-sk/dots-sk-demo.ts
new file mode 100644
index 0000000..8122e7b
--- /dev/null
+++ b/golden/modules/dots-sk/dots-sk-demo.ts
@@ -0,0 +1,27 @@
+import './index';
+import { $$ } from 'common-sk/modules/dom';
+import { isPuppeteerTest } from '../demo_util';
+import { traces, commits } from './demo_data';
+import {DotsSk} from './dots-sk';
+
+const logEventDetail = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ const log = $$<HTMLTextAreaElement>('#event-log')!;
+ const entry = `Timestamp: ${new Date().toISOString()}\n`
+ + `Event type: ${e.type}\n`
+ + `Event detail: ${JSON.stringify(detail)}\n\n`;
+ log.value = entry + log.value;
+};
+
+const dotsSk = new DotsSk();
+dotsSk.value = traces;
+dotsSk.commits = commits;
+dotsSk.addEventListener('showblamelist', logEventDetail);
+dotsSk.addEventListener('hover', logEventDetail);
+$$('#container')!.appendChild(dotsSk);
+
+// Hide event log if we're within a Puppeteer test. We don't need the event log
+// to appear in any screenshots uploaded to Gold.
+if (isPuppeteerTest()) {
+ $$<HTMLDivElement>('#event-log-container')!.style.display = 'none';
+}
diff --git a/golden/modules/dots-sk/dots-sk.js b/golden/modules/dots-sk/dots-sk.js
deleted file mode 100644
index 58ca291..0000000
--- a/golden/modules/dots-sk/dots-sk.js
+++ /dev/null
@@ -1,350 +0,0 @@
-/**
- * @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;
- }
-});
diff --git a/golden/modules/dots-sk/dots-sk.ts b/golden/modules/dots-sk/dots-sk.ts
new file mode 100644
index 0000000..0bcc952
--- /dev/null
+++ b/golden/modules/dots-sk/dots-sk.ts
@@ -0,0 +1,347 @@
+/**
+ * @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 } 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);
+
+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 = {tileSize: 0, 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.tileSize === 0)) {
+ 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() {
+ 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. */
+ 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 = 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.
+ */
+ private getColorSafe(colorArray: string[], uniqueDigestIndex: number): string {
+ return colorArray[Math.min(colorArray.length - 1, uniqueDigestIndex)];
+ }
+
+ /** 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('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('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);
diff --git a/golden/modules/dots-sk/dots-sk_test.js b/golden/modules/dots-sk/dots-sk_test.js
deleted file mode 100644
index 49cbba0..0000000
--- a/golden/modules/dots-sk/dots-sk_test.js
+++ /dev/null
@@ -1,307 +0,0 @@
-/* eslint-env browser, mocha */
-/* eslint arrow-body-style: ["off", "as-needed"] */
-import './index';
-import { commits, traces } from './demo_data';
-import {
- dotToCanvasX,
- dotToCanvasY,
- DOT_FILL_COLORS,
- DOT_FILL_COLORS_HIGHLIGHTED,
- DOT_RADIUS,
- DOT_STROKE_COLORS,
- MAX_UNIQUE_DIGESTS,
- TRACE_LINE_COLOR,
-} from './constants';
-import { setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
-
-describe('dots-sk constants', () => {
- it('DOT_FILL_COLORS has the expected number of entries', () => {
- expect(DOT_FILL_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
- });
-
- it('DOT_FILL_COLORS_HIGHLIGHTED has the expected number of entries', () => {
- expect(DOT_FILL_COLORS_HIGHLIGHTED).to.have.length(MAX_UNIQUE_DIGESTS);
- });
-
- it('DOT_STROKE_COLORS has the expected number of entries', () => {
- expect(DOT_STROKE_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
- });
-});
-
-describe('dots-sk', () => {
- const newInstance = setUpElementUnderTest('dots-sk');
-
- let dotsSk;
- beforeEach(() => {
- dotsSk = newInstance((el) => {
- // All test cases use the same set of traces and commits.
- el.value = traces;
- el.commits = commits;
- });
- });
-
- it('renders correctly', () => {
- expect(dotsSk._canvas.clientWidth).to.equal(210);
- expect(dotsSk._canvas.clientHeight).to.equal(40);
- // We specify the traces as an array and then join them instead of using a string literal
- // to avoid having invisible (but important to the test) trailing spaces.
- expect(canvasToAscii(dotsSk)).to.equal([
- 'iihgfddeeddddccbbbaa',
- ' bb-b-bbaa--aaaa ',
- ' ccccbbbbbbaaaa',
- ].join('\n'));
- });
-
- it('highlights traces when hovering', async () => {
- // Hover over first trace. (X coordinate does not matter.)
- await hoverOverDot(dotsSk, 0, 0);
- expect(canvasToAscii(dotsSk)).to.equal([
- 'IIHGFDDEEDDDDCCBBBAA',
- ' bb-b-bbaa--aaaa ',
- ' ccccbbbbbbaaaa',
- ].join('\n'));
-
- // Hover over second trace.
- await hoverOverDot(dotsSk, 15, 1);
- expect(canvasToAscii(dotsSk)).to.equal([
- 'iihgfddeeddddccbbbaa',
- ' BB-B-BBAA--AAAA ',
- ' ccccbbbbbbaaaa',
- ].join('\n'));
-
- // Hover over third trace.
- await hoverOverDot(dotsSk, 10, 2);
- expect(canvasToAscii(dotsSk)).to.equal([
- 'iihgfddeeddddccbbbaa',
- ' bb-b-bbaa--aaaa ',
- ' CCCCBBBBBBAAAA',
- ].join('\n'));
- });
-
- it('emits "hover" event when a trace is hovered', async () => {
- // Hover over first trace. (X coordinate does not matter.)
- let event = await hoverOverDotAndCatchHoverEvent(dotsSk, 0, 0);
- expect(event.detail).to.equal(',alpha=first-trace,beta=hello,gamma=world,');
-
- // Hover over second trace.
- event = await hoverOverDotAndCatchHoverEvent(dotsSk, 15, 1);
- expect(event.detail).to.equal(',alpha=second-trace,beta=foo,gamma=bar,');
-
- // Hover over third trace.
- event = await hoverOverDotAndCatchHoverEvent(dotsSk, 10, 2);
- expect(event.detail).to.equal(',alpha=third-trace,beta=baz,gamma=qux,');
- });
-
- it('emits "showblamelist" event when a dot is clicked', async () => {
- // First trace, most recent commit.
- let event = await clickDotAndCatchShowCommitsEvent(dotsSk, 19, 0);
- expect(event.detail).to.deep.equal([commits[19], commits[18]]);
-
- // First trace, middle-of-the-tile commit.
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 10, 0);
- expect(event.detail).to.deep.equal([commits[10], commits[9]]);
-
- // First trace, oldest commit.
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 0, 0);
- expect(event.detail).to.deep.equal([commits[0]]);
-
- // Second trace, most recent commit with data
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 17, 1);
- expect(event.detail).to.deep.equal([commits[17], commits[16]]);
-
- // Second trace, middle-of-the-tile dot preceded by two missing dots.
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 14, 1);
- expect(event.detail).to.deep.equal([commits[14], commits[13], commits[12], commits[11]]);
-
- // Second trace, oldest commit with data preceded by three missing dots.
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 3, 1);
- expect(event.detail).to.deep.equal(
- [commits[3], commits[2], commits[1], commits[0]],
- );
-
- // Third trace, most recent commit.
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 19, 2);
- expect(event.detail).to.deep.equal([commits[19], commits[18]]);
-
- // Third trace, middle-of-the-tile commit.
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 10, 2);
- expect(event.detail).to.deep.equal([commits[10], commits[9]]);
-
- // Third trace, oldest commit.
- event = await clickDotAndCatchShowCommitsEvent(dotsSk, 6, 2);
- expect(event.detail).to.deep.equal([
- commits[6],
- commits[5],
- commits[4],
- commits[3],
- commits[2],
- commits[1],
- commits[0],
- ]);
- });
-});
-
-// Returns an ASCII-art representation of the canvas based on function
-// dotToAscii.
-function canvasToAscii(dotsSk) {
- const ascii = [];
- for (let y = 0; y < traces.traces.length; y++) {
- const trace = [];
- for (let x = 0; x < traces.tileSize; x++) {
- trace.push(dotToAscii(dotsSk, x, y));
- }
- ascii.push(trace.join(''));
- }
- return ascii.join('\n');
-}
-
-// Returns a character representing the dot at (x, y) in dotspace.
-// - A trace line is represented with '-'.
-// - A non-highlighted dot is represented with a character in {'a', 'b', ...},
-// where 'a' represents the dot color for the most recent commit.
-// - A highlighted dot is represented with a character in {'A', 'B', ...}.
-// - A blank position is represented with ' '.
-function dotToAscii(dotsSk, x, y) {
- const canvasX = dotToCanvasX(x);
- const canvasY = dotToCanvasY(y);
-
- // Sample a few pixels (north, east, south, west, center) from the bounding
- // box for the potential dot at (x, y). We'll use these to determine whether
- // there's a dot or a trace line at (x, y), what the color of the dot is,
- // whether or not it's highlighted, etc.
- const n = pixelAt(dotsSk, canvasX, canvasY - DOT_RADIUS);
- const e = pixelAt(dotsSk, canvasX + DOT_RADIUS, canvasY);
- const s = pixelAt(dotsSk, canvasX, canvasY + DOT_RADIUS);
- const w = pixelAt(dotsSk, canvasX - DOT_RADIUS, canvasY);
- const c = pixelAt(dotsSk, canvasX, canvasY);
-
- // Determines whether the sampled pixels match the given expected colors.
- const exactColorMatch = (en, ee, es, ew, ec) => {
- return [n, e, s, w, c].toString() === [en, ee, es, ew, ec].toString();
- };
-
- // Is it empty?
- const white = '#FFFFFF';
- if (exactColorMatch(white, white, white, white, white)) {
- return ' ';
- }
-
- // Is it a trace line?
- if (exactColorMatch(white, TRACE_LINE_COLOR, white, TRACE_LINE_COLOR, TRACE_LINE_COLOR)) {
- return '-';
- }
-
- // Iterate over all possible dot colors.
- for (let i = 0; i <= MAX_UNIQUE_DIGESTS; i++) {
- // Is it a dot of the i-th color? Let's look at the pixels in the potential
- // circumference of the dot. Do they match the current color?
- // Note: we look for the closest match instead of an exact match due to
- // canvas anti-aliasing.
- if (closestColor(n, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
- && closestColor(e, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
- && closestColor(s, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
- && closestColor(w, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]) {
- // Is it a non-highlighted dot? (In other words, is it filled with the
- // corresponding non-highlighted color?)
- if (c === DOT_FILL_COLORS[i]) {
- return 'abcdefghijklmnopqrstuvwxyz'[i];
- }
-
- // Is it a highlighted dot? (In other words, is it filled with the
- // corresponding highlighted color?)
- if (c === DOT_FILL_COLORS_HIGHLIGHTED[i]) {
- return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i];
- }
- }
- }
-
- throw `unrecognized dot at (${x}, ${y})`;
-}
-
-// Returns the color for the pixel at (x, y) in the canvas, represented as a hex
-// string, e.g. "#AABBCC".
-function pixelAt(dotsSk, x, y) {
- const pixel = dotsSk._ctx.getImageData(x, y, 1, 1).data;
- const r = pixel[0].toString(16).padStart(2, '0');
- const g = pixel[1].toString(16).padStart(2, '0');
- const b = pixel[2].toString(16).padStart(2, '0');
- return `#${r}${g}${b}`.toUpperCase();
-}
-
-// Finds the color in the haystack with the minimum Euclidean distance to the
-// needle. This is necessary for pixels in the circumference of a dot due to
-// canvas anti-aliasing. All colors are hex strings, e.g. "#AABBCC".
-function closestColor(needle, haystack) {
- return haystack
- .map((color) => ({ color: color, dist: euclideanDistanceSq(needle, color) }))
- .reduce((acc, cur) => ((acc.dist < cur.dist) ? acc : cur))
- .color;
-}
-
-// Takes two colors represented as hex strings (e.g. "#AABBCC") and computes the
-// squared Euclidean distance between them.
-function euclideanDistanceSq(color1, color2) {
- const rgb1 = hexToRgb(color1);
- const rgb2 = hexToRgb(color2);
- return (rgb1[0] - rgb2[0]) ** 2 + (rgb1[1] - rgb2[1]) ** 2 + (rgb1[2] - rgb2[2]) ** 2;
-}
-
-// Takes e.g. "#FF8000" and returns [256, 128, 0].
-function hexToRgb(hex) {
- // Borrowed from https://stackoverflow.com/a/5624139.
- const res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
- return [
- parseInt(res[1], 16),
- parseInt(res[2], 16),
- parseInt(res[3], 16),
- ];
-}
-
-// Returns a promise that will resolve when the given dots-sk instance emits the
-// given event. The promise resolves to the caught event object.
-function dotsSkEventPromise(dotsSk, event) {
- let resolve;
- const promise = new Promise((_resolve) => { resolve = _resolve; });
- const handler = (e) => {
- dotsSk.removeEventListener(event, handler);
- resolve(e);
- };
- dotsSk.addEventListener(event, handler);
- return promise;
-}
-
-// Simulate hovering over a dot.
-async function hoverOverDot(dotsSk, x, y) {
- dotsSk._canvas.dispatchEvent(new MouseEvent('mousemove', {
- clientX: dotsSk._canvas.getBoundingClientRect().left + dotToCanvasX(x),
- clientY: dotsSk._canvas.getBoundingClientRect().top + dotToCanvasY(y),
- }));
-
- // Give mousemove event a chance to be processed. Necessary due to how
- // mousemove events are processed in batches by dots-sk every 40 ms.
- await new Promise((resolve) => setTimeout(resolve, 50));
-}
-
-// Simulate hovering over a dot, and return the "hover" CustomEvent emitted by
-// the dots-sk instance.
-async function hoverOverDotAndCatchHoverEvent(dotsSk, x, y) {
- const eventPromise = dotsSkEventPromise(dotsSk, 'hover');
- await hoverOverDot(dotsSk, x, y);
- return eventPromise;
-}
-
-// Simulate clicking on a dot.
-function clickDot(dotsSk, x, y) {
- dotsSk._canvas.dispatchEvent(new MouseEvent('click', {
- clientX: dotsSk._canvas.getBoundingClientRect().left + dotToCanvasX(x),
- clientY: dotsSk._canvas.getBoundingClientRect().top + dotToCanvasY(y),
- }));
-}
-
-// Simulate clicking on a dot, and return the "showblamelist" CustomElement
-// emitted by the dots-sk instance.
-async function clickDotAndCatchShowCommitsEvent(dotsSk, x, y) {
- const eventPromise = dotsSkEventPromise(dotsSk, 'showblamelist');
- clickDot(dotsSk, x, y);
- return eventPromise;
-}
diff --git a/golden/modules/dots-sk/dots-sk_test.ts b/golden/modules/dots-sk/dots-sk_test.ts
new file mode 100644
index 0000000..a730d7d
--- /dev/null
+++ b/golden/modules/dots-sk/dots-sk_test.ts
@@ -0,0 +1,301 @@
+import './index';
+import { DotsSk } from './dots-sk';
+import { commits, traces } from './demo_data';
+import {
+ dotToCanvasX,
+ dotToCanvasY,
+ DOT_FILL_COLORS,
+ DOT_FILL_COLORS_HIGHLIGHTED,
+ DOT_RADIUS,
+ DOT_STROKE_COLORS,
+ MAX_UNIQUE_DIGESTS,
+ TRACE_LINE_COLOR,
+} from './constants';
+import { eventPromise, setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
+import { expect } from 'chai';
+import { Commit } from '../rpc_types';
+
+describe('dots-sk constants', () => {
+ it('DOT_FILL_COLORS has the expected number of entries', () => {
+ expect(DOT_FILL_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
+ });
+
+ it('DOT_FILL_COLORS_HIGHLIGHTED has the expected number of entries', () => {
+ expect(DOT_FILL_COLORS_HIGHLIGHTED).to.have.length(MAX_UNIQUE_DIGESTS);
+ });
+
+ it('DOT_STROKE_COLORS has the expected number of entries', () => {
+ expect(DOT_STROKE_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
+ });
+});
+
+describe('dots-sk', () => {
+ const newInstance = setUpElementUnderTest<DotsSk>('dots-sk');
+
+ let dotsSk: DotsSk;
+ let dotsSkCanvas: HTMLCanvasElement;
+ let dotsSkCanvasCtx: CanvasRenderingContext2D;
+
+ beforeEach(() => {
+ dotsSk = newInstance((el) => {
+ // All test cases use the same set of traces and commits.
+ el.value = traces;
+ el.commits = commits;
+ });
+ dotsSkCanvas = dotsSk.querySelector('canvas')!;
+ dotsSkCanvasCtx = dotsSkCanvas.getContext('2d')!;
+ });
+
+ it('renders correctly', () => {
+ expect(dotsSkCanvas.clientWidth).to.equal(210);
+ expect(dotsSkCanvas.clientHeight).to.equal(40);
+ // We specify the traces as an array and then join them instead of using a string literal
+ // to avoid having invisible (but important to the test) trailing spaces.
+ expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+ 'iihgfddeeddddccbbbaa',
+ ' bb-b-bbaa--aaaa ',
+ ' ccccbbbbbbaaaa',
+ ].join('\n'));
+ });
+
+ it('highlights traces when hovering', async () => {
+ // Hover over first trace. (X coordinate does not matter.)
+ await hoverOverDot(dotsSkCanvas, 0, 0);
+ expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+ 'IIHGFDDEEDDDDCCBBBAA',
+ ' bb-b-bbaa--aaaa ',
+ ' ccccbbbbbbaaaa',
+ ].join('\n'));
+
+ // Hover over second trace.
+ await hoverOverDot(dotsSkCanvas, 15, 1);
+ expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+ 'iihgfddeeddddccbbbaa',
+ ' BB-B-BBAA--AAAA ',
+ ' ccccbbbbbbaaaa',
+ ].join('\n'));
+
+ // Hover over third trace.
+ await hoverOverDot(dotsSkCanvas, 10, 2);
+ expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+ 'iihgfddeeddddccbbbaa',
+ ' bb-b-bbaa--aaaa ',
+ ' CCCCBBBBBBAAAA',
+ ].join('\n'));
+ });
+
+ it('emits "hover" event when a trace is hovered', async () => {
+ // Hover over first trace. (X coordinate does not matter.)
+ let traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 0, 0);
+ expect(traceLabel).to.equal(',alpha=first-trace,beta=hello,gamma=world,');
+
+ // Hover over second trace.
+ traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 15, 1);
+ expect(traceLabel).to.equal(',alpha=second-trace,beta=foo,gamma=bar,');
+
+ // Hover over third trace.
+ traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 10, 2);
+ expect(traceLabel).to.equal(',alpha=third-trace,beta=baz,gamma=qux,');
+ });
+
+ it('emits "showblamelist" event when a dot is clicked', async () => {
+ // First trace, most recent commit.
+ let dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 19, 0);
+ expect(dotCommits).to.deep.equal([commits[19], commits[18]]);
+
+ // First trace, middle-of-the-tile commit.
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 10, 0);
+ expect(dotCommits).to.deep.equal([commits[10], commits[9]]);
+
+ // First trace, oldest commit.
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 0, 0);
+ expect(dotCommits).to.deep.equal([commits[0]]);
+
+ // Second trace, most recent commit with data
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 17, 1);
+ expect(dotCommits).to.deep.equal([commits[17], commits[16]]);
+
+ // Second trace, middle-of-the-tile dot preceded by two missing dots.
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 14, 1);
+ expect(dotCommits).to.deep.equal([commits[14], commits[13], commits[12], commits[11]]);
+
+ // Second trace, oldest commit with data preceded by three missing dots.
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 3, 1);
+ expect(dotCommits).to.deep.equal(
+ [commits[3], commits[2], commits[1], commits[0]],
+ );
+
+ // Third trace, most recent commit.
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 19, 2);
+ expect(dotCommits).to.deep.equal([commits[19], commits[18]]);
+
+ // Third trace, middle-of-the-tile commit.
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 10, 2);
+ expect(dotCommits).to.deep.equal([commits[10], commits[9]]);
+
+ // Third trace, oldest commit.
+ dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 6, 2);
+ expect(dotCommits).to.deep.equal([
+ commits[6],
+ commits[5],
+ commits[4],
+ commits[3],
+ commits[2],
+ commits[1],
+ commits[0],
+ ]);
+ });
+});
+
+// Returns an ASCII-art representation of the canvas based on function
+// dotToAscii.
+function canvasToAscii(dotsSkCanvasCtx: CanvasRenderingContext2D): string {
+ const ascii = [];
+ for (let y = 0; y < traces.traces!.length; y++) {
+ const trace = [];
+ for (let x = 0; x < traces.tileSize; x++) {
+ trace.push(dotToAscii(dotsSkCanvasCtx, x, y));
+ }
+ ascii.push(trace.join(''));
+ }
+ return ascii.join('\n');
+}
+
+// Returns a character representing the dot at (x, y) in dotspace.
+// - A trace line is represented with '-'.
+// - A non-highlighted dot is represented with a character in {'a', 'b', ...},
+// where 'a' represents the dot color for the most recent commit.
+// - A highlighted dot is represented with a character in {'A', 'B', ...}.
+// - A blank position is represented with ' '.
+function dotToAscii(dotsSkCanvasCtx: CanvasRenderingContext2D, x: number, y: number): string {
+ const canvasX = dotToCanvasX(x);
+ const canvasY = dotToCanvasY(y);
+
+ // Sample a few pixels (north, east, south, west, center) from the bounding
+ // box for the potential dot at (x, y). We'll use these to determine whether
+ // there's a dot or a trace line at (x, y), what the color of the dot is,
+ // whether or not it's highlighted, etc.
+ const n = pixelAt(dotsSkCanvasCtx, canvasX, canvasY - DOT_RADIUS);
+ const e = pixelAt(dotsSkCanvasCtx, canvasX + DOT_RADIUS, canvasY);
+ const s = pixelAt(dotsSkCanvasCtx, canvasX, canvasY + DOT_RADIUS);
+ const w = pixelAt(dotsSkCanvasCtx, canvasX - DOT_RADIUS, canvasY);
+ const c = pixelAt(dotsSkCanvasCtx, canvasX, canvasY);
+
+ // Determines whether the sampled pixels match the given expected colors.
+ const exactColorMatch = (en: string, ee: string, es: string, ew: string, ec: string) => {
+ return [n, e, s, w, c].toString() === [en, ee, es, ew, ec].toString();
+ };
+
+ // Is it empty?
+ const white = '#FFFFFF';
+ if (exactColorMatch(white, white, white, white, white)) {
+ return ' ';
+ }
+
+ // Is it a trace line?
+ if (exactColorMatch(white, TRACE_LINE_COLOR, white, TRACE_LINE_COLOR, TRACE_LINE_COLOR)) {
+ return '-';
+ }
+
+ // Iterate over all possible dot colors.
+ for (let i = 0; i <= MAX_UNIQUE_DIGESTS; i++) {
+ // Is it a dot of the i-th color? Let's look at the pixels in the potential
+ // circumference of the dot. Do they match the current color?
+ // Note: we look for the closest match instead of an exact match due to
+ // canvas anti-aliasing.
+ if (closestColor(n, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
+ && closestColor(e, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
+ && closestColor(s, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
+ && closestColor(w, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]) {
+ // Is it a non-highlighted dot? (In other words, is it filled with the
+ // corresponding non-highlighted color?)
+ if (c === DOT_FILL_COLORS[i]) {
+ return 'abcdefghijklmnopqrstuvwxyz'[i];
+ }
+
+ // Is it a highlighted dot? (In other words, is it filled with the
+ // corresponding highlighted color?)
+ if (c === DOT_FILL_COLORS_HIGHLIGHTED[i]) {
+ return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i];
+ }
+ }
+ }
+
+ throw `unrecognized dot at (${x}, ${y})`;
+}
+
+// Returns the color for the pixel at (x, y) in the canvas, represented as a hex
+// string, e.g. "#AABBCC".
+function pixelAt(dotsSkCanvasCtx: CanvasRenderingContext2D, x: number, y: number): string {
+ const pixel = dotsSkCanvasCtx.getImageData(x, y, 1, 1).data;
+ const r = pixel[0].toString(16).padStart(2, '0');
+ const g = pixel[1].toString(16).padStart(2, '0');
+ const b = pixel[2].toString(16).padStart(2, '0');
+ return `#${r}${g}${b}`.toUpperCase();
+}
+
+// Finds the color in the haystack with the minimum Euclidean distance to the
+// needle. This is necessary for pixels in the circumference of a dot due to
+// canvas anti-aliasing. All colors are hex strings, e.g. "#AABBCC".
+function closestColor(needle: string, haystack: string[]): string {
+ return haystack
+ .map((color) => ({ color: color, dist: euclideanDistanceSq(needle, color) }))
+ .reduce((acc, cur) => ((acc.dist < cur.dist) ? acc : cur))
+ .color;
+}
+
+// Takes two colors represented as hex strings (e.g. "#AABBCC") and computes the
+// squared Euclidean distance between them.
+function euclideanDistanceSq(color1: string, color2: string): number {
+ const rgb1 = hexToRgb(color1);
+ const rgb2 = hexToRgb(color2);
+ return (rgb1[0] - rgb2[0]) ** 2 + (rgb1[1] - rgb2[1]) ** 2 + (rgb1[2] - rgb2[2]) ** 2;
+}
+
+// Takes e.g. "#FF8000" and returns [256, 128, 0].
+function hexToRgb(hex: string): [number, number, number] {
+ // Borrowed from https://stackoverflow.com/a/5624139.
+ const res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ return [
+ parseInt(res[1], 16),
+ parseInt(res[2], 16),
+ parseInt(res[3], 16),
+ ];
+}
+
+// Simulate hovering over a dot.
+async function hoverOverDot(dotsSkCanvas: HTMLCanvasElement, x: number, y: number) {
+ dotsSkCanvas.dispatchEvent(new MouseEvent('mousemove', {
+ clientX: dotsSkCanvas.getBoundingClientRect().left + dotToCanvasX(x),
+ clientY: dotsSkCanvas.getBoundingClientRect().top + dotToCanvasY(y),
+ }));
+
+ // Give mousemove event a chance to be processed. Necessary due to how
+ // mousemove events are processed in batches by dots-sk every 40 ms.
+ await new Promise((resolve) => setTimeout(resolve, 50));
+}
+
+// Simulate hovering over a dot, and return the trace label in the "hover" event details.
+async function hoverOverDotAndCatchHoverEvent(
+ dotsSkCanvas: HTMLCanvasElement, x: number, y: number): Promise<string> {
+ // const eventPromise = dotsSkEventPromise(dotsSk, 'hover');
+ const event = eventPromise<CustomEvent<string>>('hover');
+ await hoverOverDot(dotsSkCanvas, x, y);
+ return (await event).detail;
+}
+
+// Simulate clicking on a dot.
+function clickDot(dotsSkCanvas: HTMLCanvasElement, x: number, y: number) {
+ dotsSkCanvas.dispatchEvent(new MouseEvent('click', {
+ clientX: dotsSkCanvas.getBoundingClientRect().left + dotToCanvasX(x),
+ clientY: dotsSkCanvas.getBoundingClientRect().top + dotToCanvasY(y),
+ }));
+}
+
+// Simulate clicking on a dot, and return the list of commits in the "showblamelist" event details.
+async function clickDotAndCatchShowBlamelistEvent(
+ dotsSkCanvas: HTMLCanvasElement, x: number, y: number): Promise<Commit[]> {
+ const event = eventPromise<CustomEvent<Commit[]>>('showblamelist');
+ clickDot(dotsSkCanvas, x, y);
+ return (await event).detail;
+}
diff --git a/golden/modules/dots-sk/index.js b/golden/modules/dots-sk/index.ts
similarity index 100%
rename from golden/modules/dots-sk/index.js
rename to golden/modules/dots-sk/index.ts