Refactor R8 perf to allow code reuse

Change-Id: I546f6fcc779f72f1ba0cb8e1a22e8b8749e9bd0f
diff --git a/tools/perf/chart.js b/tools/perf/chart.js
new file mode 100644
index 0000000..96c7f07
--- /dev/null
+++ b/tools/perf/chart.js
@@ -0,0 +1,117 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+import dom from "./dom.js";
+import scales from "./scales.js";
+import state from "./state.js";
+import url from "./url.js";
+
+var chart = null;
+var dataProvider = null;
+
+function getBenchmarkColors(theChart) {
+  const benchmarkColors = {};
+  for (var datasetIndex = 0;
+      datasetIndex < theChart.data.datasets.length;
+      datasetIndex++) {
+    if (theChart.getDatasetMeta(datasetIndex).hidden) {
+      continue;
+    }
+    const dataset = theChart.data.datasets[datasetIndex];
+    const benchmark = dataset.benchmark;
+    const benchmarkColor = dataset.borderColor;
+    if (!(benchmark in benchmarkColors)) {
+      benchmarkColors[benchmark] = benchmarkColor;
+    }
+  }
+  return benchmarkColors;
+}
+
+function get() {
+  return chart;
+}
+
+function getData() {
+  const filteredCommits = state.commits(state.zoom);
+  return dataProvider(filteredCommits);
+}
+
+function getDataLabelFormatter(value, context) {
+  var percentageChange = getDataPercentageChange(context);
+  var percentageChangeTwoDecimals = Math.round(percentageChange * 100) / 100;
+  var glyph = percentageChange < 0 ? '▼' : '▲';
+  return glyph + ' ' + percentageChangeTwoDecimals + '%';
+}
+
+function getDataPercentageChange(context) {
+  var i = context.dataIndex;
+  var value = context.dataset.data[i];
+  var j = i;
+  var previousValue;
+  do {
+    if (j == 0) {
+      return null;
+    }
+    previousValue = context.dataset.data[--j];
+  } while (previousValue === undefined || isNaN(previousValue));
+  return (value - previousValue) / previousValue * 100;
+}
+
+function initializeChart(options) {
+  chart = new Chart(dom.canvas, {
+    data: getData(),
+    options: options,
+    plugins: [ChartDataLabels]
+  });
+  // Hide disabled legends.
+  if (state.selectedLegends.size < state.legends.size) {
+    update(false, true);
+  } else {
+    update(false, false);
+  }
+}
+
+function setDataProvider(theDataProvider) {
+ dataProvider = theDataProvider;
+}
+
+function update(dataChanged, legendsChanged) {
+  console.assert(state.zoom.left <= state.zoom.right);
+
+  // Update datasets.
+  if (dataChanged) {
+    const newData = getData();
+    Object.assign(chart.data, newData);
+    // Update chart.
+    chart.update();
+  }
+
+  // Update legends.
+  if (legendsChanged || (dataChanged && state.selectedLegends.size < state.legends.size)) {
+    for (var datasetIndex = 0;
+        datasetIndex < chart.data.datasets.length;
+        datasetIndex++) {
+      const datasetMeta = chart.getDatasetMeta(datasetIndex);
+      datasetMeta.hidden = !state.isLegendSelected(datasetMeta.label);
+    }
+
+    // Update scales.
+    scales.update(chart.options.scales);
+
+    // Update chart.
+    chart.update();
+  }
+
+  dom.updateBenchmarkColors(getBenchmarkColors(chart));
+  dom.updateChartNavigation();
+  url.updateHash(state);
+}
+
+export default {
+  get: get,
+  getDataLabelFormatter: getDataLabelFormatter,
+  getDataPercentageChange: getDataPercentageChange,
+  initializeChart: initializeChart,
+  setDataProvider: setDataProvider,
+  update: update
+};
diff --git a/tools/perf/dom.js b/tools/perf/dom.js
new file mode 100644
index 0000000..101c588
--- /dev/null
+++ b/tools/perf/dom.js
@@ -0,0 +1,125 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+import chart from "./chart.js";
+import state from "./state.js";
+
+// DOM references.
+const benchmarkSelectors = document.getElementById('benchmark-selectors');
+const canvas = document.getElementById('myChart');
+const showMoreLeft = document.getElementById('show-more-left');
+const showLessLeft = document.getElementById('show-less-left');
+const showLessRight = document.getElementById('show-less-right');
+const showMoreRight = document.getElementById('show-more-right');
+
+function initializeBenchmarkSelectors() {
+  state.forEachBenchmark(
+    (benchmark, selected) => {
+      const input = document.createElement('input');
+      input.type = 'checkbox';
+      input.name = 'benchmark';
+      input.id = benchmark;
+      input.value = benchmark;
+      input.checked = selected;
+      input.onchange = function (e) {
+        if (e.target.checked) {
+          state.selectedBenchmarks.add(e.target.value);
+        } else {
+          state.selectedBenchmarks.delete(e.target.value);
+        }
+        chart.update(true, false);
+      };
+
+      const label = document.createElement('label');
+      label.id = benchmark + 'Label';
+      label.htmlFor = benchmark;
+      label.innerHTML = benchmark;
+
+      benchmarkSelectors.appendChild(input);
+      benchmarkSelectors.appendChild(label);
+    });
+}
+
+function initializeChartNavigation() {
+  const zoom = state.zoom;
+
+  canvas.onclick = event => {
+    const points =
+        chart.get().getElementsAtEventForMode(
+            event, 'nearest', { intersect: true }, true);
+    if (points.length > 0) {
+      const point = points[0];
+      const commit = state.commits[point.index];
+      window.open('https://r8.googlesource.com/r8/+/' + commit.hash, '_blank');
+    }
+  };
+
+  showMoreLeft.onclick = event => {
+    if (zoom.left == 0) {
+      return;
+    }
+    const currentSize = zoom.right - zoom.left;
+    zoom.left = zoom.left - currentSize;
+    if (zoom.left < 0) {
+      zoom.left = 0;
+    }
+    chart.update(true, false);
+  };
+
+  showLessLeft.onclick = event => {
+    const currentSize = zoom.right - zoom.left;
+    zoom.left = zoom.left + Math.floor(currentSize / 2);
+    if (zoom.left >= zoom.right) {
+      zoom.left = zoom.right - 1;
+    }
+    chart.update(true, false);
+  };
+
+  showLessRight.onclick = event => {
+    if (zoom.right == 0) {
+      return;
+    }
+    const currentSize = zoom.right - zoom.left;
+    zoom.right = zoom.right - Math.floor(currentSize / 2);
+    if (zoom.right < zoom.left) {
+      zoom.right = zoom.left;
+    }
+    chart.update(true, false);
+  };
+
+  showMoreRight.onclick = event => {
+    const currentSize = zoom.right - zoom.left;
+    zoom.right = zoom.right + currentSize;
+    if (zoom.right > state.commits().length) {
+      zoom.right = state.commits().length;
+    }
+    chart.update(true, false);
+  };
+}
+
+function updateBenchmarkColors(benchmarkColors) {
+  state.forEachBenchmark(
+    benchmark => {
+      const benchmarkLabel = document.getElementById(benchmark + 'Label');
+      const benchmarkColor = benchmarkColors[benchmark] || '#000000';
+      const benchmarkFontWeight = benchmark in benchmarkColors ? 'bold' : 'normal';
+      benchmarkLabel.style.color = benchmarkColor;
+      benchmarkLabel.style.fontWeight = benchmarkFontWeight;
+    });
+}
+
+function updateChartNavigation() {
+  const zoom = state.zoom;
+  showMoreLeft.disabled = zoom.left == 0;
+  showLessLeft.disabled = zoom.left == zoom.right - 1;
+  showLessRight.disabled = zoom.left == zoom.right - 1;
+  showMoreRight.disabled = zoom.right == state.commits().length;
+}
+
+export default {
+  canvas: canvas,
+  initializeBenchmarkSelectors: initializeBenchmarkSelectors,
+  initializeChartNavigation: initializeChartNavigation,
+  updateBenchmarkColors: updateBenchmarkColors,
+  updateChartNavigation: updateChartNavigation
+};
\ No newline at end of file
diff --git a/tools/perf/extensions.js b/tools/perf/extensions.js
new file mode 100644
index 0000000..1aa20e0
--- /dev/null
+++ b/tools/perf/extensions.js
@@ -0,0 +1,32 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+Array.prototype.any = function(predicate) {
+  for (const element of this.values()) {
+    if (predicate(element)) {
+      return true;
+    }
+  }
+  return false;
+};
+Array.prototype.first = function() {
+  return this[0];
+};
+Array.prototype.avg = function() {
+  return this.reduce(function(x, y) { return x + y; }, 0) / this.length;
+};
+Array.prototype.min = function() {
+  return this.reduce(function(x, y) { return x === null ? y : Math.min(x, y); }, null);
+};
+Array.prototype.reverseInPlace = function() {
+  for (var i = 0; i < Math.floor(this.length / 2); i++) {
+    var temp = this[i];
+    this[i] = this[this.length - i - 1];
+    this[this.length - i - 1] = temp;
+  }
+};
+Number.prototype.ns_to_s = function() {
+  const seconds = this/10E8;
+  const seconds_with_one_decimal = Math.round(seconds*10)/10;
+  return seconds;
+};
\ No newline at end of file
diff --git a/tools/perf/index.html b/tools/perf/index.html
index 8d4a6ef..aa14c1b 100644
--- a/tools/perf/index.html
+++ b/tools/perf/index.html
@@ -22,352 +22,215 @@
   </div>
   <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
   <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
