зеркало из https://github.com/golang/build.git
330 строки
9.4 KiB
JavaScript
330 строки
9.4 KiB
JavaScript
// Copyright 2021 Observable, Inc.
|
|
// Released under the ISC license.
|
|
// https://observablehq.com/@d3/band-chart
|
|
|
|
function BandChart(data, {
|
|
defined,
|
|
marginTop = 30, // top margin, in pixels
|
|
marginRight = 15, // right margin, in pixels
|
|
marginBottom = 30, // bottom margin, in pixels
|
|
marginLeft = 40, // left margin, in pixels
|
|
width = 480, // outer width, in pixels
|
|
height = 240, // outer height, in pixels
|
|
benchmark,
|
|
unit,
|
|
repository,
|
|
minViewDeltaPercent,
|
|
higherIsBetter,
|
|
} = {}) {
|
|
// 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);
|
|
|
|
// Determine Y domain.
|
|
//
|
|
// Three cases:
|
|
// (1) All data is above Y=0 line.
|
|
// (2) All data is below Y=0 line.
|
|
// (3) Data crosses the Y=0 line.
|
|
//
|
|
// For (1) set the Y=0 line as the bottom of the domain.
|
|
// For (2) set it at the top.
|
|
// For (3) make sure the Y=0 line is in the middle.
|
|
//
|
|
// Finally, make sure we don't get closer than minViewDeltaPercent,
|
|
// because otherwise it just looks really noisy.
|
|
const minYDomain = [-minViewDeltaPercent, minViewDeltaPercent];
|
|
if (yDomain[0] > 0) {
|
|
// (1)
|
|
yDomain[0] = 0;
|
|
if (yDomain[1] < minYDomain[1]) {
|
|
yDomain[1] = minYDomain[1];
|
|
}
|
|
} else if (yDomain[1] < 0) {
|
|
// (2)
|
|
yDomain[1] = 0;
|
|
if (yDomain[0] > minYDomain[0]) {
|
|
yDomain[0] = minYDomain[0];
|
|
}
|
|
} else {
|
|
// (3)
|
|
if (Math.abs(yDomain[1]) > Math.abs(yDomain[0])) {
|
|
yDomain[0] = -Math.abs(yDomain[1]);
|
|
} else {
|
|
yDomain[1] = Math.abs(yDomain[0]);
|
|
}
|
|
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 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;");
|
|
|
|
// Chart area background color.
|
|
svg.append("rect")
|
|
.attr("fill", "white")
|
|
.attr("x", xRange[0])
|
|
.attr("y", yRange[1])
|
|
.attr("width", xRange[1] - xRange[0])
|
|
.attr("height", yRange[0] - yRange[1]);
|
|
|
|
// Title (unit).
|
|
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("a")
|
|
.attr("xlink:href", "?benchmark=" + benchmark + "&unit=" + unit)
|
|
.append("text")
|
|
.attr("x", xRange[0]-40)
|
|
.attr("y", 24)
|
|
.attr("fill", "currentColor")
|
|
.attr("text-anchor", "start")
|
|
.attr("font-size", "16px")
|
|
.text(unit));
|
|
|
|
const defs = svg.append("defs")
|
|
|
|
const maxHalfColorValue = 0.10;
|
|
const maxHalfColorOpacity = 0.5;
|
|
|
|
// Draw top half.
|
|
const goodColor = "#005AB5";
|
|
const badColor = "#DC3220";
|
|
|
|
// By default, lower is better.
|
|
var bottomColor = goodColor;
|
|
var topColor = badColor;
|
|
if (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] > maxHalfColorValue) {
|
|
topGOffsetPercent *= maxHalfColorValue/yDomain[1];
|
|
} else {
|
|
topGStopOpacity *= yDomain[1]/maxHalfColorValue;
|
|
}
|
|
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] < -maxHalfColorValue) {
|
|
bottomGOffsetPercent *= -maxHalfColorValue/yDomain[0];
|
|
} else {
|
|
bottomGStopOpacity *= -yDomain[0]/maxHalfColorValue;
|
|
}
|
|
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));
|
|
|
|
// Add a harder gridline for Y=0 to make it stand out.
|
|
|
|
const line0 = d3.line()
|
|
.defined(i => D[i])
|
|
.x(i => xScale(X[i]))
|
|
.y(i => yScale(0))
|
|
|
|
svg.append("path")
|
|
.attr("fill", "none")
|
|
.attr("stroke", "#999999")
|
|
.attr("stroke-width", 2)
|
|
.attr("d", line0(I))
|
|
|
|
// 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 label.
|
|
svg.append("text")
|
|
.attr("x", xRange[0] + (xRange[1]-xRange[0])/2)
|
|
.attr("y", yRange[0] + (yRange[0]-yRange[1])*0.10)
|
|
.attr("fill", "currentColor")
|
|
.attr("text-anchor", "middle")
|
|
.attr("font-size", "12px")
|
|
.text("Commits");
|
|
|
|
// 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", "#212121")
|
|
.attr("stroke-width", 2.5)
|
|
.attr("d", line(I))
|
|
|
|
// 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/"+repository+"/+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)
|
|
.attr("pointer-events", "none")
|
|
)
|
|
.call(g => g.append('text')
|
|
// Point metadata (commit hash and date).
|
|
// Above graph, top-right.
|
|
.attr("x", xRange[1])
|
|
.attr("y", yRange[1] - 6)
|
|
.attr("pointer-events", "none")
|
|
.attr("fill", "currentColor")
|
|
.attr("text-anchor", "end")
|
|
.attr("font-family", "sans-serif")
|
|
.attr("font-size", 12)
|
|
.text(C[i].slice(0, 7) + " ("
|
|
+ Intl.DateTimeFormat([], {
|
|
dateStyle: "long",
|
|
timeStyle: "short"
|
|
}).format(X[i])
|
|
+ ")")
|
|
)
|
|
.call(g => g.append('text')
|
|
// Point center, low, high values.
|
|
// Bottom-right corner, next to "Commits".
|
|
.attr("x", xRange[1])
|
|
.attr("y", yRange[0] + (yRange[0]-yRange[1])*0.10)
|
|
.attr("pointer-events", "none")
|
|
.attr("fill", "currentColor")
|
|
.attr("text-anchor", "end")
|
|
.attr("font-family", "sans-serif")
|
|
.attr("font-size", 12)
|
|
.text(Intl.NumberFormat([], {
|
|
style: 'percent',
|
|
signDisplay: 'always',
|
|
minimumFractionDigits: 2,
|
|
}).format(Y[i]) + " (" + Intl.NumberFormat([], {
|
|
style: 'percent',
|
|
signDisplay: 'always',
|
|
minimumFractionDigits: 2,
|
|
}).format(Y1[i]) + ", " + Intl.NumberFormat([], {
|
|
style: 'percent',
|
|
signDisplay: 'always',
|
|
minimumFractionDigits: 2,
|
|
}).format(Y2[i]) + ")")
|
|
)
|
|
})
|
|
.on("mouseout", () => svg.selectAll('.tooltip').remove());
|
|
|
|
return svg.node();
|
|
}
|