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