perf: add per-unit page to dashboard

This page helps avoid the need to zoom in on the chart SVGs to try and
select exactly the commit you want. It essentially shows an expanded
version of the graph as a table.

Change-Id: Ibf17f23c267b3ba0b1e4c51c32c6b5c927f53a95
Reviewed-on: https://go-review.googlesource.com/c/build/+/459517
Run-TryBot: Michael Knyszek <mknyszek@google.com>
Reviewed-by: Michael Pratt <mpratt@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/perf/app/dashboard.go b/perf/app/dashboard.go
index fa6d764..6ba8a20 100644
--- a/perf/app/dashboard.go
+++ b/perf/app/dashboard.go
@@ -655,6 +655,7 @@
 	}
 
 	benchmark := r.FormValue("benchmark")
+	unit := r.FormValue("unit")
 	var benchmarks []*BenchmarkJSON
 	if benchmark == "" {
 		benchmarks, err = fetchDefaultBenchmarks(ctx, qc, start, end, repository, branch)
@@ -662,8 +663,14 @@
 		benchmarks, err = fetchAllBenchmarks(ctx, qc, false, start, end, repository, branch)
 	} else if benchmark == "regressions" {
 		benchmarks, err = fetchAllBenchmarks(ctx, qc, true, start, end, repository, branch)
-	} else {
+	} else if benchmark != "" && unit == "" {
 		benchmarks, err = fetchNamedBenchmark(ctx, qc, start, end, repository, branch, benchmark)
+	} else {
+		var result *BenchmarkJSON
+		result, err = fetchNamedUnitBenchmark(ctx, qc, start, end, repository, branch, benchmark, unit)
+		if result != nil && err == nil {
+			benchmarks = []*BenchmarkJSON{result}
+		}
 	}
 	if err == errBenchmarkNotFound {
 		log.Printf("Benchmark not found: %q", benchmark)
diff --git a/perf/app/dashboard/index.html b/perf/app/dashboard/index.html
index f18111e..e21cc97 100644
--- a/perf/app/dashboard/index.html
+++ b/perf/app/dashboard/index.html
@@ -12,6 +12,7 @@
 	<link rel="stylesheet" href="./static/style.css"/>
 	<script src="https://ajax.googleapis.com/ajax/libs/d3js/7.4.2/d3.min.js"></script>
 	<script src="./third_party/bandchart/bandchart.js"></script>
+	<script src="./static/range.js"></script>
 </head>
 
 <body class="Dashboard">
@@ -32,9 +33,12 @@
 	<form autocomplete="off" action="./">
 		<ul>
 			<li>
-				<div class="Dashboard-search">
+				<div class="Dashboard-search-benchmark">
 					<input id="benchmark-input" type="text" name="benchmark" placeholder="Type benchmark name..." />
 				</div>
+				<div class="Dashboard-search-unit">
+					<input id="unit-input" type="text" name="unit" placeholder="Unit (optional)" />
+				</div>
 				<input type="submit" />
 			</li>
 			<li><a href="?benchmark=all">All benchmarks</a></li>
@@ -81,23 +85,44 @@
 		for tested ("ok") / untested (empty) commits.
 	</p>
 	<p>
-		Also note that the 'branch' selection above is the Go branch
-		that benchmarking ran against on https://build.golang.org, not
-		the subrepo branch.
+		The 'branch' selection above is the Go branch that benchmarking
+		ran against on <a href=https://build.golang.org>the build dashboard</a>,
+		not the subrepo branch.
+	</p>
+	<p>
+		Using the 'unit' search box above leads to a page that shows much
+		more detail about each individual point in the selected time range.
+		Another way of reaching this page is to click the unit name that acts
+		as each graph's title, i.e. "sec/op".
 	</p>
 </div>
 
 <grid id="dashboard">
-	<h2 class="Dashboard-title" id="loading">Loading...</h2>
+	<grid id="loading" class="Dashboard-section Dashboard-section-expand">
+		<h2 class="Dashboard-title" id="loading">Loading...</h2>
+	</grid>
 </grid>
 
 <script>
+// minViewPercentDelta represents the minimum range we're willing to
+// let the Y axis have for charts and X axis for ranges in the per-unit
+// view. In both cases the unit of this value is a delta percent, hence
+// the name.
+//
+// This constant exists because allowing the axis' range to be arbitrarily
+// small produces results that are really noisy visually, even though they
+// represent virtually no change. For instance, a relatively low-noise
+// series of benchmark results, with a min delta of -0.05% and and +0.05%
+// might appear really noisy if we "zoom" in too far, when in actuality
+// the amount of noise is incredibly low.
+const minViewDeltaPercent = 0.025;
+
 function removeLoadingMessage() {
 	let loading = document.getElementById("loading");
 	loading.parentNode.removeChild(loading);
 }
 
-function addContent(benchmarks) {
+function addCharts(benchmarks) {
 	let dashboard = document.getElementById("dashboard");
 
 	removeLoadingMessage();
@@ -131,12 +156,111 @@
 		let item = document.createElement("div");
 		item.classList.add("Dashboard-grid-item");
 		item.appendChild(BandChart(bench.Values, {
+			benchmark: bench.Name,
 			unit: bench.Unit,
+			minViewDeltaPercent: minViewDeltaPercent,
 		}));
 		grid.appendChild(item);
 	}
 }
 
+function addTable(bench, unit) {
+	let dashboard = document.getElementById("dashboard");
+
+	removeLoadingMessage();
+
+	let section = document.createElement("grid");
+	section.classList.add("Dashboard-section");
+	section.classList.add("Dashboard-section-expand");
+	dashboard.appendChild(section);
+
+	let link = document.createElement("a");
+	link.href = "?benchmark=" + bench.Name + "&unit=" + unit;
+	link.textContent = bench.Name + " (" + unit + ")";
+
+	let title = document.createElement("h2");
+	title.classList.add("Dashboard-title");
+	title.appendChild(link);
+	section.appendChild(title);
+
+	const table = document.createElement("table");
+	table.classList.add("Dashboard-table");
+	section.appendChild(table);
+
+	const createCell = function(text, header) {
+		let elemType = "td";
+		if (header) {
+			elemType = "th";
+		}
+		const elem = document.createElement(elemType);
+		elem.textContent = text;
+		return elem;
+	}
+
+	// Create the header.
+	const header = document.createElement("tr");
+	header.appendChild(createCell("Date", true));
+	header.appendChild(createCell("Commit", true));
+	header.appendChild(createCell("Delta", true));
+	table.appendChild(header);
+
+	// Find the min and max.
+	let min = bench.Values[0].Low;
+	let max = bench.Values[0].High;
+	for (let i = 1; i < bench.Values.length; i++) {
+		if (bench.Values[i].Low < min) {
+			min = bench.Values[i].Low;
+		}
+		if (bench.Values[i].High > max) {
+			max = bench.Values[i].High;
+		}
+	}
+
+	// Clamp for presentation.
+	if (min < 0 && min > -minViewDeltaPercent) {
+		min = -minViewDeltaPercent
+	}
+	if (max > 0 && max < minViewDeltaPercent) {
+		max = minViewDeltaPercent
+	}
+	if (max-min < 2*minViewDeltaPercent) {
+		const amt = (2*minViewDeltaPercent-(max-min))/2;
+		max += amt;
+		min -= amt;
+	}
+
+	// Iterate backwards, showing the most recent first.
+	for (let i = bench.Values.length-1; i >= 0; i--) {
+		const v = bench.Values[i];
+
+		// Create a row per value.
+		const row = document.createElement("tr");
+
+		// Commit date.
+		row.appendChild(createCell(Intl.DateTimeFormat([], {
+			dateStyle: "long",
+			timeStyle: "short",
+		}).format(v.CommitDate), false));
+
+		// Commit hash.
+		const commitHash = createCell("", false);
+		const commitLink = document.createElement("a");
+		commitLink.href = "https://go.googlesource.com/go/+show/" + v.CommitHash;
+		commitLink.textContent = v.CommitHash.slice(0, 7);
+		commitHash.appendChild(commitLink);
+		commitHash.classList.add("Dashboard-table-commit")
+		row.appendChild(commitHash);
+
+		// Range visualization.
+		const range = createCell("", false);
+		range.appendChild(Range(v.Low, v.Center, v.High, min, max, 640, 32, bench.Unit));
+		range.classList.add("Dashboard-table-range")
+		row.appendChild(range);
+
+		table.appendChild(row);
+	}
+}
+
 function failure(name, response) {
 	let dashboard = document.getElementById("dashboard");
 
@@ -165,6 +289,12 @@
 		input.value = benchmark;
 	}
 
+	let unit = params.get('unit');
+	if (unit) {
+		let input = document.getElementById('unit-input');
+		input.value = unit;
+	}
+
 	let repository = params.get('repository');
 	if (repository) {
 		let select = document.getElementById('repository-select');
@@ -203,6 +333,7 @@
 
 // Fetch content.
 let benchmark = (new URLSearchParams(window.location.search)).get('benchmark');
+let unit = (new URLSearchParams(window.location.search)).get('unit');
 let dataURL = './data.json' + window.location.search;  // Pass through all URL params.
 fetch(dataURL)
 	.then(response => {
@@ -220,7 +351,16 @@
 			});
 		});
 
