зеркало из https://github.com/golang/build.git
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>
This commit is contained in:
Родитель
59e7a6bb02
Коммит
2e87f3ba77
|
@ -57,6 +57,7 @@ func (a *App) RegisterOnMux(mux *http.ServeMux) {
|
|||
mux.HandleFunc("/trend", a.trend)
|
||||
mux.HandleFunc("/cron/syncinflux", a.syncInflux)
|
||||
mux.HandleFunc("/healthz", a.healthz)
|
||||
a.dashboardRegisterOnMux(mux)
|
||||
}
|
||||
|
||||
// search handles /search.
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -27,6 +27,19 @@ const (
|
|||
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 @@ func (a *App) syncInflux(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
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...")
|
||||
|
|
|
@ -39,6 +39,8 @@ func main() {
|
|||
mux := http.NewServeMux()
|
||||
app.RegisterOnMux(mux)
|
||||
|
||||
log.Printf("Serving...")
|
||||
|
||||
ctx := context.Background()
|
||||
log.Fatal(https.ListenAndServe(ctx, mux))
|
||||
}
|
||||
|
|
|
@ -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.
|
|
@ -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();
|
||||
}
|
|
@ -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
|
Загрузка…
Ссылка в новой задаче