+  <script src="extensions.js"></script>
+  <script src="utils.js"></script>
   <script type="module">
-    // Utility methods.
-    Array.prototype.any = function(predicate) {
-      for (const element of this.values()) {
-        if (predicate(element)) {
-          return true;
-        }
-      }
-      return false;
-    };
-    Array.prototype.first = function() {
-      return this[0];
-    };
-    Array.prototype.avg = function() {
-      return this.reduce(function(x, y) { return x + y; }, 0) / this.length;
-    };
-    Array.prototype.min = function() {
-      return this.reduce(function(x, y) { return x === null ? y : Math.min(x, y); }, null);
-    };
-    Array.prototype.reverseInPlace = function() {
-      for (var i = 0; i < Math.floor(this.length / 2); i++) {
-        var temp = this[i];
-        this[i] = this[this.length - i - 1];
-        this[this.length - i - 1] = temp;
-      }
-    };
-    Number.prototype.ns_to_s = function() {
-      const seconds = this/10E8;
-      const seconds_with_one_decimal = Math.round(seconds*10)/10;
-      return seconds;
-    };
-
-    // Import and reverse commits so that newest are last.
-    import commits from "./benchmark_data.json" with { type: "json" };
-    commits.reverseInPlace();
-
-    // Amend the commits with their unique index.
-    for (var i = 0; i < commits.length; i++) {
-      commits[i].index = i;
-    }
-
-    // DOM references.
-    const benchmarkSelectors = document.getElementById('benchmark-selectors');
-    const canvas = document.getElementById('myChart');
-    const showMoreLeft = document.getElementById('show-more-left');
-    const showLessLeft = document.getElementById('show-less-left');
-    const showLessRight = document.getElementById('show-less-right');
-    const showMoreRight = document.getElementById('show-more-right');
-
-    // Initialize benchmark selectors.
-    const benchmarks = new Set();
-    for (const commit of commits.values()) {
-      for (const benchmark in commit.benchmarks) {
-          benchmarks.add(benchmark);
-      }
-    }
-    const selectedBenchmarks = new Set();
-    const urlOptions = unescape(window.location.hash.substring(1)).split(',');
-    for (const benchmark of benchmarks.values()) {
-      for (const filter of urlOptions.values()) {
-        if (filter) {
-          const match = benchmark.match(new RegExp(filter.replace("*", ".*")));
-          if (match) {
-            selectedBenchmarks.add(benchmark);
-            break;
-          }
-        }
-      }
-    }
-    if (selectedBenchmarks.size == 0) {
-      const randomBenchmarkIndex = Math.floor(Math.random() * benchmarks.size);
-      const randomBenchmark = Array.from(benchmarks)[randomBenchmarkIndex];
-      selectedBenchmarks.add(randomBenchmark);
-    }
-    for (const benchmark of benchmarks.values()) {
-      const input = document.createElement('input');
-      input.type = 'checkbox';
-      input.name = 'benchmark';
-      input.id = benchmark;
-      input.value = benchmark;
-      input.checked = selectedBenchmarks.has(benchmark);
-      input.onchange = function (e) {
-        if (e.target.checked) {
-          selectedBenchmarks.add(e.target.value);
-        } else {
-          selectedBenchmarks.delete(e.target.value);
-        }
-        updateChart(true, false);
-      };
-
-      const label = document.createElement('label');
-      label.id = benchmark + 'Label';
-      label.htmlFor = benchmark;
-      label.innerHTML = benchmark;
-
-      benchmarkSelectors.appendChild(input);
-      benchmarkSelectors.appendChild(label);
-    }
-
-    function getSingleResult(benchmark, commit, resultName, resultIteration = 0) {
-      if (!(benchmark in commit.benchmarks)) {
-        return NaN;
-      }
-      const allResults = commit.benchmarks[benchmark].results;
-      const resultsForIteration = allResults[resultIteration];
-      // If a given iteration does not declare a result, then the result
-      // was the same as the first run.
-      if (resultIteration > 0 && !(resultName in resultsForIteration)) {
-        return allResults.first()[resultName];
-      }
-      return resultsForIteration[resultName];
-    }
-
-    function getAllResults(benchmark, commit, resultName) {
-      const result = [];
-      const allResults = commit.benchmarks[benchmark].results;
-      for (var iteration = 0; iteration < allResults.length; iteration++) {
-        result.push(getSingleResult(benchmark, commit, resultName, iteration));
-      }
-      return result;
-    }
+    import chart from "./chart.js";
+    import dom from "./dom.js";
+    import scales from "./scales.js";
+    import state from "./state.js";
 
     // Chart data provider.