-		addContent(benchmarks);
+		// If we have an explicit unit, then there should be just one result.
+		if (unit) {
+			if (benchmarks.length !== 1) {
+				failure(benchmark, "got more that one benchmark when a unit was specified");
+				throw new Error("Data fetch failed");
+			}
+			addTable(benchmarks[0], unit);
+		} else {
+			addCharts(benchmarks);
+		}
 	});
 </script>
 
diff --git a/perf/app/dashboard/static/range.js b/perf/app/dashboard/static/range.js
new file mode 100644
index 0000000..dcff4c9
--- /dev/null
+++ b/perf/app/dashboard/static/range.js
@@ -0,0 +1,108 @@
+// Copyright 2022 The Go Authors.  All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+function Range(low, center, high, min, max, width, height, unit) {
+	const margin = 40;
+	const svg = d3.create("svg")
+		.attr("width", width)
+		.attr("height", height)
+		.attr("viewBox", [0, 0, width, height])
+		.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
+
+	const goodColor = "#005AB5";
+	const badColor = "#DC3220";
+	const pickColor = function(n) {
+		const higherIsBetter = {
+			"B/s": true,
+			"ops/s": true
+		};
+		if (unit in higherIsBetter) {
+			if (n > 0) {
+				return goodColor;
+			}
+			return badColor;
+		}
+		if (n < 0) {
+			return goodColor;
+		}
+		return badColor;
+	};
+
+	const xScale = d3.scaleLinear([min, max], [margin, width-margin]);
+	const yBaseline = 3*height/4;
+
+	// Draw line.
+	const line = d3.line()
+		.x(d => xScale(d))
+		.y(yBaseline)
+
+	const partialStroke = function() {
+		return svg.append("path")
+			.attr("fill", "none")
+			.attr("stroke-width", 6);
+	}
+	if (high < 0) {
+		partialStroke().attr("stroke", pickColor(high))
+			.attr("d", line([low, high]));
+	} else if (low < 0 && high > 0) {
+		partialStroke().attr("stroke", pickColor(low))
+			.attr("d", line([low, 0]));
+		partialStroke().attr("stroke", pickColor(high))
+			.attr("d", line([0, high]));
+	} else {
+		partialStroke().attr("stroke", pickColor(low))
+			.attr("d", line([low, high]));
+	}
+
+	const tick = d3.line()
+		.x(d => xScale(d[0]))
+		.y(d => d[1])
+
+	const xTicks = [low, center, high];
+	for (const i in xTicks) {
+		svg.append("path")
+			.attr("fill", "none")
+			.attr("stroke", pickColor(xTicks[i]))
+			.attr("stroke-width", 2.5)
+			.attr("d", tick([[xTicks[i], yBaseline-4], [xTicks[i], yBaseline+4]]))
+	}
+
+	svg.append("text")
+		.attr("x", xScale(low)-4)
+		.attr("y", yBaseline+3)
+		.attr("fill", pickColor(low))
+		.attr("text-anchor", "end")
+		.attr("font-size", "11px")
+		.text(Intl.NumberFormat([], {
+			style: 'percent',
+			signDisplay: 'always',
+			minimumFractionDigits: 2,
+		}).format(low));
+
+	svg.append("text")
+		.attr("x", xScale(center))
+		.attr("y", height/2)
+		.attr("fill", pickColor(center))
+		.attr("text-anchor", "middle")
+		.attr("font-size", "16px")
+		.text(Intl.NumberFormat([], {
+			style: 'percent',
+			signDisplay: 'always',
+			minimumFractionDigits: 2,
+		}).format(center));
+
+	svg.append("text")
+		.attr("x", xScale(high)+4)
+		.attr("y", yBaseline+3)
+		.attr("fill", pickColor(high))
+		.attr("text-anchor", "start")
+		.attr("font-size", "11px")
+		.text(Intl.NumberFormat([], {
+			style: 'percent',
+			signDisplay: 'always',
+			minimumFractionDigits: 2,
+		}).format(high));
+
+	return svg.node();
+}
diff --git a/perf/app/dashboard/static/style.css b/perf/app/dashboard/static/style.css
index b050078..ba9f65c 100644
--- a/perf/app/dashboard/static/style.css
+++ b/perf/app/dashboard/static/style.css
@@ -109,10 +109,14 @@
   padding-left: 0.5rem;
 }
 
