perf: add MVP dashboard

Add an MVP dashboard of benchmark results at /dashboard. This dashboard
is heavily based on mknyszek@'s prototype in CL 385554.

Results from the past 7 days for a few hand-picked benchmarks are
fetched from Influx and sent to the frontend, where they are graphed
using d3.js.

For golang/go#48803.

Change-Id: Id6cc7c51afc5a6bf718559a93b7b1e9a18c4b9bf
Reviewed-on: https://go-review.googlesource.com/c/build/+/412136
Reviewed-by: Michael Knyszek <mknyszek@google.com>
Run-TryBot: Michael Pratt <mpratt@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/perf/app/app.go b/perf/app/app.go
index 793e051..d270139 100644
--- a/perf/app/app.go
+++ b/perf/app/app.go
@@ -57,6 +57,7 @@
 	mux.HandleFunc("/trend", a.trend)
 	mux.HandleFunc("/cron/syncinflux", a.syncInflux)
 	mux.HandleFunc("/healthz", a.healthz)
+	a.dashboardRegisterOnMux(mux)
 }
 
 // search handles /search.
diff --git a/perf/app/dashboard.go b/perf/app/dashboard.go
new file mode 100644
index 0000000..e6bcedd
--- /dev/null
+++ b/perf/app/dashboard.go
@@ -0,0 +1,197 @@
+// 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.
+
+package app
+
+import (
+	"context"
+	"embed"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"sort"
+	"time"
+
+	"github.com/influxdata/influxdb-client-go/v2/api"
+	"golang.org/x/build/internal/influx"
+	"golang.org/x/build/third_party/bandchart"
+)
+
+// /dashboard/ displays a dashboard of benchmark results over time for
+// performance monitoring.
+
+//go:embed dashboard/*
+var dashboardFS embed.FS
+
+// dashboardRegisterOnMux registers the dashboard URLs on mux.
+func (a *App) dashboardRegisterOnMux(mux *http.ServeMux) {
+	mux.Handle("/dashboard/", http.FileServer(http.FS(dashboardFS)))
+	mux.Handle("/dashboard/third_party/bandchart/", http.StripPrefix("/dashboard/third_party/bandchart/", http.FileServer(http.FS(bandchart.FS))))
+	mux.HandleFunc("/dashboard/data.json", a.dashboardData)
+}
+
+// BenchmarkJSON contains the timeseries values for a single benchmark name +
+// unit.
+//
+// We could try to shoehorn this into benchfmt.Result, but that isn't really
+// the best fit for a graph.
+type BenchmarkJSON struct {
+	Name string
+	Unit string
+
+	// These will be sorted by CommitDate.
+	Values []ValueJSON
+}
+
+type ValueJSON struct {
+	CommitHash string
+	CommitDate time.Time
+
+	// These are pre-formatted as percent change.
+	Low    float64
+	Center float64
+	High   float64
+}
+
+// fetch queries Influx to fill Values. Name and Unit must be set.
+//
+// WARNING: Name and Unit are not sanitized. DO NOT pass user input.
+func (b *BenchmarkJSON) fetch(ctx context.Context, qc api.QueryAPI) error {
+	if b.Name == "" {
+		return fmt.Errorf("Name must be set")
+	}
+	if b.Unit == "" {
+		return fmt.Errorf("Unit must be set")
+	}
+
+	// TODO(prattmic): Adjust UI to comfortably display more than 7d of
+	// data.
+	query := fmt.Sprintf(`
+from(bucket: "perf")
+  |> range(start: -7d)
+  |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
+  |> filter(fn: (r) => r["name"] == "%s")
+  |> filter(fn: (r) => r["unit"] == "%s")
+  |> filter(fn: (r) => r["branch"] == "master")
+  |> filter(fn: (r) => r["goos"] == "linux")
+  |> filter(fn: (r) => r["goarch"] == "amd64")
+  |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
+  |> yield(name: "last")
+`, b.Name, b.Unit)
+
+	ir, err := qc.Query(ctx, query)
+	if err != nil {
+		return fmt.Errorf("error performing query: %W", err)
+	}
+
+	for ir.Next() {
+		rec := ir.Record()
+
+		low, ok := rec.ValueByKey("low").(float64)
+		if !ok {
+			return fmt.Errorf("record %s low value got type %T want float64", rec, rec.ValueByKey("low"))
+		}
+
+		center, ok := rec.ValueByKey("center").(float64)
+		if !ok {
+			return fmt.Errorf("record %s center value got type %T want float64", rec, rec.ValueByKey("center"))
+		}
+
+		high, ok := rec.ValueByKey("high").(float64)
+		if !ok {
+			return fmt.Errorf("record %s high value got type %T want float64", rec, rec.ValueByKey("high"))
+		}
+
+		commit, ok := rec.ValueByKey("experiment-commit").(string)
+		if !ok {
+			return fmt.Errorf("record %s experiment-commit value got type %T want float64", rec, rec.ValueByKey("experiment-commit"))
+		}
+
+		b.Values = append(b.Values, ValueJSON{
+			CommitDate: rec.Time(),
+			CommitHash: commit,
+			Low:        (low - 1) * 100,
+			Center:     (center - 1) * 100,
+			High:       (high - 1) * 100,
+		})
+	}
+
+	sort.Slice(b.Values, func(i, j int) bool {
+		return b.Values[i].CommitDate.Before(b.Values[j].CommitDate)
+	})
+
+	return nil
+}
+
+// search handles /dashboard/data.json.
+//
+// TODO(prattmic): Consider caching Influx results in-memory for a few mintures
+// to reduce load on Influx.
+func (a *App) dashboardData(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	start := time.Now()
+	defer func() {
+		log.Printf("Dashboard total query time: %s", time.Since(start))
+	}()
+
+	ifxc, err := a.influxClient(ctx)
+	if err != nil {
+		log.Printf("Error getting Influx client: %v", err)
+		http.Error(w, "Error connecting to Influx", 500)
+		return
+	}
+	defer ifxc.Close()
+
+	qc := ifxc.QueryAPI(influx.Org)
+
+	// Keep benchmarks with the same name grouped together, which is
+	// assumed by the JS.
+	//
+	// WARNING: Name and Unit are not sanitized. DO NOT pass user input.
+	benchmarks := []BenchmarkJSON{
+		{
+			Name: "Tile38WithinCircle100kmRequest",
+			Unit: "sec/op",
+		},
+		{
+			Name: "Tile38WithinCircle100kmRequest",
+			Unit: "p90-latency-sec",
+		},
+		{
+			Name: "Tile38WithinCircle100kmRequest",
+			Unit: "average-RSS-bytes",
+		},
+		{
+			Name: "Tile38WithinCircle100kmRequest",
+			Unit: "peak-RSS-bytes",
+		},
+		{
+			Name: "GoBuildKubelet",
+			Unit: "sec/op",
+		},
+		{
+			Name: "GoBuildKubeletLink",
+			Unit: "sec/op",
+		},
+	}
+
+	for i := range benchmarks {
+		b := &benchmarks[i]
+		// WARNING: Name and Unit are not sanitized. DO NOT pass user
+		// input.
+		if err := b.fetch(ctx, qc); err != nil {
+			log.Printf("Error fetching benchmark %s/%s: %v", b.Name, b.Unit, err)
+			http.Error(w, "Error fetching benchmark", 500)
+			return
+		}
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	e := json.NewEncoder(w)
+	e.SetIndent("", "\t")
+	e.Encode(benchmarks)
+}
diff --git a/perf/app/dashboard/index.html b/perf/app/dashboard/index.html
new file mode 100644
index 0000000..bda57b7
--- /dev/null
+++ b/perf/app/dashboard/index.html
@@ -0,0 +1,107 @@
+<!--
+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.
+-->
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<title>Go Performance Dashboard</title>
+	<link rel="icon" href="https://go.dev/favicon.ico"/>
+	<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>
+</head>
+
+<body class="Dashboard">
+<header class="Dashboard-topbar">
+	<h1>
+		<a href="./">Go Performance Dashboard</a>
+	</h1>
+	<nav>
+		<ul>
+			<li><a href="https://build.golang.org">Build Dashboard</a></li>
+		</ul>
+	</nav>
+</header>
+
+<form autocomplete="off" action="./">
+<nav class="Dashboard-controls">
+	<div class="Dashboard-search">
+		<input id="benchmarkInput" type="text" name="benchmark" placeholder="Type benchmark name...">
+	</div>
+	<input type="submit">
+</nav>
+</form>
+
+<script>
+</script>
+
+<div id="dashboard"></div>
+
+<script>
+function addContent(name, benchmarks) {
+	let dashboard = document.getElementById("dashboard");
+
+	if (name == "" || name == null || name == undefined) {
+		// All benchmarks.
+		// TODO(prattmic): Replace with a simpler overview?
+	} else {
+		// Filter to specified benchmark.
+		benchmarks = benchmarks.filter(function(b) {
+			return b.Name == name;
+		});
+		if (benchmarks.length == 0) {
+			let title = document.createElement("h2");
+			title.classList.add("Dashboard-title");
+			title.innerHTML = "Benchmark \"" + name + "\" not found.";
+			dashboard.appendChild(title);
+			return;
+		}
+	}
+
+	let prevName = "";
+	let grid = null;
+	for (const b in benchmarks) {
+		const bench = benchmarks[b];
+
+		if (bench.Name != prevName) {
+			prevName = bench.Name;
+
+			let title = document.createElement("h2");
+			title.classList.add("Dashboard-title");
+			title.innerHTML = bench.Name;
+			dashboard.appendChild(title);
+
+			grid = document.createElement("grid");
+			grid.classList.add("Dashboard-grid");
+			dashboard.appendChild(grid);
+		}
+
+		let item = document.createElement("div");
+		item.classList.add("Dashboard-grid-item");
+		item.appendChild(BandChart(bench.Values, {
+			unit: bench.Unit,
+		}));
+		grid.appendChild(item);
+	}
+}
+
+let benchmark = (new URLSearchParams(window.location.search)).get('benchmark');
+fetch('./data.json')
+	.then(response => response.json())
+	.then(function(benchmarks) {
+		// Convert CommitDate to a proper date.
+		benchmarks.forEach(function(b) {
+			b.Values.forEach(function(v) {
+				v.CommitDate = new Date(v.CommitDate);
+			});
+		});
+
+		addContent(benchmark, benchmarks);
+	});
+</script>
+
+</body>
+</html>
diff --git a/perf/app/dashboard/static/style.css b/perf/app/dashboard/static/style.css
new file mode 100644
index 0000000..3da6995
--- /dev/null
+++ b/perf/app/dashboard/static/style.css
@@ -0,0 +1,141 @@
+/*!
+ * 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.
+ */
+
+* {
+  box-sizing: border-box;
+}
+body {
+  color: #222;
+  font-family: sans-serif;
+  margin: 0;
+  padding: 10px;
+}
+
+h1,
+h2,
+h1 > a,
+h2 > a,
+h1 > a:visited,
+h2 > a:visited {
+  color: #375eab;
+}
+h1 {
+  font-size: 24px;
+}
+h2 {
+  font-size: 20px;
+}
+
+h1 > a,
+h2 > a {
+  display: none;
+  text-decoration: none;
+}
+
+h1:hover > a,
+h2:hover > a {
+  display: inline;
+}
+
+h1 > a:hover,
+h2 > a:hover {
+  text-decoration: underline;
+}
+
+pre {
+  font-family: monospace;
+  font-size: 9pt;
+}
+
+header {
+  background: #e0ebf5;
+  margin: -10px -10px 0 -10px;
+  padding: 10px 10px;
+}
+header h1 {
+  display: inline;
+  margin: 0;
+  padding-top: 5px;
+}
+header h1 a {
+  display: initial;
+}
+header nav {
+  display: inline-block;
+  margin-left: 20px;
+}
+header nav ul {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+header nav ul li {
+  display: inline-block;
+}
+header nav a {
+  background: #375eab;
+  border: 1px solid #375eab;
+  border-radius: 5px;
+  color: white;
+  display: inline-block;
+  font-size: 16px;
+  margin: 0;
+  margin-right: 5px;
+  padding: 10px;
+  text-decoration: none;
+}
+
+.Dashboard {
+  margin: 0;
+  padding: 0;
+}
+.Dashboard-topbar {
+  margin: 0;
+  padding: 0.625rem 0.625rem;
+}
+.Dashboard-title {
+  background: #e0ebf5;
+  padding: 0.125rem 0.3125rem;
+}
+.Dashboard-controls {
+  padding: 0.5rem;
+}
+
+.Dashboard-search {
+  display: inline-block;
+  width: 300px;
+}
+input {
+  border: 1px solid transparent;
+  background-color: #f4f4f4;
+  padding: 10px;
+  font-size: 16px;
+}
+input[type=text] {
+  background-color: #f4f4f4;
+  width: 100%;
+}
+input[type=submit] {
+  background: #375eab;
+  border: 1px solid #375eab;
+  border-radius: 5px;
+  color: white;
+  display: inline-block;
+  font-size: 16px;
+  margin: 0;
+  margin-right: 5px;
+  padding: 10px;
+  text-decoration: none;
+}
+.Dashboard-grid {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: flex-start;
+}
+.Dashboard-grid-item {
+  padding: 8px;
+}
diff --git a/perf/app/influx.go b/perf/app/influx.go
index dfce7da..ed1de07 100644
--- a/perf/app/influx.go
+++ b/perf/app/influx.go
@@ -27,6 +27,19 @@
 	backfillWindow = 30 * 24 * time.Hour // 30 days.
 )
 
+func (a *App) influxClient(ctx context.Context) (influxdb2.Client, error) {
+	if a.InfluxHost == "" {
+		return nil, fmt.Errorf("Influx host unknown (set INFLUX_HOST?)")
+	}
+
+	token, err := a.findInfluxToken(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("error finding Influx token: %w", err)
+	}
+
+	return influxdb2.NewClient(a.InfluxHost, token), nil
+}
+
 // syncInflux handles /cron/syncinflux, which updates an InfluxDB instance with
 // the latest data from perfdata.golang.org (i.e. storage), or backfills it.
 func (a *App) syncInflux(w http.ResponseWriter, r *http.Request) {
@@ -40,21 +53,12 @@
 		}
 	}
 