-    function getData() {
-      const filteredCommits = commits.slice(zoom.left, zoom.right);
+    function getData(filteredCommits) {
       const labels = filteredCommits.map((c, i) => c.index);
-      const datasets = [];
-      for (const selectedBenchmark of selectedBenchmarks.values()) {
-        const codeSizeData =
-            filteredCommits.map(
-                (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "code_size"));
-        const instructionCodeSizeData =
-            filteredCommits.map(
-                (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "ins_code_size"));
-        const composableInstructionCodeSizeData =
-            filteredCommits.map(
-                (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "composable_code_size"));
-        const oatCodeSizeData =
-            filteredCommits.map(
-                (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "oat_code_size"));
-        const codeSizeScatterData = [];
-        for (const commit of filteredCommits.values()) {
-          if (!(selectedBenchmark in commit.benchmarks)) {
-            continue;
-          }
-          const seen = new Set();
-          seen.add(getSingleResult(selectedBenchmark, commit, "code_size"));
-          const codeSizes = getAllResults(selectedBenchmark, commit, "code_size")
-          for (const codeSize of codeSizes.values()) {
-            if (!seen.has(codeSize)) {
-              codeSizeScatterData.push({ x: commit.index, y: codeSize });
-              seen.add(codeSize);
-            }
-          }
-        }
-        const runtimeData =
-            filteredCommits.map(
-                (c, i) =>
-                    selectedBenchmark in filteredCommits[i].benchmarks
-                        ? getAllResults(selectedBenchmark, filteredCommits[i], "runtime")
-                            .min()
-                            .ns_to_s()
-                        : NaN);
-        const runtimeScatterData = [];
-        for (const commit of filteredCommits.values()) {
-          if (!(selectedBenchmark in commit.benchmarks)) {
-            continue;
-          }
-          const runtimes = getAllResults(selectedBenchmark, commit, "runtime")
-          for (const runtime of runtimes.values()) {
-            runtimeScatterData.push({ x: commit.index, y: runtime.ns_to_s() });
-          }
-        }
-
-        const skipped = (ctx, value) => ctx.p0.skip || ctx.p1.skip ? value : undefined;
-        datasets.push(...[
-          {
-            benchmark: selectedBenchmark,
-            type: 'line',
-            label: 'Dex size',
-            data: codeSizeData,
-            datalabels: {
-              align: 'end',
-              anchor: 'end'
-            },
-            tension: 0.1,
-            yAxisID: 'y',
-            segment: {
-              borderColor: ctx =>
-                  skipped(
-                      ctx,
-                      myChart
-                          ? myChart.data.datasets[ctx.datasetIndex].backgroundColor
-                          : undefined),
-              borderDash: ctx => skipped(ctx, [6, 6]),
-            },
-            spanGaps: true
-          },
-          {
-            benchmark: selectedBenchmark,
-            type: 'line',
-            label: 'Instruction size',
-            data: instructionCodeSizeData,
-            datalabels: {
-              align: 'end',
-              anchor: 'end'
-            },
-            tension: 0.1,
-            yAxisID: 'y_ins_code_size',
-            segment: {
-              borderColor: ctx =>
-                  skipped(
-                      ctx,
-                      myChart
-                          ? myChart.data.datasets[ctx.datasetIndex].backgroundColor
-                          : undefined),
-              borderDash: ctx => skipped(ctx, [6, 6]),
-            },
-            spanGaps: true
-          },
-          {
-            benchmark: selectedBenchmark,
-            type: 'line',
-            label: 'Composable size',
-            data: composableInstructionCodeSizeData,
-            datalabels: {
-              align: 'start',
-              anchor: 'start'
-            },
-            tension: 0.1,
-            yAxisID: 'y_ins_code_size',
-            segment: {
-              borderColor: ctx =>
-                  skipped(
-                      ctx,
-                      myChart
-                          ? myChart.data.datasets[ctx.datasetIndex].backgroundColor
-                          : undefined),
-              borderDash: ctx => skipped(ctx, [6, 6]),
-            },
-            spanGaps: true
-          },
-          {
-            benchmark: selectedBenchmark,
-            type: 'line',
-            label: 'Oat size',
-            data: oatCodeSizeData,
-            datalabels: {
-              align: 'start',
-              anchor: 'start'
-            },
-            tension: 0.1,
-            yAxisID: 'y_oat_code_size',
-            segment: {
-              borderColor: ctx =>
-                  skipped(
-                      ctx,
-                      myChart
-                          ? myChart.data.datasets[ctx.datasetIndex].backgroundColor
-                          : undefined),
-              borderDash: ctx => skipped(ctx, [6, 6]),
-            },
-            spanGaps: true
-          },
-          {
-            benchmark: selectedBenchmark,
-            type: 'scatter',
-            label: 'Nondeterminism',
-            data: codeSizeScatterData,
-            datalabels: {
-              labels: {
-                value: null
-              }
-            },
-            radius: 6,
-            pointBackgroundColor: 'red'
-          },
-          {
-            benchmark: selectedBenchmark,
-            type: 'line',
-            label: 'Runtime',
-            data: runtimeData,
-            datalabels: {
-              labels: {
-                value: null
-              }
-            },
-            tension: 0.1,
-            yAxisID: 'y_runtime',
-            segment: {
-              borderColor: ctx =>
-                  skipped(
-                      ctx,
-                      myChart
-                          ? myChart.data.datasets[ctx.datasetIndex].backgroundColor
-                          : undefined),
-              borderDash: ctx => skipped(ctx, [6, 6]),
-            },
-            spanGaps: true
-          },
-          {
-            benchmark: selectedBenchmark,
-            type: 'scatter',
-            label: 'Runtime variance',
-            data: runtimeScatterData,
-            datalabels: {
-              labels: {
-                value: null
-              }
-            },
-            yAxisID: 'y_runtime'
-          }
-        ]);
-      }
-
+      const datasets = getDatasets(filteredCommits);
       return {
         labels: labels,
-        datasets: datasets,
+        datasets: datasets
       };
     }
 
-    // Legend tracking.
-    const legends =
-        new Set(['Dex size', 'Instruction size', 'Composable size', 'Oat size', 'Nondeterminism', 'Runtime', 'Runtime variance']);
-    const selectedLegends =
-        new Set(
-              unescape(window.location.hash.substring(1))
-                  .split(',')
-                  .filter(l => legends.has(l)));
-    if (selectedLegends.size == 0) {
-      legends.forEach(l => selectedLegends.add(l));
-      selectedLegends.delete('Runtime variance')
-    }
+    function getDatasets(filteredCommits) {
+      const datasets = [];
+      state.forEachSelectedBenchmark(
+        selectedBenchmark => {
+          const codeSizeData =
+              filteredCommits.map(
+                  (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "code_size"));
+          const instructionCodeSizeData =
+              filteredCommits.map(
+                  (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "ins_code_size"));
+          const composableInstructionCodeSizeData =
+              filteredCommits.map(
+                  (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "composable_code_size"));
+          const oatCodeSizeData =
+              filteredCommits.map(
+                  (c, i) => getSingleResult(selectedBenchmark, filteredCommits[i], "oat_code_size"));
+          const codeSizeScatterData = [];
+          for (const commit of filteredCommits.values()) {
+            if (!(selectedBenchmark in commit.benchmarks)) {
+              continue;
+            }
+            const seen = new Set();
+            seen.add(getSingleResult(selectedBenchmark, commit, "code_size"));
+            const codeSizes = getAllResults(selectedBenchmark, commit, "code_size")
+            for (const codeSize of codeSizes.values()) {
+              if (!seen.has(codeSize)) {
+                codeSizeScatterData.push({ x: commit.index, y: codeSize });
+                seen.add(codeSize);
+              }
+            }
+          }
+          const runtimeData =
+              filteredCommits.map(
+                  (c, i) =>
+                      selectedBenchmark in filteredCommits[i].benchmarks
+                          ? getAllResults(selectedBenchmark, filteredCommits[i], "runtime")
+                              .min()
+                              .ns_to_s()
+                          : NaN);
+          const runtimeScatterData = [];
+          for (const commit of filteredCommits.values()) {
+            if (!(selectedBenchmark in commit.benchmarks)) {
+              continue;
+            }
+            const runtimes = getAllResults(selectedBenchmark, commit, "runtime")
+            for (const runtime of runtimes.values()) {
+              runtimeScatterData.push({ x: commit.index, y: runtime.ns_to_s() });
+            }
+          }
 
-    function getDataPercentageChange(context) {
-      var i = context.dataIndex;
-      var value = context.dataset.data[i];
-      var j = i;
-      var previousValue;
-      do {
-        if (j == 0) {
-          return null;
-        }
-        previousValue = context.dataset.data[--j];
-      } while (previousValue === undefined || isNaN(previousValue));
-      return (value - previousValue) / previousValue * 100;
+          const skipped = (ctx, value) => ctx.p0.skip || ctx.p1.skip ? value : undefined;
+          datasets.push(...[
+            {
+              benchmark: selectedBenchmark,
+              type: 'line',
+              label: 'Dex size',
+              data: codeSizeData,
+              datalabels: {
+                align: 'end',
+                anchor: 'end'
+              },
+              tension: 0.1,
+              yAxisID: 'y',
+              segment: {
+                borderColor: ctx =>
+                    skipped(
+                        ctx,
+                        chart.get()
+                            ? chart.get().data.datasets[ctx.datasetIndex].backgroundColor
+                            : undefined),
+                borderDash: ctx => skipped(ctx, [6, 6]),
+              },
+              spanGaps: true
+            },
+            {
+              benchmark: selectedBenchmark,
+              type: 'line',
+              label: 'Instruction size',
+              data: instructionCodeSizeData,
+              datalabels: {
+                align: 'end',
+                anchor: 'end'
+              },
+              tension: 0.1,
+              yAxisID: 'y_ins_code_size',
+              segment: {
+                borderColor: ctx =>
+                    skipped(
+                        ctx,
+                        chart.get()
+                            ? chart.get().data.datasets[ctx.datasetIndex].backgroundColor
+                            : undefined),
+                borderDash: ctx => skipped(ctx, [6, 6]),
+              },
+              spanGaps: true
+            },
+            {
+              benchmark: selectedBenchmark,
+              type: 'line',
+              label: 'Composable size',
+              data: composableInstructionCodeSizeData,
+              datalabels: {
+                align: 'start',
+                anchor: 'start'
+              },
+              tension: 0.1,
+              yAxisID: 'y_ins_code_size',
+              segment: {
+                borderColor: ctx =>
+                    skipped(
+                        ctx,
+                        chart.get()
+                            ? chart.get().data.datasets[ctx.datasetIndex].backgroundColor
+                            : undefined),
+                borderDash: ctx => skipped(ctx, [6, 6]),
+              },
+              spanGaps: true
+            },
+            {
+              benchmark: selectedBenchmark,
+              type: 'line',
+              label: 'Oat size',
+              data: oatCodeSizeData,
+              datalabels: {
+                align: 'start',
+                anchor: 'start'
+              },
+              tension: 0.1,
+              yAxisID: 'y_oat_code_size',
+              segment: {
+                borderColor: ctx =>
+                    skipped(
+                        ctx,
+                        chart.get()
+                            ? chart.get().data.datasets[ctx.datasetIndex].backgroundColor
+                            : undefined),
+                borderDash: ctx => skipped(ctx, [6, 6]),
+              },
+              spanGaps: true
+            },
+            {
+              benchmark: selectedBenchmark,
+              type: 'scatter',
+              label: 'Nondeterminism',
+              data: codeSizeScatterData,
+              datalabels: {
+                labels: {
+                  value: null
+                }
+              },
+              radius: 6,
+              pointBackgroundColor: 'red'
+            },
+            {
+              benchmark: selectedBenchmark,
+              type: 'line',
+              label: 'Runtime',
+              data: runtimeData,
+              datalabels: {
+                labels: {
+                  value: null
+                }
+              },
+              tension: 0.1,
+              yAxisID: 'y_runtime',
+              segment: {
+                borderColor: ctx =>
+                    skipped(
+                        ctx,
+                        chart.get()
+                            ? chart.get().data.datasets[ctx.datasetIndex].backgroundColor
+                            : undefined),
+                borderDash: ctx => skipped(ctx, [6, 6]),
+              },
+              spanGaps: true
+            },
+            {
+              benchmark: selectedBenchmark,
+              type: 'scatter',
+              label: 'Runtime variance',
+              data: runtimeScatterData,
+              datalabels: {
+                labels: {
+                  value: null
+                }
+              },
+              yAxisID: 'y_runtime'
+            }
+          ]);
+        });
+      return datasets;
     }
 
     // Chart options.
@@ -381,9 +244,9 @@
           borderColor: 'rgba(128, 128, 128, 0.7)',
           borderRadius: 4,
           borderWidth: 1,
-          color: context => getDataPercentageChange(context) < 0 ? 'green' : 'red',
+          color: context => chart.getDataPercentageChange(context) < 0 ? 'green' : 'red',
           display: context => {
-            var percentageChange = getDataPercentageChange(context);
+            var percentageChange = chart.getDataPercentageChange(context);
             return percentageChange !== null && Math.abs(percentageChange) >= 0.1;
           },
           font: {
@@ -391,12 +254,7 @@
             weight: 'bold'
           },
           offset: 8,
-          formatter: (value, context) => {
-            var percentageChange = getDataPercentageChange(context);
-            var percentageChangeTwoDecimals = Math.round(percentageChange * 100) / 100;
-            var glyph = percentageChange < 0 ? '▼' : '▲';
-            return glyph + ' ' + percentageChangeTwoDecimals + '%';
-          },
+          formatter: chart.getDataLabelFormatter,
           padding: 6
         },
         legend: {
@@ -406,18 +264,18 @@
               // multiple benchmarks are selected, then use the legends of the
               // first selected benchmark to control all selected benchmarks.
               const numUniqueLegends =
-                  data.datasets.length / selectedBenchmarks.size;
+                  data.datasets.length / state.selectedBenchmarks.size;
               return legendItem.datasetIndex < numUniqueLegends;
             },
           },
           onClick: (e, legendItem, legend) => {
             const clickedLegend = legendItem.text;
-            if (selectedLegends.has(clickedLegend)) {
-              selectedLegends.delete(clickedLegend);
+            if (state.selectedLegends.has(clickedLegend)) {
+              state.selectedLegends.delete(clickedLegend);
             } else {
-              selectedLegends.add(clickedLegend);
+              state.selectedLegends.add(clickedLegend);
             }
-            updateChart(false, true);
+            chart.update(false, true);
           },
         },
         tooltip: {
@@ -426,7 +284,7 @@
               const elementInfo = context[0];
               var commit;
               if (elementInfo.dataset.type == 'line') {
-                commit = commits[zoom.left + elementInfo.dataIndex];
+                commit = commits[state.zoom.left + elementInfo.dataIndex];
               } else {
                 console.assert(elementInfo.dataset.type == 'scatter');
                 commit = commits[elementInfo.raw.x];
@@ -437,12 +295,12 @@
               const elementInfo = context[0];
               var commit;
               if (elementInfo.dataset.type == 'line') {
-                commit = commits[zoom.left + elementInfo.dataIndex];
+                commit = commits[state.zoom.left + elementInfo.dataIndex];
               } else {
                 console.assert(elementInfo.dataset.type == 'scatter');
                 commit = commits[elementInfo.raw.x];
               }
-              const dataset = myChart.data.datasets[elementInfo.datasetIndex];
+              const dataset = chart.get().data.datasets[elementInfo.datasetIndex];
               return `App: ${dataset.benchmark}\n`
                   + `Author: ${commit.author}\n`
                   + `Submitted: ${new Date(commit.submitted * 1000).toLocaleString()}\n`
@@ -453,197 +311,25 @@
         }
       },
       responsive: true,
