Implement pagination for the multi graph view.

- Use pagination-sk from golden to create pagination support for multi graph view. In a future CL, I think we should move the pagination-sk out to infra-sk/modules for better sharing. Not doing it in this CL to avoid cluttering.

Demo: https://screencast.googleplex.com/cast/NTA0MTg2Mzk1NzQxMzg4OHw2OGNiMWNhMy0wZQ
Bug: b/323256991
Change-Id: I8ffe27fd6aef4749e07a565f91a29f625a7959e6
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/816210
Commit-Queue: Ashwin Verleker <ashwinpv@google.com>
Reviewed-by: Eduardo Yap <eduardoyap@google.com>
diff --git a/golden/modules/pagination-sk/pagination-sk.ts b/golden/modules/pagination-sk/pagination-sk.ts
index 20e6988..bb33d4c 100644
--- a/golden/modules/pagination-sk/pagination-sk.ts
+++ b/golden/modules/pagination-sk/pagination-sk.ts
@@ -109,7 +109,7 @@
   }
 
   private _canGoNext(next: number) {
-    return this.total === MANY ? true : next <= this.total;
+    return this.total === MANY ? true : next < this.total;
   }
 
   private _page(n: number) {
diff --git a/perf/modules/explore-multi-sk/BUILD.bazel b/perf/modules/explore-multi-sk/BUILD.bazel
index cdc8a1f..8981c87 100644
--- a/perf/modules/explore-multi-sk/BUILD.bazel
+++ b/perf/modules/explore-multi-sk/BUILD.bazel
@@ -5,13 +5,13 @@
     sass_srcs = ["explore-multi-sk.scss"],
     sk_element_deps = [
         "//perf/modules/explore-simple-sk",
+        "//golden/modules/pagination-sk",
     ],
     ts_deps = [
         "//elements-sk/modules:define_ts_lib",
         "//perf/modules/errorMessage:index_ts_lib",
         "//infra-sk/modules:hintable_ts_lib",
         "//infra-sk/modules:statereflector_ts_lib",
-        "//infra-sk/modules:query_ts_lib",
         "//infra-sk/modules/ElementSk:index_ts_lib",
         "//perf/modules/paramtools:index_ts_lib",
         "//:node_modules/lit-html",
diff --git a/perf/modules/explore-multi-sk/explore-multi-sk.scss b/perf/modules/explore-multi-sk/explore-multi-sk.scss
index 5b66060..5add39d 100644
--- a/perf/modules/explore-multi-sk/explore-multi-sk.scss
+++ b/perf/modules/explore-multi-sk/explore-multi-sk.scss
@@ -7,4 +7,15 @@
   #menu {
     margin: 1em;
   }
+
+  label > span.prefix {
+    display: inline-block;
+    width: 100px;
+  }
+
+  label > input {
+    color: var(--on-surface);
+    background: var(--surface-1dp);
+    width: 64px;
+  }
 }
diff --git a/perf/modules/explore-multi-sk/explore-multi-sk.ts b/perf/modules/explore-multi-sk/explore-multi-sk.ts
index eb34e96..5b0db86 100644
--- a/perf/modules/explore-multi-sk/explore-multi-sk.ts
+++ b/perf/modules/explore-multi-sk/explore-multi-sk.ts
@@ -13,7 +13,6 @@
  *
  */
 import { html } from 'lit-html';
-import * as query from '../../../infra-sk/modules/query';
 import { define } from '../../../elements-sk/modules/define';
 import {
   DEFAULT_RANGE_S,
@@ -29,9 +28,10 @@
 import { ElementSk } from '../../../infra-sk/modules/ElementSk';
 
 import '../explore-simple-sk';
-import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
+import '../../../golden/modules/pagination-sk/pagination-sk';
 
-const GRAPH_LIMIT = 50;
+import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
+import { PaginationSkPageChangedEventDetail } from '../../../golden/modules/pagination-sk/pagination-sk';
 
 class State {
   begin: number = Math.floor(Date.now() / 1000 - DEFAULT_RANGE_S);
@@ -47,6 +47,12 @@
   numCommits: number = 250;
 
   summary: boolean = false;
+
+  pageSize: number = 10;
+
+  pageOffset: number = 0;
+
+  totalGraphs: number = 0;
 }
 
 class GraphConfig {
@@ -62,6 +68,10 @@
 
   private exploreElements: ExploreSimpleSk[] = [];
 
+  private currentPageExploreElements: ExploreSimpleSk[] = [];
+
+  private currentPageGraphConfigs: GraphConfig[] = [];
+
   private stateHasChanged: (() => void) | null = null;
 
   private _state: State = new State();
@@ -70,6 +80,8 @@
 
   private mergeGraphsButton: HTMLButtonElement | null = null;
 
+  private graphDiv: Element | null = null;
+
   constructor() {
     super(ExploreMultiSk.template);
   }
@@ -78,6 +90,7 @@
     super.connectedCallback();
     this._render();
 
+    this.graphDiv = this.querySelector('#graphContainer');
     this.splitGraphButton = this.querySelector('#split-graph-button');
     this.mergeGraphsButton = this.querySelector('#merge-graphs-button');
 
@@ -96,43 +109,26 @@
           }
         }
 