-	if a.InfluxHost == "" {
-		s := "Influx host unknown (set INFLUX_HOST?)"
-		log.Printf(s)
-		http.Error(w, s, 500)
-		return
-	}
-
-	token, err := a.findInfluxToken(ctx)
+	ifxc, err := a.influxClient(ctx)
 	if err != nil {
-		log.Printf("Error finding Influx token: %v", err)
+		log.Printf("Error getting Influx client: %v", err)
 		http.Error(w, err.Error(), 500)
 		return
 	}
-
-	ifxc := influxdb2.NewClient(a.InfluxHost, token)
 	defer ifxc.Close()
 
 	log.Printf("Connecting to influx...")
diff --git a/perf/main.go b/perf/main.go
index ceba496..707612f 100644
--- a/perf/main.go
+++ b/perf/main.go
@@ -39,6 +39,8 @@
 	mux := http.NewServeMux()
 	app.RegisterOnMux(mux)
 
+	log.Printf("Serving...")
+
 	ctx := context.Background()
 	log.Fatal(https.ListenAndServe(ctx, mux))
 }
diff --git a/third_party/bandchart/LICENSE b/third_party/bandchart/LICENSE
new file mode 100644
index 0000000..6866aaf
--- /dev/null
+++ b/third_party/bandchart/LICENSE
@@ -0,0 +1,13 @@
+Copyright 2018–2021 Observable, Inc.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/third_party/bandchart/bandchart.js b/third_party/bandchart/bandchart.js
new file mode 100644
index 0000000..fdffc91
--- /dev/null
+++ b/third_party/bandchart/bandchart.js
@@ -0,0 +1,286 @@
+// Copyright 2021 Observable, Inc.
+// Released under the ISC license.
+// https://observablehq.com/@d3/band-chart
+
+function BandChart(data, {
+	defined,
+	marginTop = 50, // top margin, in pixels
+	marginRight = 15, // right margin, in pixels
+	marginBottom = 50, // bottom margin, in pixels
+	marginLeft = 30, // left margin, in pixels
+	width = 480, // outer width, in pixels
+	height = 240, // outer height, in pixels
+	unit,
+} = {}) {
+	// Compute values.
+	const C = d3.map(data, d => d.CommitHash);
+	const X = d3.map(data, d => d.CommitDate);
+	const Y = d3.map(data, d => d.Center);
+	const Y1 = d3.map(data, d => d.Low);
+	const Y2 = d3.map(data, d => d.High);
+	const I = d3.range(X.length);
+	if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y1[i]) && !isNaN(Y2[i]);
+	const D = d3.map(data, defined);
+
+	const xRange = [marginLeft, width - marginRight]; // [left, right]
+	const yRange = [height - marginBottom, marginTop]; // [bottom, top]
+
+	// Compute default domains.
+	let yDomain = d3.nice(...d3.extent([...Y1, ...Y2]), 10);
+	// Don't show <2.5% up-close because it just looks extremely noisy.
+	const minYDomain = [-2.5, 2.5];
+	if (yDomain[0] > minYDomain[0]) {
+		yDomain[0] = minYDomain[0];
+	}
+	if (yDomain[1] < minYDomain[1]) {
+		yDomain[1] = minYDomain[1];
+	}
+
+	// Construct scales and axes.
+	const xOrdTicks = d3.range(xRange[0], xRange[1], (xRange[1]-xRange[0])/(X.length-1));
+	xOrdTicks.push(xRange[1]);
+	const xScale = d3.scaleOrdinal(X, xOrdTicks);
+	const yScale = d3.scaleLinear(yDomain, yRange);
+	const xAxis = d3.axisBottom(xScale).tickSizeOuter(0).tickValues(d3.map(C, c => c.slice(0, 7)));
+	const yAxis = d3.axisLeft(yScale).ticks(height / 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;");
+
+	svg.append("g")
+		.attr("transform", `translate(${marginLeft},0)`)
+		.call(yAxis)
+		.call(g => g.select(".domain").remove())
+		.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[1]-xRange[0])/2)
+			.attr("y", 40)
+			.attr("fill", "currentColor")
+			.attr("text-anchor", "middle")
+			.attr("font-size", "20px")
+			.attr("font-weight", "bold")
+			.text(unit));
+
+	const defs = svg.append("defs")
+
+	const maxHalfColorPercent = 10;
+	const maxHalfColorOpacity = 0.25;
+
+	// Draw top half.
+	const goodColor = "blue";
+	const badColor = "red";
+
+	// By default, lower is better.
+	var bottomColor = goodColor;
+	var topColor = badColor;
+	const higherIsBetter = {
+		"B/s": true,
+		"ops/s": true
+	};
+	if (unit in higherIsBetter) {
+		bottomColor = badColor;
+		topColor = goodColor;
+	}
+
+	// IDs, even within SVGs, are shared across the entire page. (what?)
+	// So, at least try to avoid a collision.
+	const gradientIDSuffix = Math.random()*10000000.0;
+
+	const topGradient = defs.append("linearGradient")
+		.attr("id", "topGradient"+gradientIDSuffix)
+		.attr("x1", "0%")
+		.attr("x2", "0%")
+		.attr("y1", "100%")
+		.attr("y2", "0%");
+	topGradient.append("stop")
+		.attr("offset", "0%")
+		.style("stop-color", topColor)
+		.style("stop-opacity", 0);
+	let topGStopOpacity = maxHalfColorOpacity;
+	let topGOffsetPercent = 100.0;
+	if (yDomain[1] > maxHalfColorPercent) {
+		topGOffsetPercent *= maxHalfColorPercent/yDomain[1];
+	} else {
+		topGStopOpacity *= yDomain[1]/maxHalfColorPercent;
+	}
+	topGradient.append("stop")
+		.attr("offset", topGOffsetPercent+"%")
+		.style("stop-color", topColor)
+		.style("stop-opacity", topGStopOpacity);
+
+	const bottomGradient = defs.append("linearGradient")
+		.attr("id", "bottomGradient"+gradientIDSuffix)
+		.attr("x1", "0%")
+		.attr("x2", "0%")
+		.attr("y1", "0%")
+		.attr("y2", "100%");
+	bottomGradient.append("stop")
+		.attr("offset", "0%")
+		.style("stop-color", bottomColor)
+		.style("stop-opacity", 0);
+	let bottomGStopOpacity = maxHalfColorOpacity;
+	let bottomGOffsetPercent = 100.0;
+	if (yDomain[0] < -maxHalfColorPercent) {
+		bottomGOffsetPercent *= -maxHalfColorPercent/yDomain[0];
+	} else {
+		bottomGStopOpacity *= -yDomain[0]/maxHalfColorPercent;
+	}
+	bottomGradient.append("stop")
+		.attr("offset", bottomGOffsetPercent+"%")
+		.style("stop-color", bottomColor)
+		.style("stop-opacity", bottomGStopOpacity);
+
+	// Top half color.
+	svg.append("rect")
+		.attr("fill", "url(#topGradient"+gradientIDSuffix+")")
+		.attr("x", xRange[0])
+		.attr("y", yScale(yDomain[1]))
+		.attr("width", xRange[1] - xRange[0])
+		.attr("height", (yDomain[1]/(yDomain[1]-yDomain[0]))*(height-marginTop-marginBottom));
+
+	// Bottom half color.
+	svg.append("rect")
+		.attr("fill", "url(#bottomGradient"+gradientIDSuffix+")")
+		.attr("x", xRange[0])
+		.attr("y", yScale(0))
+		.attr("width", xRange[1] - xRange[0])
+		.attr("height", (-yDomain[0]/(yDomain[1]-yDomain[0]))*(height-marginTop-marginBottom));
+
+	// Create CI area.
+
+	const area = d3.area()
+		.defined(i => D[i])
+		.curve(d3.curveLinear)
+		.x(i => xScale(X[i]))
+		.y0(i => yScale(Y1[i]))
+		.y1(i => yScale(Y2[i]));
+
+	svg.append("path")
+		.attr("fill", "black")
+		.attr("opacity", 0.1)
+		.attr("d", area(I));
+
+	// Add X axis.
+	svg.append("g")
+		.attr("transform", `translate(0,${height - marginBottom})`)
+		.call(xAxis)
+		.call(g => g.select(".domain").remove())
+		.selectAll("text")
+		.attr("y", 6)
+		.attr("x", -42)
+		.attr("transform", "rotate(315)")
+		.style("text-anchor", "start");
+
+	// Create center line.
+
+	const line = d3.line()
+		.defined(i => D[i])
+		.x(i => xScale(X[i]))
+		.y(i => yScale(Y[i]))
+
+	svg.append("path")
+		.attr("fill", "none")
+		.attr("stroke", "#375eab")
+		.attr("stroke-width", 3)
+		.attr("d", line(I))
+
+	// Create dots.
+
+	svg.append("g")
+		.attr("fill", "#375eab")
+		.attr("stroke", "#e0ebf5")
+		.attr("stroke-width", 1)
+		.selectAll("circle")
+		.data(I)
+		.join("circle")
+			.attr("cx", i => xScale(X[i]))
+			.attr("cy", i => yScale(Y[i]))
+			.attr("r", 4);
+
+	// Divide the chart into columns and apply links and hover actions to them.
+	svg.append("g")
+		.attr("stroke", "#2074A0")
+		.attr("stroke-opacity", 0)
+		.attr("fill", "none")
+		.selectAll("path")
+		.data(I)
+		.join("a")
+			.attr("xlink:href", (d, i) => "https://go.googlesource.com/go/+show/"+C[i])
+			.append("rect")
+				.attr("pointer-events", "all")
+				.attr("x", (d, i) => {
+					if (i == 0) {
+						return xOrdTicks[i];
+					}
+					return xOrdTicks[i-1]+(xOrdTicks[i]-xOrdTicks[i-1])/2;
+				})
+				.attr("y", marginTop)
+				.attr("width", (d, i) => {
+					if (i == 0 || i == X.length-1) {
+						return (xOrdTicks[1]-xOrdTicks[0]) / 2;
+					}
+					return xOrdTicks[1]-xOrdTicks[0];
+				})
+				.attr("height", height-marginTop-marginBottom)
+				.on("mouseover", (d, i) => {
+					svg.append('a')
+						.attr("class", "tooltip")
+						.call(g => g.append('line')
+							.attr("x1", xScale(X[i]))
+							.attr("y1", yRange[0])
+							.attr("x2", xScale(X[i]))
+							.attr("y2", yRange[1])
+							.attr("stroke", "black")
+							.attr("stroke-width", 1)
+							.attr("stroke-dasharray", 2)
+							.attr("opacity", 0.5)
+						)
+						.call(g => g.append('text')
+							.attr("x", (() => {
+								let base = xScale(X[i]);
+								if (base < marginLeft+100) {
+									base += 10;
+								} else if (base > width-marginRight-100) {
+									base -= 10;
+								}
+								return base;
+							})())
+							.attr("y", (() => {
+								let base = yScale(Y[i]);
+								if (base < marginTop+100) {
+									base += 30;
+								} else if (base > height-marginBottom-100) {
+									base -= 30;
+								}
+								return base;
+							}))
+							.attr("pointer-events", "none")
+							.attr("fill", "currentColor")
+							.attr("text-anchor", (() => {
+								let base = xScale(X[i]);
+								if (base < marginLeft+100) {
+									return "start";
+								} else if (base > width-marginRight-100) {
+									return "end";
+								}
+								return "middle";
+							})())
+							.attr("font-family", "sans-serif")
+							.attr("font-size", 12)
+							.text(C[i].slice(0, 7) + " ("
+								+ Intl.DateTimeFormat([], {
+									dateStyle: "long",
+									timeStyle: "short"
+								}).format(X[i])
+								+ ")")
+						)
+				})
+				.on("mouseout", () => svg.selectAll('.tooltip').remove());
+
+	return svg.node();
+}
diff --git a/third_party/bandchart/fs.go b/third_party/bandchart/fs.go
new file mode 100644
index 0000000..b4d73a5
--- /dev/null
+++ b/third_party/bandchart/fs.go
@@ -0,0 +1,13 @@
+// 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.
+
+// Package bandchart provides an embedded bandchart.js.
+package bandchart
+
+import (
+	"embed"
+)
+
+//go:embed bandchart.js
+var FS embed.FS