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