-.Dashboard-search {
+.Dashboard-search-benchmark {
   display: inline-block;
   width: 300px;
 }
+.Dashboard-search-unit {
+  display: inline-block;
+  width: 90px;
+}
 input, select {
   border: 1px solid transparent;
   background-color: #f4f4f4;
@@ -148,6 +152,10 @@
   flex-direction: row;
   margin: 8px;
 }
+.Dashboard-section-expand {
+  padding: 8px;
+  width: 100%;
+}
 .Dashboard-grid {
   display: flex;
   flex-direction: row;
@@ -156,3 +164,24 @@
 }
 .Dashboard-grid-item {
 }
+.Dashboard-table {
+  padding: 16px;
+  width: 100%;
+}
+.Dashboard-table tr:nth-child(2n+1) {
+  background: #f4f4f4;
+}
+.Dashboard-table th {
+  background: #e0ebf5;
+  color: #375eab;
+  text-align: center;
+}
+.Dashboard-table td {
+  padding: 8px;
+  text-align: center;
+}
+.Dashboard-table-commit {
+  font-family: monospace;
+}
+.Dashboard-table-range {
+}
diff --git a/third_party/bandchart/bandchart.js b/third_party/bandchart/bandchart.js
index 77f2d4f..67e36e5 100644
--- a/third_party/bandchart/bandchart.js
+++ b/third_party/bandchart/bandchart.js
@@ -10,7 +10,9 @@
 	marginLeft = 40, // left margin, in pixels
 	width = 480, // outer width, in pixels
 	height = 240, // outer height, in pixels
+	benchmark,
 	unit,
+	minViewDeltaPercent,
 } = {}) {
 	// Compute values.
 	const C = d3.map(data, d => d.CommitHash);
@@ -39,9 +41,9 @@
 	// For (2) set it at the top.
 	// For (3) make sure the Y=0 line is in the middle.
 	//
-	// Finally, make sure we don't get closer than 0.025,
+	// Finally, make sure we don't get closer than minViewDeltaPercent,
 	// because otherwise it just looks really noisy.
-	const minYDomain = [-0.025, 0.025];
+	const minYDomain = [-minViewDeltaPercent, minViewDeltaPercent];
 	if (yDomain[0] > 0) {
 		// (1)
 		yDomain[0] = 0;
@@ -98,13 +100,15 @@
 		.call(g => g.selectAll(".tick line").clone()
 			.attr("x2", width - marginLeft - marginRight)
 			.attr("stroke-opacity", 0.1))
-		.call(g => g.append("text")
-			.attr("x", xRange[0]-40)
-			.attr("y", 24)
-			.attr("fill", "currentColor")
-			.attr("text-anchor", "start")
-			.attr("font-size", "16px")
-			.text(unit));
+		.call(g => g.append("a")
+			.attr("xlink:href", "?benchmark=" + benchmark + "&unit=" + unit)
+			.append("text")
+				.attr("x", xRange[0]-40)
+				.attr("y", 24)
+				.attr("fill", "currentColor")
+				.attr("text-anchor", "start")
+				.attr("font-size", "16px")
+				.text(unit));
 
 	const defs = svg.append("defs")