+        // This loop helps get rid of extra graphs that aren't part of the
+        // current config. A scenario where this occurs is if we have 1 graph,
+        // add another graph and then go back in the browser.
+        while (this.exploreElements.length > graphConfigs.length) {
+          this.exploreElements.pop();
+          this.graphConfigs.pop();
+          this.graphDiv!.removeChild(this.graphDiv!.lastChild!);
+        }
+
         for (let i = 0; i < graphConfigs.length; i++) {
           if (i >= numElements) {
             this.addEmptyGraph();
           }
           this.graphConfigs[i] = graphConfigs[i];
         }
-        while (this.exploreElements.length > graphConfigs.length) {
-          this.popGraph();
-        }
 
         this.state = state;
+        this.addGraphsToCurrentPage();
+
         this.updateButtons();
-
-        this.exploreElements.forEach((elem, i) => {
-          const graphConfig = this.graphConfigs[i];
-
-          const newState: ExploreState = {
-            formulas: graphConfig.formulas,
-            queries: graphConfig.queries,
-            keys: graphConfig.keys,
-            begin: state.begin,
-            end: state.end,
-            showZero: state.showZero,
-            dots: state.dots,
-            numCommits: state.numCommits,
-            summary: state.summary,
-            xbaroffset: elem.state.xbaroffset,
-            autoRefresh: elem.state.autoRefresh,
-            requestType: elem.state.requestType,
-            pivotRequest: elem.state.pivotRequest,
-            sort: elem.state.sort,
-            selected: elem.state.selected,
-            _incremental: false,
-            labelMode: LabelMode.Date,
-          };
-          elem.state = newState;
-        });
       }
     );
   }
@@ -144,6 +140,7 @@
         @click=${() => {
           const explore = ele.addEmptyGraph();
           if (explore) {
+            ele.updatePageForNewExplore();
             explore.openQuery();
           }
         }}
@@ -168,31 +165,96 @@
       </button>
     </div>
     <hr />
+
+    <pagination-sk
+      offset=${ele.state.pageOffset}
+      page_size=${ele.state.pageSize}
+      total=${ele.state.totalGraphs}
+      @page-changed=${ele.pageChanged}>
+    </pagination-sk>
+    <label>
+      <span class="prefix">Charts per page</span>
+      <input
+        @change=${ele.pageSizeChanged}
+        type="number"
+        .value="${ele.state.pageSize.toString()}"
+        min="1"
+        max="50"
+        title="The number of charts per page." />
+    </label>
     <div id="graphContainer"></div>
