// TODO(peter) // - Save pan/zoom settings in query params // // TODO(travers): There exists an awkward ordering script loading issue where // write-throughput.js is loaded first, but contains references to functions // defined in this file. Work out a better way of modularizing this code. const parseTime = d3.timeParse("%Y%m%d"); const formatTime = d3.timeFormat("%b %d"); const dateBisector = d3.bisector(d => d.date).left; let minDate; let max = { date: new Date(), perChart: {}, opsSec: 0, readBytes: 0, writeBytes: 0, readAmp: 0, writeAmp: 0 }; let usePerChartMax = false; let detail; let detailName; let detailFormat; let annotations = []; function getMaxes(chartKey) { return usePerChartMax ? max.perChart[chartKey] : max; } function styleWidth(e) { const width = +e.style("width").slice(0, -2); return Math.round(Number(width)); } function styleHeight(e) { const height = +e.style("height").slice(0, -2); return Math.round(Number(height)); } function pathGetY(path, x) { // Walk along the path using binary search to locate the point // with the supplied x value. let start = 0; let end = path.getTotalLength(); while (start < end) { const target = (start + end) / 2; const pos = path.getPointAtLength(target); if (Math.abs(pos.x - x) < 0.01) { // Close enough. return pos.y; } else if (pos.x > x) { end = target; } else { start = target; } } return path.getPointAtLength(start).y; } // Pretty formatting of a number in human readable units. function humanize(s) { const iecSuffixes = [" B", " KB", " MB", " GB", " TB", " PB", " EB"]; if (s < 10) { return "" + s; } let e = Math.floor(Math.log(s) / Math.log(1024)); let suffix = iecSuffixes[Math.floor(e)]; let val = Math.floor(s / Math.pow(1024, e) * 10 + 0.5) / 10; return val.toFixed(val < 10 ? 1 : 0) + suffix; } function dirname(path) { return path.match(/.*\//)[0]; } function equalDay(d1, d2) { return ( d1.getYear() == d2.getYear() && d1.getMonth() == d2.getMonth() && d1.getDate() == d2.getDate() ); } function computeSegments(data) { return data.reduce(function(segments, d) { if (segments.length == 0) { segments.push([d]); return segments; } const lastSegment = segments[segments.length - 1]; const lastDatum = lastSegment[lastSegment.length - 1]; const days = Math.round( (d.date.getTime() - lastDatum.date.getTime()) / (24 * 60 * 60 * 1000) ); if (days == 1) { lastSegment.push(d); } else { segments.push([d]); } return segments; }, []); } function computeGaps(segments) { let gaps = []; for (let i = 1; i < segments.length; ++i) { const last = segments[i - 1]; const cur = segments[i]; gaps.push([last[last.length - 1], cur[0]]); } // If the last day is not equal to the current day, add a gap that // spans to the current day. const last = segments[segments.length - 1]; const lastDay = last[last.length - 1]; if (!equalDay(lastDay.date, max.date)) { const maxDay = Object.assign({}, lastDay); maxDay.date = max.date; gaps.push([lastDay, maxDay]); } return gaps; } function renderChart(chart) { const chartKey = chart.attr("data-key"); const vals = data[chartKey]; const svg = chart.html(""); const margin = { top: 25, right: 60, bottom: 25, left: 60 }; const width = styleWidth(svg) - margin.left - margin.right, height = styleHeight(svg) - margin.top - margin.bottom; const defs = svg.append("defs"); const filter = defs .append("filter") .attr("id", "textBackground") .attr("x", 0) .attr("y", 0) .attr("width", 1) .attr("height", 1); filter.append("feFlood").attr("flood-color", "white"); filter.append("feComposite").attr("in", "SourceGraphic"); defs .append("clipPath") .attr("id", chartKey) .append("rect") .attr("x", 0) .attr("y", -margin.top) .attr("width", width) .attr("height", margin.top + height + 10); const title = svg .append("text") .attr("class", "chart-title") .attr("x", margin.left + width / 2) .attr("y", 15) .style("text-anchor", "middle") .style("font", "8pt sans-serif") .text(chartKey); const g = svg .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); const x = d3.scaleTime().range([0, width]); const x2 = d3.scaleTime().range([0, width]); const y1 = d3.scaleLinear().range([height, 0]); const z = d3.scaleOrdinal(d3.schemeCategory10); const xFormat = formatTime; x.domain([minDate, max.date]); x2.domain([minDate, max.date]); y1.domain([0, getMaxes(chartKey).opsSec]); const xAxis = d3.axisBottom(x).ticks(5); g .append("g") .attr("class", "axis axis--x") .attr("transform", "translate(0," + height + ")") .call(xAxis); g .append("g") .attr("class", "axis axis--y") .call(d3.axisLeft(y1).ticks(5)); if (!vals) { // That's all we can draw for an empty chart. svg .append("text") .attr("x", margin.left + width / 2) .attr("y", margin.top + height / 2) .style("text-anchor", "middle") .style("font", "8pt sans-serif") .text("No data"); return; } const view = g .append("g") .attr("class", "view") .attr("clip-path", "url(#" + chartKey + ")"); const triangle = d3 .symbol() .type(d3.symbolTriangle) .size(12); view .selectAll("path.annotation") .data(annotations) .enter() .append("path") .attr("class", "annotation") .attr("d", triangle) .attr("stroke", "#2b2") .attr("fill", "#2b2") .attr( "transform", d => "translate(" + (x(d.date) + "," + (height + 5) + ")") ); view .selectAll("line.annotation") .data(annotations) .enter() .append("line") .attr("class", "annotation") .attr("fill", "none") .attr("stroke", "#2b2") .attr("stroke-width", "1px") .attr("stroke-dasharray", "1 2") .attr("x1", d => x(d.date)) .attr("x2", d => x(d.date)) .attr("y1", 0) .attr("y2", height); // Divide the data into contiguous days so that we can avoid // interpolating days where there is missing data. const segments = computeSegments(vals); const gaps = computeGaps(segments); const line1 = d3 .line() .x(d => x(d.date)) .y(d => y1(d.opsSec)); const path1 = view .selectAll(".line1") .data(segments) .enter() .append("path") .attr("class", "line1") .attr("d", line1) .style("stroke", d => z(0)); view .selectAll(".line1-gaps") .data(gaps) .enter() .append("path") .attr("class", "line1-gaps") .attr("d", line1) .attr("opacity", 0.8) .style("stroke", d => z(0)) .style("stroke-dasharray", "1 2"); let y2 = d3.scaleLinear().range([height, 0]); let line2; let path2; if (detail) { y2 = d3.scaleLinear().range([height, 0]); y2.domain([0, detail(getMaxes(chartKey))]); g .append("g") .attr("class", "axis axis--y") .attr("transform", "translate(" + width + ",0)") .call( d3 .axisRight(y2) .ticks(5) .tickFormat(detailFormat) ); line2 = d3 .line() .x(d => x(d.date)) .y(d => y2(detail(d))); path2 = view .selectAll(".line2") .data(segments) .enter() .append("path") .attr("class", "line2") .attr("d", line2) .style("stroke", d => z(1)); view .selectAll(".line2-gaps") .data(gaps) .enter() .append("path") .attr("class", "line2-gaps") .attr("d", line2) .attr("opacity", 0.8) .style("stroke", d => z(1)) .style("stroke-dasharray", "1 2"); } const updateZoom = function(t) { x.domain(t.rescaleX(x2).domain()); g.select(".axis--x").call(xAxis); g.selectAll(".line1").attr("d", line1); g.selectAll(".line1-gaps").attr("d", line1); if (detail) { g.selectAll(".line2").attr("d", line2); g.selectAll(".line2-gaps").attr("d", line2); } g .selectAll("path.annotation") .attr( "transform", d => "translate(" + (x(d.date) + "," + (height + 5) + ")") ); g .selectAll("line.annotation") .attr("x1", d => x(d.date)) .attr("x2", d => x(d.date)); }; svg.node().updateZoom = updateZoom; const hoverSeries = function(mouse) { if (!detail) { return 1; } const mousex = mouse[0]; const mousey = mouse[1] - margin.top; const path1Y = pathGetY(path1.node(), mousex); const path2Y = pathGetY(path2.node(), mousex); return Math.abs(mousey - path1Y) < Math.abs(mousey - path2Y) ? 1 : 2; }; // This is a bit funky: initDate() initializes the date range to // [today-90,today]. We then allow zooming out by 4x which will // give a maximum range of 360 days. We limit translation to the // 360 day period. The funkiness is that it would be more natural // to start at the maximum zoomed amount and then initialize the // zoom. But that doesn't work because we want to maintain the // existing zoom settings whenever we have to (re-)render(). const zoom = d3 .zoom() .scaleExtent([0.25, 2]) .translateExtent([[-width * 3, 0], [width, 1]]) .extent([[0, 0], [width, 1]]) .on("zoom", function() { const t = d3.event.transform; if (!d3.event.sourceEvent) { updateZoom(t); return; } d3.selectAll(".chart").each(function() { if (this.updateZoom != null) { this.updateZoom(t); } }); d3.selectAll(".chart").each(function() { this.__zoom = t.translate(0, 0); }); const mouse = d3.mouse(this); if (mouse) { mouse[0] -= margin.left; // adjust for rect.mouse position const date = x.invert(mouse[0]); const hover = hoverSeries(mouse); d3.selectAll(".chart.ycsb").each(function() { this.updateMouse(mouse, date, hover); }); } }); svg.call(zoom); svg.call(zoom.transform, d3.zoomTransform(svg.node())); const lineHover = g .append("line") .attr("class", "hover") .style("fill", "none") .style("stroke", "#f99") .style("stroke-width", "1px"); const dateHover = g .append("text") .attr("class", "hover") .attr("fill", "#f22") .attr("text-anchor", "middle") .attr("alignment-baseline", "hanging") .attr("transform", "translate(0, 0)"); const opsHover = g .append("text") .attr("class", "hover") .attr("fill", "#f22") .attr("text-anchor", "middle") .attr("transform", "translate(0, 0)"); const marker = g .append("circle") .attr("class", "hover") .attr("r", 3) .style("opacity", "0") .style("stroke", "#f22") .style("fill", "#f22"); svg.node().updateMouse = function(mouse, date, hover) { const mousex = mouse[0]; const mousey = mouse[1]; const i = dateBisector(vals, date, 1); const v = i == vals.length ? vals[i - 1] : mousex - x(vals[i - 1].date) < x(vals[i].date) - mousex ? vals[i - 1] : vals[i]; const noData = mousex < x(vals[0].date); let lineY = height; if (!noData) { if (hover == 1) { lineY = pathGetY(path1.node(), mousex); } else { lineY = pathGetY(path2.node(), mousex); } } let val, valY, valFormat; if (hover == 1) { val = v.opsSec; valY = y1(val); valFormat = d3.format(",.0f"); } else { val = detail(v); valY = y2(val); valFormat = detailFormat; } lineHover .attr("x1", mousex) .attr("x2", mousex) .attr("y1", lineY) .attr("y2", height); marker.attr("transform", "translate(" + x(v.date) + "," + valY + ")"); dateHover .attr("transform", "translate(" + mousex + "," + (height + 8) + ")") .text(xFormat(date)); opsHover .attr( "transform", "translate(" + x(v.date) + "," + (valY - 7) + ")" ) .text(valFormat(val)); }; const rect = svg .append("rect") .attr("class", "mouse") .attr("cursor", "move") .attr("fill", "none") .attr("pointer-events", "all") .attr("width", width) .attr("height", height + margin.top + margin.bottom) .attr("transform", "translate(" + margin.left + "," + 0 + ")") .on("mousemove", function() { const mouse = d3.mouse(this); const date = x.invert(mouse[0]); const hover = hoverSeries(mouse); let resetTitle = true; for (let i in annotations) { if (Math.abs(mouse[0] - x(annotations[i].date)) <= 5) { title .style("font-size", "9pt") .text(annotations[i].message); resetTitle = false; break; } } if (resetTitle) { title.style("font-size", "8pt").text(chartKey); } d3.selectAll(".chart").each(function() { if (this.updateMouse != null) { this.updateMouse(mouse, date, hover); } }); }) .on("mouseover", function() { d3 .selectAll(".chart") .selectAll(".hover") .style("opacity", 1.0); }) .on("mouseout", function() { d3 .selectAll(".chart") .selectAll(".hover") .style("opacity", 0); }); } function renderYCSB() { d3.selectAll(".chart.ycsb").each(function(d, i) { renderChart(d3.select(this)); }); } function initData() { for (key in data) { data[key] = d3.csvParseRows(data[key], function(d, i) { return { date: parseTime(d[0]), opsSec: +d[1], readBytes: +d[2], writeBytes: +d[3], readAmp: +d[4], writeAmp: +d[5] }; }); const vals = data[key]; max.perChart[key] = { opsSec: d3.max(vals, d => d.opsSec), readBytes: d3.max(vals, d => d.readBytes), writeBytes: d3.max(vals, d => d.writeBytes), readAmp: d3.max(vals, d => d.readAmp), writeAmp: d3.max(vals, d => d.writeAmp), } max.opsSec = Math.max(max.opsSec, max.perChart[key].opsSec); max.readBytes = Math.max(max.readBytes, max.perChart[key].readBytes); max.writeBytes = Math.max( max.writeBytes, max.perChart[key].writeBytes, ); max.readAmp = Math.max(max.readAmp, max.perChart[key].readAmp); max.writeAmp = Math.max(max.writeAmp, max.perChart[key].writeAmp); } // Load the write-throughput data and merge with the existing data. We // return a promise here to allow us to continue to make progress elsewhere. return fetch(writeThroughputSummaryURL()) .then(response => response.json()) .then(wtData => { for (let key in wtData) { data[key] = wtData[key]; } }); } function initDateRange() { max.date.setHours(0, 0, 0, 0); minDate = new Date(new Date().setDate(max.date.getDate() - 90)); } function initAnnotations() { d3.selectAll(".annotation").each(function() { const annotation = d3.select(this); const date = parseTime(annotation.attr("data-date")); annotations.push({ date: date, message: annotation.text() }); }); } function setQueryParams() { var params = new URLSearchParams(); if (detailName) { params.set("detail", detailName); } if (usePerChartMax) { params.set("max", "local"); } var search = "?" + params; if (window.location.search != search) { window.history.pushState(null, null, search); } } function setDetail(name) { detail = undefined; detailFormat = undefined; detailName = name; switch (detailName) { case "readBytes": detail = d => d.readBytes; detailFormat = humanize; break; case "writeBytes": detail = d => d.writeBytes; detailFormat = humanize; break; case "readAmp": detail = d => d.readAmp; detailFormat = d3.format(",.1f"); break; case "writeAmp": detail = d => d.writeAmp; detailFormat = d3.format(",.1f"); break; } d3.selectAll(".toggle").classed("selected", false); d3.select("#" + detailName).classed("selected", detail != null); } function initQueryParams() { var params = new URLSearchParams(window.location.search.substring(1)); setDetail(params.get("detail")); usePerChartMax = params.get("max") == "local"; d3.select("#localMax").classed("selected", usePerChartMax); } function toggleDetail(name) { const link = d3.select("#" + name); const selected = !link.classed("selected"); link.classed("selected", selected); if (selected) { setDetail(name); } else { setDetail(null); } setQueryParams(); renderYCSB(); } function toggleLocalMax() { const link = d3.select("#localMax"); const selected = !link.classed("selected"); link.classed("selected", selected); usePerChartMax = selected; setQueryParams(); renderYCSB(); } window.onload = function init() { d3.selectAll(".toggle").each(function() { const link = d3.select(this); link.attr("href", 'javascript:toggleDetail("' + link.attr("id") + '")'); }); d3.selectAll("#localMax").each(function() { const link = d3.select(this); link.attr("href", 'javascript:toggleLocalMax()'); }); initData().then(_ => { initDateRange(); initAnnotations(); initQueryParams(); renderYCSB(); renderWriteThroughputSummary(data); // Use the max date to bisect into the workload data to pluck out the // correct datapoint. let workloadData = data[writeThroughputWorkload]; bisectAndRenderWriteThroughputDetail(workloadData, max.date); let lastUpdate; for (let key in data) { const max = d3.max(data[key], d => d.date); if (!lastUpdate || lastUpdate < max) { lastUpdate = max; } } d3.selectAll(".updated") .text("Last updated: " + d3.timeFormat("%b %e, %Y")(lastUpdate)); }) // By default, display each panel with its local max, which makes spotting // regressions simpler. toggleLocalMax(); }; window.onpopstate = function() { initQueryParams(); renderYCSB(); }; window.addEventListener("resize", renderYCSB);