blob: 1f676e4ccae13f5b490c1de0e82c621bf6a98d96 [file] [log] [blame]
// 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,
history,
} = {}) {
// Compute a set of valid hashes so we can filter out any bad values.
// This is to work around a bug where some test results have bad commits
// attached to them.
// TODO(mknyszek): Consider doing this data cleaning server-side.
let historySet = new Set();
for (let i = 0; i < history.length; i++) {
historySet.add(history[i].Hash);
}
data = data.filter(d => historySet.has(d.CommitHash));
// Compute values.
const CT = d3.map(data, d => d.CommitDate);
const X = d3.map(data, d => d.CommitHash);
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(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])/(history.length-1));
xOrdTicks.push(xRange[1]);
const xScale = d3.scaleOrdinal(d3.map(history, d => d.Hash), 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]);
// Set up the params for the link to the unit page.
let unitLinkParams = new URLSearchParams(window.location.search);
unitLinkParams.set("unit", unit);
unitLinkParams.set("benchmark", benchmark);
// 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", "?" + unitLinkParams.toString())
.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 => xRange[i])
.x(i => xRange[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) => "?" + unitLinkParams.toString() + "#commit" + X[i])
.append("rect")
.attr("pointer-events", "all")
.attr("x", (d, i) => {
if (i == 0) {
return xScale(X[i]);
}
return xScale(X[i]) - (xScale(X[i])-xScale(X[i-1]))/2;
})
.attr("y", marginTop)
.attr("width", (d, i) => {
if (i == 0) {
return (xScale(X[i+1])-xScale(X[i]))/2;
}
if (i == X.length-1) {
return (xScale(X[i])-xScale(X[i-1]))/2;
}
return (xScale(X[i])-xScale(X[i-1]))/2 + (xScale(X[i+1])-xScale(X[i]))/2;
})
.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(X[i].slice(0, 7) + " ("
+ Intl.DateTimeFormat([], {
dateStyle: "long",
timeStyle: "short"
}).format(CT[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();
}