-      scales: {
-        x: {},
-        y: {
-          position: 'left',
-          title: {
-            display: true,
-            text: 'Dex size (bytes)'
-          }
-        },
-        y_runtime: {
-          position: 'right',
-          title: {
-            display: true,
-            text: 'Runtime (seconds)'
-          }
-        },
-        y_ins_code_size: {
-          position: 'left',
-          title: {
-            display: true,
-            text: 'Instruction size (bytes)'
-          }
-        },
-        y_oat_code_size: {
-          position: 'left',
-          title: {
-            display: true,
-            text: 'Oat size (bytes)'
-          }
-        }
-      }
+      scales: scales.get()
     };
 
-    // Setup click handler.
-    canvas.onclick = event => {
-      const points =
-          myChart.getElementsAtEventForMode(
-              event, 'nearest', { intersect: true }, true);
-      if (points.length > 0) {
-        const point = points[0];
-        const commit = commits[point.index];
-        window.open('https://r8.googlesource.com/r8/+/' + commit.hash, '_blank');
-      }
-    };
-
-    // Setup chart navigation.
-    var zoom = { left: Math.max(0, commits.length - 75), right: commits.length };
-    for (const urlOption of urlOptions.values()) {
-      if (urlOption.startsWith('L')) {
-        var left = parseInt(urlOption.substring(1));
-        if (isNaN(left)) {
-          continue;
-        }
-        left = left >= 0 ? left : commits.length + left;
-        if (left < 0) {
-          zoom.left = 0;
-        } else if (left >= commits.length) {
-          zoom.left = commits.length - 1;
-        } else {
-          zoom.left = left;
-        }
-      }
-    }
-
-    showMoreLeft.onclick = event => {
-      if (zoom.left == 0) {
-        return;
-      }
-      const currentSize = zoom.right - zoom.left;
-      zoom.left = zoom.left - currentSize;
-      if (zoom.left < 0) {
-        zoom.left = 0;
-      }
-      updateChart(true, false);
-    };
-
-    showLessLeft.onclick = event => {
-      const currentSize = zoom.right - zoom.left;
-      zoom.left = zoom.left + Math.floor(currentSize / 2);
-      if (zoom.left >= zoom.right) {
-        zoom.left = zoom.right - 1;
-      }
-      updateChart(true, false);
-    };
-
-    showLessRight.onclick = event => {
-      if (zoom.right == 0) {
-        return;
-      }
-      const currentSize = zoom.right - zoom.left;
-      zoom.right = zoom.right - Math.floor(currentSize / 2);
-      if (zoom.right < zoom.left) {
-        zoom.right = zoom.left;
-      }
-      updateChart(true, false);
-    };
-
-    showMoreRight.onclick = event => {
-      const currentSize = zoom.right - zoom.left;
-      zoom.right = zoom.right + currentSize;
-      if (zoom.right > commits.length) {
-        zoom.right = commits.length;
-      }
-      updateChart(true, false);
-    };
-
-    function updateChart(dataChanged, legendsChanged) {
-      console.assert(zoom.left <= zoom.right);
-
-      // Update datasets.
-      if (dataChanged) {
-        const newData = getData();
-        Object.assign(myChart.data, newData);
-        // Update chart.
-        myChart.update();
-      }
-
-      // Update legends.
-      if (legendsChanged || (dataChanged && selectedLegends.size < legends.size)) {
-        for (var datasetIndex = 0;
-            datasetIndex < myChart.data.datasets.length;
-            datasetIndex++) {
-          const datasetMeta = myChart.getDatasetMeta(datasetIndex);
-          datasetMeta.hidden = !selectedLegends.has(datasetMeta.label);
-        }
-
-        // Update scales.
-        options.scales.y.display = selectedLegends.has('Dex size');
-        options.scales.y_ins_code_size.display =
-            selectedLegends.has('Instruction size') || selectedLegends.has('Composable size');
-        options.scales.y_oat_code_size.display = selectedLegends.has('Oat size');
-        options.scales.y_runtime.display =
-            selectedLegends.has('Runtime') || selectedLegends.has('Runtime variance');
-
-        // Update chart.
-        myChart.update();
-      }
-
-
-      // Update checkbox colors.
-      const benchmarkColors = {};
-      for (var datasetIndex = 0;
-          datasetIndex < myChart.data.datasets.length;
-          datasetIndex++) {
-        if (myChart.getDatasetMeta(datasetIndex).hidden) {
-          continue;
-        }
-        const dataset = myChart.data.datasets[datasetIndex];
-        const benchmark = dataset.benchmark;
-        const benchmarkColor = dataset.borderColor;
-        if (!(benchmark in benchmarkColors)) {
-          benchmarkColors[benchmark] = benchmarkColor;
-        }
-      }
-      for (const benchmark of benchmarks.values()) {
-        const benchmarkLabel = document.getElementById(benchmark + 'Label');
-        const benchmarkColor = benchmarkColors[benchmark] || '#000000';
-        const benchmarkFontWeight = benchmark in benchmarkColors ? 'bold' : 'normal';
-        benchmarkLabel.style.color = benchmarkColor;
-        benchmarkLabel.style.fontWeight = benchmarkFontWeight;
-      }
-
-      // Update navigation.
-      showMoreLeft.disabled = zoom.left == 0;
-      showLessLeft.disabled = zoom.left == zoom.right - 1;
-      showLessRight.disabled = zoom.left == zoom.right - 1;
-      showMoreRight.disabled = zoom.right == commits.length;
-
-      // Update hash.
-      window.location.hash =
-          Array.from(selectedBenchmarks)
-              .concat(
-                  selectedLegends.size == legends.size
-                      ? []
-                      : Array.from(selectedLegends))
-              .join(',');
-    }
-
-    // Create chart.
-    const myChart = new Chart(canvas, {
-      data: getData(),
-      options: options,
-      plugins: [ChartDataLabels]
+    const commits = await state.importCommits("./benchmark_data.json");
+    state.initializeBenchmarks();
+    state.initializeLegends({
+      'Dex size': { default: true },
+      'Instruction size': { default: true },
+      'Composable size': { default: true },
+      'Oat size': { default: true },
+      'Nondeterminism': { default: true },
+      'Runtime': { default: true },
+      'Runtime variance': { default: false }
     });
-
-    // Hide disabled legends.
-    if (selectedLegends.size < legends.size) {
-      updateChart(false, true);
-    } else {
-      updateChart(false, false);
-    }
+    state.initializeZoom();
+    dom.initializeBenchmarkSelectors();
+    dom.initializeChartNavigation();
+    chart.setDataProvider(getData);
+    chart.initializeChart(options);
   </script>
 </body>
 </html>
\ No newline at end of file
diff --git a/tools/perf/scales.js b/tools/perf/scales.js
new file mode 100644
index 0000000..7678fa3
--- /dev/null
+++ b/tools/perf/scales.js
@@ -0,0 +1,52 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+import state from "./state.js";
+
+function get() {
+  return {
+    x: {},
+    y: {
+      position: 'left',
+      title: {
+        display: true,
+        text: 'Dex size (bytes)'
+      }
+    },
+    y_runtime: {
+      position: 'right',
+      title: {
+        display: true,
+        text: 'Runtime (seconds)'
+      }
+    },
+    y_ins_code_size: {
+      position: 'left',
+      title: {
+        display: true,
+        text: 'Instruction size (bytes)'
+      }
+    },
+    y_oat_code_size: {
+      position: 'left',
+      title: {
+        display: true,
+        text: 'Oat size (bytes)'
+      }
+    }
+  };;
+}
+
+function update(scales) {
+  scales.y.display = state.isLegendSelected('Dex size');
+  scales.y_ins_code_size.display =
+      state.isLegendSelected('Instruction size') || state.isLegendSelected('Composable size');
+  scales.y_oat_code_size.display = state.isLegendSelected('Oat size');
+  scales.y_runtime.display =
+      state.isLegendSelected('Runtime') || state.isLegendSelected('Runtime variance');
+}
+
+export default {
+  get: get,
+  update: update
+};
\ No newline at end of file
diff --git a/tools/perf/state.js b/tools/perf/state.js
new file mode 100644
index 0000000..d98f6f1
--- /dev/null
+++ b/tools/perf/state.js
@@ -0,0 +1,116 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+import url from "./url.js";
+
+var commits = null;
+
+const benchmarks = new Set();
+const selectedBenchmarks = new Set();
+
+const legends = new Set();
+const selectedLegends = new Set();
+
+const zoom = { left: -1, right: -1 };
+
+function forEachBenchmark(callback) {
+  for (const benchmark of benchmarks.values()) {
+    callback(benchmark, selectedBenchmarks.has(benchmark));
+  }
+}
+
+function forEachSelectedBenchmark(callback) {
+  forEachBenchmark((benchmark, selected) => {
+    if (selected) {
+      callback(benchmark);
+    }
+  });
+}
+
+function importCommits(url) {
+  return import("./benchmark_data.json", { with: { type: "json" }})
+      .then(module => {
+        commits = module.default;
+        commits.reverseInPlace();
+        // Amend the commits with their unique index.
+        for (var i = 0; i < commits.length; i++) {
+          commits[i].index = i;
+        }
+        return commits;
+      });
+}
+
+function initializeBenchmarks() {
+  for (const commit of commits.values()) {
+    for (const benchmark in commit.benchmarks) {
+        benchmarks.add(benchmark);
+    }
+  }
+  for (const benchmark of benchmarks.values()) {
+    if (url.matches(benchmark)) {
+      selectedBenchmarks.add(benchmark);
+    }
+  }
+  if (selectedBenchmarks.size == 0) {
+    const randomBenchmarkIndex = Math.floor(Math.random() * benchmarks.size);
+    const randomBenchmark = Array.from(benchmarks)[randomBenchmarkIndex];
+    selectedBenchmarks.add(randomBenchmark);
+  }
+}
+
+function initializeLegends(legendsInfo) {
+  for (var legend in legendsInfo) {
+    legends.add(legend);
+    if (url.contains(legend)) {
+      selectedLegends.add(legend);
+    }
+  }
+  if (selectedLegends.size == 0) {
+    for (let [legend, legendInfo] of Object.entries(legendsInfo)) {
+      if (legendInfo.default) {
+        selectedLegends.add(legend);
+      }
+    }
+  }
+}
+
+function initializeZoom() {
+  zoom.left = Math.max(0, commits.length - 75);
+  zoom.right = commits.length;
+  for (const urlOption of url.values()) {
+    if (urlOption.startsWith('L')) {
+      var left = parseInt(urlOption.substring(1));
+      if (isNaN(left)) {
+        continue;
+      }
+      left = left >= 0 ? left : commits.length + left;
+      if (left < 0) {
+        zoom.left = 0;
+      } else if (left >= commits.length) {
+        zoom.left = commits.length - 1;
+      } else {
+        zoom.left = left;
+      }
+    }
+  }
+}
+
+function isLegendSelected(legend) {
+  return selectedLegends.has(legend);
+}
+
+export default {
+  benchmarks: benchmarks,
+  commits: zoom => zoom ? commits.slice(zoom.left, zoom.right) : commits,
+  legends: legends,
+  selectedBenchmarks: selectedBenchmarks,
+  selectedLegends: selectedLegends,
+  forEachBenchmark: forEachBenchmark,
+  forEachSelectedBenchmark: forEachSelectedBenchmark,
+  initializeBenchmarks: initializeBenchmarks,
+  initializeLegends: initializeLegends,
+  initializeZoom: initializeZoom,
+  importCommits: importCommits,
+  isLegendSelected: isLegendSelected,
+  zoom: zoom
+};
diff --git a/tools/perf/url.js b/tools/perf/url.js
new file mode 100644
index 0000000..f00a2f77
--- /dev/null
+++ b/tools/perf/url.js
@@ -0,0 +1,41 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+const options = unescape(window.location.hash.substring(1)).split(',');
+
+function contains(subject) {
+  return options.includes(subject);
+}
+
+function matches(subject) {
+  for (const filter of options.values()) {
+    if (filter) {
+      const match = subject.match(new RegExp(filter.replace("*", ".*")));
+      if (match) {
+        return true;
+      }
+    }
+  }
+  return false;
+}
+
+function updateHash(state) {
+  window.location.hash =
+      Array.from(state.selectedBenchmarks)
+          .concat(
+              state.selectedLegends.size == state.legends.size
+                  ? []
+                  : Array.from(state.selectedLegends))
+          .join(',');
+}
+
+function values() {
+  return options;
+}
+
+export default {
+  contains: contains,
+  matches: matches,
+  updateHash: updateHash,
+  values: values
+};
\ No newline at end of file
diff --git a/tools/perf/utils.js b/tools/perf/utils.js
new file mode 100644
index 0000000..46890d0
--- /dev/null
+++ b/tools/perf/utils.js
@@ -0,0 +1,25 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+function getSingleResult(benchmark, commit, resultName, resultIteration = 0) {
+  if (!(benchmark in commit.benchmarks)) {
+    return NaN;
+  }
+  const allResults = commit.benchmarks[benchmark].results;
+  const resultsForIteration = allResults[resultIteration];
+  // If a given iteration does not declare a result, then the result
+  // was the same as the first run.
+  if (resultIteration > 0 && !(resultName in resultsForIteration)) {
+    return allResults.first()[resultName];
+  }
+  return resultsForIteration[resultName];
+}
+
+function getAllResults(benchmark, commit, resultName) {
+  const result = [];
+  const allResults = commit.benchmarks[benchmark].results;
+  for (var iteration = 0; iteration < allResults.length; iteration++) {
+    result.push(getSingleResult(benchmark, commit, resultName, iteration));
+  }
+  return result;
+}
diff --git a/tools/upload_benchmark_data_to_google_storage.py b/tools/upload_benchmark_data_to_google_storage.py
index f293c2c..07b1fdd 100755
--- a/tools/upload_benchmark_data_to_google_storage.py
+++ b/tools/upload_benchmark_data_to_google_storage.py
@@ -16,7 +16,17 @@
 TARGETS = ['r8-full']
 NUM_COMMITS = 1000
 
-INDEX_HTML = os.path.join(utils.TOOLS_DIR, 'perf/index.html')
+FILES = [
+    'chart.js',
+    'dom.js',
+    'extensions.js',
+    'index.html',
+    'scales.js',
+    'state.js',
+    'stylesheet.css',
+    'url.js',
+    'utils.js'
+]
 
 
 def DownloadCloudBucket(dest):
@@ -94,7 +104,10 @@
         perf.ArchiveOutputFile(benchmark_data_file,
                                'benchmark_data.json',
                                header='Cache-Control:no-store')
-        perf.ArchiveOutputFile(INDEX_HTML, 'index.html')
+        for file in FILES:
+            dest = os.path.join(utils.TOOLS_DIR, 'perf', file)
+            perf.ArchiveOutputFile(dest, file)
+
 
 
 def main():