[perf] Reflect the selected point on a trace into the URL.

Also displays the xbar at the location of the click to make it easier
to track which point is being displayed in the 'Details' section.

Bug: skia:13725
Change-Id: I704d11a9d96a3b61088d7c062f8b0274ee7241fe
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/578676
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
diff --git a/perf/modules/explore-sk/explore-sk.ts b/perf/modules/explore-sk/explore-sk.ts
index d1b1186..d4dc1ac 100644
--- a/perf/modules/explore-sk/explore-sk.ts
+++ b/perf/modules/explore-sk/explore-sk.ts
@@ -48,6 +48,7 @@
   progress,
   pivot,
   FrameResponseDisplayMode,
+  ColumnHeader,
 } from '../json';
 import {
   PlotSimpleSk,
@@ -115,6 +116,48 @@
   summary: [],
 });
 
+// Stores the trace name and commit number of a single point on a trace.
+export interface PointSelected {
+  commit: number
+  name: string
+}
+
+/** Returns true if the PointSelected is valid. */
+export const isValidSelection = (p: PointSelected): boolean => p.name !== '';
+
+/** Converts a PointSelected into a CustomEvent<PlotSimpleSkTraceEventDetails>,
+ * so that it can be passed into traceSelected().
+ *
+ * Note that we need the _dataframe.header to convert the commit back into an
+ * offset. Also note that might fail, in which case the 'x' value will be set to
+ * -1.
+ */
+export const selectionToEvent = (p: PointSelected, header: (ColumnHeader | null)[] | null): CustomEvent<PlotSimpleSkTraceEventDetails> => {
+  let x = -1;
+  if (header !== null) {
+    // Find the index of the ColumnHeader that matches the commit.
+    x = header.findIndex((h: ColumnHeader | null) => {
+      if (h === null) {
+        return false;
+      }
+      return (h.offset === p.commit);
+    });
+  }
+  return new CustomEvent<PlotSimpleSkTraceEventDetails>('', {
+    detail: {
+      x: x,
+      y: 0,
+      name: p.name,
+    },
+  });
+};
+
+/** Returns a default value for PointSelected. */
+export const defaultPointSelected = (): PointSelected => ({
+  commit: 0,
+  name: '',
+});
+
 // State is reflected to the URL via stateReflector.
 class State {
   begin: number = Math.floor(Date.now() / 1000 - DEFAULT_RANGE_S);
@@ -144,6 +187,8 @@
   sort: string = '' // Pivot table sort order.
 
   summary: boolean = false; // Whether to show the zoom/summary area.
+
+  selected: PointSelected = defaultPointSelected(); // The point on a trace that was clicked on.
 }
 
 // TODO(jcgregorio) Move to a 'key' module.
@@ -860,18 +905,28 @@
   /** Highlight a trace when it is clicked on. */
   private traceSelected(e: CustomEvent<PlotSimpleSkTraceEventDetails>) {
     this.plot!.highlight = [e.detail.name];
+    this.plot!.xbar = e.detail.x;
     this.commits!.details = [];
 
     const x = e.detail.x;
+
+    if (x < 0) {
+      return;
+    }
     // loop backwards from x until you get the next
     // non MISSING_DATA_SENTINEL point.
-    const commits = [this._dataframe.header![x]?.offset];
+    const commit = this._dataframe.header![x]?.offset;
+    if (!commit) {
+      return;
+    }
+
+    const commits = [commit];
     const trace = this._dataframe.traceset[e.detail.name];
     for (let i = x - 1; i >= 0; i--) {
       if (trace![i] !== MISSING_DATA_SENTINEL) {
         break;
       }
-      commits.push(this._dataframe.header![i]?.offset);
+      commits.push(this._dataframe.header![i]!.offset);
     }
     // Convert the trace id into a paramset to display.
     const params: { [key: string]: string } = toObject(e.detail.name);
@@ -882,6 +937,10 @@
 
     this._render();
 
+    this.state.selected.name = e.detail.name;
+    this.state.selected.commit = commit;
+    this._stateHasChanged();
+
     // Request populated commits from the server.
     fetch('/_/cid/', {
       method: 'POST',
@@ -907,6 +966,16 @@
       .catch(errorMessage);
   }
 
+  private clearSelectedState() {
+    // Switch back to the params tab since we are about to hide the details tab.
+    this.detailTab!.selected = PARAMS_TAB_INDEX;
+    this.commitsTab!.disabled = true;
+    this.plot!.highlight = [];
+    this.plot!.xbar = -1;
+    this.state.selected = defaultPointSelected();
+    this._stateHasChanged();
+  }
+
   private startStateReflector() {
     this._stateHasChanged = stateReflector(
       () => (this.state as unknown) as HintableObject,
@@ -1017,6 +1086,16 @@
       this.plot!.removeAll();
       this.addTraces(json, switchToTab);
       this._render();
+      if (isValidSelection(this.state.selected)) {
+        const e = selectionToEvent(this.state.selected, this._dataframe.header);
+        // If the range has moved to no longer include the selected commit then
+        // clear the selection.
+        if (e.detail.x === -1) {
+          this.clearSelectedState();
+        } else {
+          this.traceSelected(e);
+        }
+      }
     });
   }
 
@@ -1244,6 +1323,7 @@
     this.displayMode = 'display_query_only';
     this._render();
     if (!skipHistory) {
+      this.clearSelectedState();
       this._stateHasChanged();
     }
   }