+    <pagination-sk
+      offset=${ele.state.pageOffset}
+      page_size=${ele.state.pageSize}
+      total=${ele.state.totalGraphs}
+      @page-changed=${ele.pageChanged}>
+    </pagination-sk>
   `;
 
-  private popGraph() {
-    const graphDiv: Element | null = this.querySelector('#graphContainer');
-
-    this.exploreElements.pop();
-    this.graphConfigs.pop();
+  private clearGraphs() {
+    this.exploreElements = [];
+    this.graphConfigs = [];
     this.updateButtons();
-    graphDiv!.removeChild(graphDiv!.lastChild!);
   }
 
-  private clearGraphs() {
-    while (this.exploreElements.length > 0) {
-      this.popGraph();
+  private emptyCurrentPage(): void {
+    while (this.graphDiv!.hasChildNodes()) {
+      this.graphDiv!.removeChild(this.graphDiv!.lastChild!);
     }
+    this.currentPageExploreElements = [];
+    this.currentPageGraphConfigs = [];
+  }
+
+  private addGraphsToCurrentPage(): void {
+    this.state.totalGraphs = this.exploreElements.length;
+    this.emptyCurrentPage();
+    const startIndex = this.state.pageOffset;
+    let endIndex = startIndex + this.state.pageSize - 1;
+    if (this.exploreElements.length <= endIndex) {
+      endIndex = this.exploreElements.length - 1;
+    }
+
+    for (let i = startIndex; i <= endIndex; i++) {
+      this.graphDiv!.appendChild(this.exploreElements[i]);
+      this.currentPageExploreElements.push(this.exploreElements[i]);
+      this.currentPageGraphConfigs.push(this.graphConfigs[i]);
+    }
+
+    this.currentPageExploreElements.forEach((elem, i) => {
+      const graphConfig = this.currentPageGraphConfigs[i];
+      this.addStateToExplore(elem, graphConfig);
+    });
+
+    this._render();
+  }
+
+  private addStateToExplore(
+    explore: ExploreSimpleSk,
+    graphConfig: GraphConfig
+  ) {
+    const newState: ExploreState = {
+      formulas: graphConfig.formulas || [],
+      queries: graphConfig.queries || [],
+      keys: graphConfig.keys || '',
+      begin: this.state.begin,
+      end: this.state.end,
+      showZero: this.state.showZero,
+      dots: this.state.dots,
+      numCommits: this.state.numCommits,
+      summary: this.state.summary,
+      xbaroffset: explore.state.xbaroffset,
+      autoRefresh: explore.state.autoRefresh,
+      requestType: explore.state.requestType,
+      pivotRequest: explore.state.pivotRequest,
+      sort: explore.state.sort,
+      selected: explore.state.selected,
+      _incremental: false,
+      labelMode: LabelMode.Date,
+    };
+    explore.state = newState;
   }
 
   private addEmptyGraph(): ExploreSimpleSk | null {
-    if (this.exploreElements.length >= GRAPH_LIMIT) {
-      errorMessage(`Cannot exceed display limit of ${GRAPH_LIMIT} graphs.`);
-      return null;
-    }
-
-    const graphDiv: Element | null = this.querySelector('#graphContainer');
     const explore: ExploreSimpleSk = new ExploreSimpleSk(true);
 
     explore.openQueryByDefault = false;
@@ -217,7 +279,6 @@
       this.updateShortcut();
     });
 
-    graphDiv!.appendChild(explore);
     return explore;
   }
 
@@ -352,26 +413,21 @@
     }
     this.clearGraphs();
     traceset.forEach((key, i) => {
-      const newExplore = this.addEmptyGraph();
-      if (newExplore) {
-        if (key[0] === ',') {
-          const queries = this.queryFromKey(key);
-          newExplore.state = {
-            ...newExplore.state,
-            queries: [queries],
-          };
-          this.graphConfigs[i].queries = [queries];
-        } else {
-          const formulas = key;
-          newExplore.state = {
-            ...newExplore.state,
-            formulas: [formulas],
-          };
-          this.graphConfigs[i].formulas = [formulas];
-        }
+      this.addEmptyGraph();
+      if (key[0] === ',') {
+        const queries = this.queryFromKey(key);
+        this.graphConfigs[i].queries = [queries];
+      } else {
+        const formulas = key;
+        this.graphConfigs[i].formulas = [formulas];
       }
     });
     this.updateShortcut();
+
+    // Upon the split action, we would want to move to the first page
+    // of the split graph set.
+    this.state.pageOffset = 0;
+    this.addGraphsToCurrentPage();
   }
 
   /**
@@ -381,39 +437,23 @@
    * Opposite of splitGraph function.
    */
   private async mergeGraphs() {
-    const tracesets = this.getTracesets();
-
-    const traces: string[] = [];
-    // Flatten tracesets
-    tracesets.forEach((traceset) => {
-      traceset.forEach((trace) => {
-        if (!traces.includes(trace)) {
-          traces.push(trace);
-        }
+    const mergedGraphConfig = new GraphConfig();
+    this.graphConfigs.forEach((config) => {
+      config.formulas.forEach((formula) => {
+        mergedGraphConfig.formulas.push(formula);
+      });
+      config.queries.forEach((query) => {
+        mergedGraphConfig.queries.push(query);
       });
     });
-
     this.clearGraphs();
-    const newExplore = this.addEmptyGraph();
+    this.addEmptyGraph();
 
-    const queries: string[] = [];
-    const formulas: string[] = [];
-
-    traces.forEach((trace) => {
-      if (trace[0] === ',') {
-        queries.push(this.queryFromKey(trace));
-      } else {
-        formulas.push(trace);
-      }
-    });
-    newExplore!.state = {
-      ...newExplore!.state,
-      formulas: formulas,
-      queries: queries,
-    };
-    this.graphConfigs[0].formulas = formulas;
-    this.graphConfigs[0].queries = queries;
+    this.graphConfigs[0] = mergedGraphConfig;
     this.updateShortcut!();
+    // Upon the merge action, we would want to move to the first page.
+    this.state.pageOffset = 0;
+    this.addGraphsToCurrentPage();
   }
 
   /**
@@ -471,6 +511,40 @@
       })
       .catch(errorMessage);
   }
+
+  private pageChanged(e: CustomEvent<PaginationSkPageChangedEventDetail>) {
+    this.state.pageOffset = Math.max(
+      0,
+      this.state.pageOffset + e.detail.delta * this.state.pageSize
+    );
+    this.stateHasChanged!();
+    this.addGraphsToCurrentPage();
+  }
+
+  private pageSizeChanged(e: MouseEvent) {
+    this.state.pageSize = +(e.target! as HTMLInputElement).value;
+    this.stateHasChanged!();
+    this.addGraphsToCurrentPage();
+  }
+
+  private updatePageForNewExplore() {
+    // Check if there is space left on the current page
+    if (this.graphDiv!.childElementCount === this.state.pageSize) {
+      // We will have to add another page since the current one is full.
+      // Go to the next page.
+      this.pageChanged(
+        new CustomEvent<PaginationSkPageChangedEventDetail>('page-changed', {
+          detail: {
+            delta: 1,
+          },
+          bubbles: true,
+        })
+      );
+    } else {
+      // Re-render the page
+      this.addGraphsToCurrentPage();
+    }
+  }
 }
 
 define('explore-multi-sk', ExploreMultiSk);