@@ -1279,6 +1359,7 @@
       .then((json) => {
         this.state.keys = json.id;
         this.state.queries = [];
+        this.clearSelectedState();
         this._stateHasChanged();
         this._render();
       })
diff --git a/perf/modules/explore-sk/explore-sk_test.ts b/perf/modules/explore-sk/explore-sk_test.ts
index 9045732..8802e66 100644
--- a/perf/modules/explore-sk/explore-sk_test.ts
+++ b/perf/modules/explore-sk/explore-sk_test.ts
@@ -1,8 +1,10 @@
 /* eslint-disable dot-notation */
 import { assert } from 'chai';
 import fetchMock from 'fetch-mock';
-import { FrameRequest, progress } from '../json';
-import { calculateRangeChange, ExploreSk } from './explore-sk';
+import { ColumnHeader, progress } from '../json';
+import {
+  calculateRangeChange, defaultPointSelected, ExploreSk, isValidSelection, PointSelected, selectionToEvent,
+} from './explore-sk';
 
 fetchMock.config.overwriteRoutes = true;
 
@@ -113,3 +115,69 @@
     fetchMock.restore();
   });
 });
+
+describe('PointSelected', () => {
+  it('defaults to not having a name', () => {
+    const p = defaultPointSelected();
+    assert.isEmpty(p.name);
+  });
+
+  it('defaults to being invalid', () => {
+    const p = defaultPointSelected();
+    assert.isFalse(isValidSelection(p));
+  });
+
+  it('becomes a valid event if the commit appears in the header', () => {
+    const header: ColumnHeader[] = [
+      {
+        offset: 99,
+        timestamp: 0,
+      },
+      {
+        offset: 100,
+        timestamp: 0,
+      },
+      {
+        offset: 101,
+        timestamp: 0,
+      },
+    ];
+
+    const p: PointSelected = {
+      commit: 100,
+      name: 'foo',
+    };
+    // selectionToEvent will look up the commit (aka offset) in header and
+    // should return an event where the 'x' value is the index of the matching
+    // ColumnHeader in 'header', i.e. 1.
+    const e = selectionToEvent(p, header);
+    assert.equal(e.detail.x, 1);
+  });
+
+  it('becomes an invalid event if the commit does not appear in the header', () => {
+    const header: ColumnHeader[] = [
+      {
+        offset: 99,
+        timestamp: 0,
+      },
+      {
+        offset: 100,
+        timestamp: 0,
+      },
+      {
+        offset: 101,
+        timestamp: 0,
+      },
+    ];
+
+    const p: PointSelected = {
+      commit: 102,
+      name: 'foo',
+    };
+    // selectionToEvent will look up the commit (aka offset) in header and
+    // should return an event where the 'x' value is -1 since the matching
+    // ColumnHeader in 'header' doesn't exist.
+    const e = selectionToEvent(p, header);
+    assert.equal(e.detail.x, -1);
+  });
+});
diff --git a/perf/modules/plot-simple-sk/plot-simple-sk.ts b/perf/modules/plot-simple-sk/plot-simple-sk.ts
index a20029a..61b04e4 100644
--- a/perf/modules/plot-simple-sk/plot-simple-sk.ts
+++ b/perf/modules/plot-simple-sk/plot-simple-sk.ts
@@ -960,6 +960,7 @@
   removeAll(): void {
     this.lineData = [];
     this.labels = [];
+    this.highlight = [];
     this.hoverPt = {
       x: -1,
       y: -1,