blob: 4083bf7715a6d835a7945a70ef6eb1820bf45df5 [file] [log] [blame]
<!--
The chart-container element represents one chart and all related functionality,
including a legend listing different traces that can be plotted on the same
chart, a revision range selecting mini-chart at the bottom, and all of the alert
triaging functionality in the chart.
-->
<link rel="import" href="../../third_party/polymer/components/core-collapse/core-collapse.html">
<link rel="import" href="../../third_party/polymer/components/paper-icon-button/paper-icon-button.html">
<link rel="import" href="chart-legend.html">
<link rel="import" href="chart-slider.html">
<link rel="import" href="chart-tooltip.html">
<polymer-element name="chart-container"
attributes="showCloseGraphButton chartTitle graphParams graphUncheckedParams
revisionInfo coreTraces selectedTraces indicesToGraph xsrfToken
warning alertKey showCompact collapseLegend">
<template>
<style>
#container {
width: 100%;
display: flex;
display: -webkit-flex;
flex-direction: column;
-webkit-flex-direction: column;
align-items: center;
-webkit-align-items: center;
position: relative;
min-width: 845px; /* Minimum width of plot plus width of chart-legend. */
}
#container[compact] {
min-width: 670px; /* Minimum width of plot plus width of compacted chart-legend. */
}
#horizontal {
display: flex;
display: -webkit-flex;
width: 100%;
position: relative;
}
#chart-yaxis-label {
transform: rotate(-90deg) translate(-50%);
-webkit-transform: rotate(-90deg) translate(-50%);
transform-origin: 0 0;
-webkit-transform-origin: 0 0;
top: 50%;
position: absolute;
padding-top: 5px;
background-color: white;
z-index: 1000;
}
#plots-container {
-webkit-flex-grow: 1;
flex-grow: 1;
display: -webkit-flex;
flex-direction: column;
-webkit-flex-direction: column;
}
#plot {
-webkit-flex-grow: 1;
flex-grow: 1;
min-height: 240px;
min-width: 500px;
}
#slider {
width: 100%;
height: 60px;
}
#warning {
position: absolute;
left: 80px;
top: 15px;
z-index: 2000;
font-size: 16px;
font-weight: bold;
color: #dd4b39;
}
#original {
position: absolute;
top: 10px;
margin-left: -15px;
}
#rhs {
margin-bottom: 20px;
margin-top: 8px;
margin-left: 5px;
margin-right: 10px;
padding: 16px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
outline: 1px solid rgba(0,0,0,0.2);
font-size: 11px;
height: 246px;
width: 312px;
overflow: auto;
}
#rhs[compact] {
width: 125px;
}
#rhs[collapse-legend] {
margin-top: 8px;
height: 25px;
width: 25px;
padding: 0;
}
#expand-legend-btn {
position: absolute;
right: 2px;
top: 1px;
opacity: .75;
}
#delta-off, #delta-drag {
margin-bottom: 10px;
}
#traces {
margin-bottom: 10px;
}
.trace-link {
text-decoration: none;
}
.trace-selected {
text-decoration: underline;
}
#loading-div {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: white;
display: flex;
display: -webkit-flex;
align-items: center;
-webkit-align-items: center;
justify-content: center;
-webkit-justify-content: center;
}
#anomaly-container {
width: 0px;
height: 0px;
left: 0px;
top: 0px;
position: relative;
}
.anomaly {
position: absolute;
margin-top: -5px;
margin-left: -1px;
z-index: 1000;
}
.anomaly.highlighted {
border: 3px solid #FFBA02;
border-radius: 12px;
}
#tooltip {
position: absolute;
z-index: 2000;
}
#close-graph {
position: absolute;
right: 10px;
top: 10px;
opacity: 0;
}
#container:hover #close-graph {
opacity: .75;
}
#container #close-graph:hover {
opacity: 1;
cursor: pointer;
}
</style>
<div id="container" on-mouseleave="{{onMouseLeave}}" compact?="{{showCompact}}">
<h3 id="chart-title">{{chartTitle}}</h3>
<img src="/closebox_large.png"
hidden?="{{!showCloseGraphButton}}"
id="close-graph"
on-click="{{closeGraphClicked}}">
<div id="horizontal">
<div id="chart-yaxis-container">
<div id="chart-yaxis-label">{{chartYAxisLabel}}</div>
</div>
<div id="anomaly-container"></div>
<chart-tooltip id="tooltip"
xsrfToken="{{xsrfToken}}"
hidden></chart-tooltip>
<div id="plots-container">
<div id="plot"></div>
<chart-slider id="slider" on-revisionrange="{{onRevisionRange}}"></chart-slider>
</div>
<span hidden?="{{!warning}}" id="warning">{{warning}}</span>
<a hidden id="original"
on-click="{{onViewOriginal}}"
href="javascript:void(0);">View original graph</a>
<div id="rhs" compact?="{{showCompact}}" collapse-legend?="{{collapseLegend}}">
<paper-icon-button id="expand-legend-btn" icon="arrow-drop-down" title="legend" role="button"
aria-label="menu" on-click="{{toggleLegend}}"></paper-icon-button>
<core-collapse id="collapsible-legend" opened?="{{!collapseLegend}}">
<div id="delta-off">Click and drag graph to measure or zoom.</div>
<div id="delta-drag" hidden>
Delta: {{deltaAbsolute}} or {{deltaPercent}}%.<br>
Click selected range to zoom.
</div>
<div id="traces">Traces:
<a href="javascript:void(0);"
class="trace-link"
on-click="{{onSelectAll}}">select all</a>
&#124;
<a href="javascript:void(0);"
class="trace-link"
on-click="{{onDeselectAll}}">deselect all</a>
&#124;
<a href="javascript:void(0);"
class="trace-link"
on-click="{{onSelectCore}}">core only</a>
</div>
<chart-legend tests={{legendTests}}
indicesToGraph={{indicesToGraph}}
coreEndIndex={{coreEndIndex}}
disableUnselected={{loadingUnselected}}>
</core-collapse>
</div>
</div>
<div id="loading-div">
<img src="//www.google.com/images/loading.gif">
</div>
</div>
</template>
<script>
(function() {
// Minimum multiple standard deviation for a value to be considered
// an outlier.
var MULTIPLE_OF_STD_DEV = 5;
/**
* Gets the mean for list of numbers.
*
* @return {number} A number.
*/
function calculateMean(values) {
var len = values.length;
var sum = values.reduce(function(v1, v2) { return v1 + v2;}, 0);
return sum / len;
var squaredDevianceSum = values.reduce(
function(v1, v2) {return v1 + Math.pow(v2 - mean, 2);}, 0);
var variance = squaredDevianceSum / len;
var std = Math.sqrt(variance);
return {mean: mean, std: std};
}
/**
* Gets standard deviation for list of numbers and a mean.
*
* @return {number} A number.
*/
function calculateStdDev(values, mean) {
var squaredDevianceSum = values.reduce(
function(v1, v2) {return v1 + Math.pow(v2 - mean, 2);}, 0);
var variance = squaredDevianceSum / values.length;
return Math.sqrt(variance);
}
/**
* Finds outliers in a series, along with their directions.
*
* An outlier is defined as a value that deviates from the mean by over
* MULTIPLE_OF_STD_DEV times the standard deviation. An outlier direction
* is either 1 for positive, -1 for negative and 0 for non-outlier.
*
* Example:
* Input values: [50, 50, 50, 100, 50, 10, 50, 50, 50]
* Output directions: [0, 0, 0, 1, 0, -1, 0, 0, 0]
*
* @param {Array.<number>} values List of numbers.
* @return {Array.<number>} List of outlier directions.
*/
function getOutlierDirections(values) {
var mean = calculateMean(values);
var stddev = calculateStdDev(values, mean);
var outlierDirections = [];
for (var i = 0; i < values.length; i++) {
var direction = 0;
var value = values[i];
var multDevFromMean = Math.abs(value - mean) / stddev;
if (multDevFromMean > MULTIPLE_OF_STD_DEV) {
if (value >= mean) {
direction = 1;
}
direction = -1;
}
outlierDirections.push(direction);
}
return outlierDirections;
}
/**
* Returns a date corresponding to the given timestamp, or null.
* @param {string} rev A string that may contain a timestamp.
* @return {(Date|null)} A date object if a timestamp was given, or
* null.
*/
function getDateDisplay(rev) {
var timestampSecondsRegex = /^[\d]{10}$/;
var timestampMillisecondsRegex = /^[\d]{13}$/;
var d = null;
if (timestampSecondsRegex.test(rev)) {
d = new Date(rev * 1000);
} else if (timestampMillisecondsRegex.test(rev)) {
d = new Date(rev);
}
if (d) {
return d.toISOString();
}
return null;
};
/**
* Returns a string to display, given a string that contains a
* revision. This will be different depending on whether the given
* revision is a git hash, a timestamp, or other.
* Note: this is similar to the getDisplayRevision function for the
* embed page (defined in static/embed.js)
* @param {string|boolean} rev A string containing a "revision".
* False may also be passed, in which case it doesn't matter what
* @return {string} A string to display for the given revision.
*/
function getDisplayRevision(rev) {
// Truncate git sha1 to 7 chars.
var gitRegex = /^[a-f0-9]{40}$/;
if (gitRegex.test(rev)) {
return rev.substring(0, 7);
}
var d = getDateDisplay(rev);
if (d) {
return d;
}
return rev;
};
/**
* Formats numbers in a human-readable way.
* @param {(number|string)} val
* @return {string}
*/
function formatNumber(val) {
// Truncate to at most 3 decimal points. Don't use toFixed() because
// we don't want to add precision if it's not there.
val = Math.round(val * 1000) / 1000;
return val.toLocaleString()
};
Polymer('chart-container', {
// The data fetched from the graph_json handler for this chart.
/** @type {(Object|null)} */
json: null,
// The data from graph_json for unselected series.
unselectedJson: null,
// The colors of the series lines, used in the chart options.
// Colors should be updated both here and in the chart-legend CSS.
lineColors: [
'#4d90fe', '#ffe83b', '#8e4efe', '#ffb83b', '#194fa5',
'#a69413', '#4c19a5', '#a67113', '#9dc3ff', '#fff293',
'#c19dff', '#ffd893', '#5b81bf', '#bfb251', '#805bbf',
'#bf9751', '#79adff', '#ffee6c', '#aa7aff', '#ffca6c',
'#3415b0', '#04819e', '#b40097', '#ffcf00', '#1c0772',
'#38b2ce', '#750062', '#a68700', '#846fd7', '#60b9ce',
'#d962c7', '#ffe573', '#3e2d84', '#206676', '#872277',
'#bfa430', '#6549d7', '#015367', '#d936c0', '#ffdb40'
],
/**
* Initializes the chart-container element and all of its properties.
*
* This is a custom element lifecycle callback that's called when an
* instance of the element is ready.
* http://www.polymer-project.org/polymer.html#lifecyclemethods
*
* We use 'ready' instead of 'created' to know when the element has its
* bindings, Shadow DOM has been created, and its on-* handlers and
* property observers have been set up.
* See https://github.com/Polymer/polymer/releases/tag/v0.0.20131025
*/
ready: function() {
// Data series index of the point that was most recently clicked.
this.currentItem = null;
// The data fetched from the graph_json handler for this chart,
// for selected and non-selected series.
this.json = null;
this.unselectedJson = null;
// Whether or not the non-selected traces are currently being loaded.
this.loadingUnselected = false;
// Meta-data about tests, which will be displayed in the legend.
this.legendTests = [];
// List of indices of series which are selected and will be plotted.
this.indicesToGraph = [];
// See updateIndiciesToGraph and indicesToGraphChanged.
this.sendCheckedEvent = true;
// Text to display above this chart and next to the y-axis.
this.chartTitle = '';
this.chartYAxisLabel = '';
// Y-axis delta of current zoom selection, as an absolute number
// and as a percentage of the original selection.
this.deltaAbsolute = 0;
this.deltaPercent = 0;
// Information about the last selection range.
this.lastSelectedDelta = null;
// Whether or not the user is currently selecting a range.
this.selecting = false;
// Y-axis start value in the last selection range.
this.firstSelectedValue = 0;
// Whether or not the tooltip is "sticky". Sticky means that it will
// stay visible when the user is no longer hovering over the point.
this.stickyTooltip = false;
// Map of series indexes to arrays with revision information.
this.revisionMap = null;
// Warning text to be shown over the chart, if any.
this.warning = null;
// Whether or not the chart can currently be drawn.
this.drawable = true;
// The Flot Plot object, returned by $.plot.
this.chart = null;
// Handler for the 'resize' event.
this.resizeHandler = this.onResize.bind(this);
// Timeout ID of the timer used by this.resizeHandler.
this.resizeTimer = null;
// Chart options to be given when initializing the Flot chart.
// See: https://github.com/flot/flot/blob/master/API.md#plot-options
this.chartOptions = {
grid: {
hoverable: true,
clickable: true,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.5)'
},
crosshair: {
mode: 'xy',
color: 'rgba(34, 34, 34, 0.3)',
lineWidth: 0.3
},
xaxis: {
tickFormatter: this.formatXAxis.bind(this)
},
yaxis: {
labelWidth: 60,
tickFormatter: this.formatYAxis.bind(this)
},
selection: {
mode: 'y'
},
colors: this.lineColors
};
this.$.tooltip.addEventListener(
'triaged', this.onBugTriaged.bind(this), true);
this.$.tooltip.addEventListener(
'alertChangedRevisions', this.onAlertChangedRevisions.bind(this),
true);
window.addEventListener('resize', this.resizeHandler);
if (this.graphParams && Object.keys(this.graphParams).length) {
this.graphParamsChanged();
}
if (this.graphUncheckedParams && Object.keys(this.graphUncheckedParams).length) {
this.graphUncheckedParamsChanged();
}
this.initializePlotEventListeners();
},
/**
* Binds event handlers for events that are fired by flot.
*/
initializePlotEventListeners: function() {
var plot = $(this.$.plot);
plot.bind('plotselected', this.onPlotSelected.bind(this));
plot.bind('plotselecting', this.onPlotSelecting.bind(this));
plot.bind('plotunselected', this.onPlotUnselected.bind(this));
plot.bind('plotclick', this.onPlotClick.bind(this));
plot.bind('plothover', this.onPlotHover.bind(this));
},
/**
* A custom element lifecycle callback, called when an instance of the
* element is removed from the DOM.
* See: http://www.polymer-project.org/polymer.html#lifecyclemethods
*/
leftView: function() {
this.drawable = false;
window.removeEventListener('resize', this.resizeHandler);
},
/**
* Handler for the click event of X button on the top-right corner.
*/
closeGraphClicked: function() {
this.parentNode.removeChild(this);
this.fire('closeGraph')
},
/**
* Handler for 'revisionrange' event, fired when revision range
* changes.
*
* This event is fired by the chart-slider element. When this
* event is fired, it indicates that a new start and end revision have
* been selected.
* Note: The properties 'graphParams' and 'graphUncheckedParams' are
* both set in graph.js.
*
* @param {Event} event The revisionrange event.
* @param {Object} detail The detail object given when firing the
* event. This is set in graph.js and it contains 'startrev' and
* 'endrev'.
* @param {Object} sender
*/
onRevisionRange: function(event, detail, sender) {
newGraphParams = deepCopy(this.graphParams);
newUncheckedParams = deepCopy(this.graphUncheckedParams);
delete newGraphParams['rev'];
delete newUncheckedParams['rev'];
delete newGraphParams['num_points'];
delete newUncheckedParams['num_points'];
newGraphParams['start_rev'] = detail.startrev;
newUncheckedParams['start_rev'] = detail.startrev;
newGraphParams['end_rev'] = detail.endrev;
newUncheckedParams['end_rev'] = detail.endrev;
this.graphParams = newGraphParams;
this.graphUncheckedParams = newUncheckedParams;
},
/**
* Updates the chart based on the graphParams property.
*
* This method is called when the graphParams property changes;
* It's also called directly when the chart is first initialized.
* The graphParams property should be set by the page that created
* this chart-container element.
*/
graphParamsChanged: function() {
console.debug('graphParamsChanged', this);
if (!this.graphParams || !Object.keys(this.graphParams).length) {
return;
}
this.hideTooltipEvenIfSticky();
this.$['anomaly-container'].innerHTML = '';
this.$['loading-div'].style.display = '';
this.json = null;
this.indicesToGraph = [];
var firstTestTestPath = this.graphParams.masters[0] + '/' +
this.graphParams.bots[0] + '/' +
this.graphParams.tests[0];
this.$.slider.testpath = firstTestTestPath;
// Make a request to /graph_json for the new set of checked graphs.
var postdata =
'graphs=' + encodeURIComponent(JSON.stringify(this.graphParams));
var req = new XMLHttpRequest();
var self = this;
req.onload = function() {
self.json = JSON.parse(req.responseText);
if (self.unselectedJson) {
self.mergeJson();
}
self.updateForNewJson();
};
req.open('post', '/graph_json', true);
req.setRequestHeader('Content-type',
'application/x-www-form-urlencoded');
req.send(postdata);
},
/**
* This is called when the property 'graphUncheckedParams' changes.
* Note: The property 'graphUncheckedParams' is set in graph.js.
*/
graphUncheckedParamsChanged: function() {
if (!this.graphUncheckedParams ||
!Object.keys(this.graphUncheckedParams).length) {
return;
}
this.unselectedJson = null;
this.loadingUnselected = true;
// Make a request to /graph_json for the new set of unchecked graphs.
var postdata = 'graphs=' +
encodeURIComponent(JSON.stringify(this.graphUncheckedParams));
var req = new XMLHttpRequest();
var self = this;
req.onload = function() {
self.unselectedJson = JSON.parse(req.responseText);
self.loadingUnselected = false;
if (self.json) {
self.mergeJson();
}
};
req.open('post', '/graph_json', true);
req.setRequestHeader('Content-type',
'application/x-www-form-urlencoded');
req.send(postdata);
},
/**
* Updates the contents of this.json with the contents of
* this.unselectedJson.
*/
mergeJson: function() {
if (this.json.warning && !this.unselectedJson.warning) {
// Some data is stale/non-existent in the checked traces, but not
// in unchecked.
this.json.warning = null;
this.warning = null;
}
for (var series = 0; series < this.json.data.length; series++) {
if (this.json.data[series].data.length == 0) {
// The main json was empty for this index;
// copy it from unselected.
this.json.data[series].data =
this.unselectedJson.data[series].data;
this.json.annotations[series] =
this.unselectedJson.annotations[series];
this.json['error_bars'][series] =
this.unselectedJson['error_bars'][series];
}
}
this.updateSmartAutoscaleMap();
},
/**
* Updates the chart when new data has been received from /graph_json.
* Note: Properties 'coreTraces' and 'selectedTraces' are set in
* graph.js.
*/
updateForNewJson: function() {
console.debug('updateForNewJson', this);
if (!this.drawable) {
return;
}
this.warning = this.json.warning;
var tests = [];
var series = this.json.annotations.series;
if (series.length == 0) {
this.$['loading-div'].style.display = 'none';
return;
}
this.coreEndIndex = -1;
for (var i = 0; i < series.length; i++) {
if (this.coreTraces[series[i].name]) {
this.coreEndIndex = i;
}
tests.push({
name: series[i].name,
path: series[i].path,
direction: series[i].better,
units: series[i].units,
description: series[i].description,
testColorClass: 'line-color-' + i,
index: i,
selected: true
});
if (this.selectedTraces[series[i].name]) {
this.indicesToGraph.push(i);
}
// Flot's getData() method can return the data series out of order,
// so maintain an index so that we can get back to the original
// data series on click and hover events.
this.json.data[i].index = i;
}
this.legendTests = tests;
this.updateYAxisLabel();
this.updateSmartAutoscaleMap();
this.updateChart();
this.$['loading-div'].style.display = 'none';
},
/**
* Sets the Y-axis label based on the units of the data.
*/
updateYAxisLabel: function() {
// If the series annotations are properly populated, then there will
// be at least one entry and it will have the properties 'units' and
// 'better'. All traces in one chart should have the same units.
var series = this.json.annotations.series;
if (series[0].units) {
this.chartYAxisLabel = series[0].units;
// Some unit names have improvement direction information in them.
if (series[0].units.indexOf('is better') == -1 &&
series[0].better) {
this.chartYAxisLabel += ' (' + series[0].better.toLowerCase();
this.chartYAxisLabel += ' is better)';
}
}
},
/**
* Updates the map of series index to the adjusted scale.
*
* This map is used to update y-axis scale base on selected traces.
*/
updateSmartAutoscaleMap: function() {
this.yAxisScaleMap = {};
var numSeries = this.json.data.length;
for (var seriesIndex = 0; seriesIndex < numSeries; seriesIndex++) {
var data = this.json.data[seriesIndex].data;
var values = [];
for (var dataIndex = 0; dataIndex < data.length; dataIndex++) {
values.push(data[dataIndex][1]);
}
if (values.length > 0) {
var scale = this.determineMinMax(seriesIndex, values);
this.yAxisScaleMap[seriesIndex] = scale;
}
}
},
/**
* Determines a min and a max from a list of numbers without outliers.
*
* @param {number} seriesIndex Index into the data series list
* (this.json.data).
* @param {Array.<number>} values List of series data values.
* @return {Object} An object containing min and max number.
*/
determineMinMax: function(seriesIndex, values) {
values = values.filter(function(v) {
return v != null;
});
var outlierDirections = getOutlierDirections(values);
// Exclude value that is a stand-alone spike which has either
// outlier direction 1 or -1 in between zeroes. Also include
// points that has an Anomaly.
var resultValues = [];
for (var i = 0; i < outlierDirections.length; i++) {
var direction = outlierDirections[i];
if (direction == 0 || this.hasAnomaly(seriesIndex, i)) {
resultValues.push(values[i]);
} else if (i > 0 && i < outlierDirections.length - 1) {
if (direction == outlierDirections[i-1] ||
direction == outlierDirections[i+1]) {
resultValues.push(values[i]);
}
}
}
return {'min': Math.min.apply(Math, resultValues),
'max': Math.max.apply(Math, resultValues)};
},
/**
* Checks whether a series at a point has an anomaly.
*/
hasAnomaly: function(seriesIndex, dataIndex) {
var annotations = this.json.annotations;
if (!annotations[seriesIndex] ||
!annotations[seriesIndex][dataIndex] ||
!annotations[seriesIndex][dataIndex]['g_anomaly']) {
return false;
}
return true;
},
/**
* Sets scale for chart's y-axis for graphs in indicesToGraph.
* This function sets values in this.chartOptions based on the
* contents of this.yAxisScaleMap.
*/
setSmartAutoscale: function() {
if (!this.indicesToGraph.length || this.indicesToGraph.length <= 0 ||
!this.yAxisScaleMap) {
return;
}
// Get the min/max of the min/max of all graphs in indicesToGraph.
var firstIndex = this.indicesToGraph[0];
if (!(firstIndex in this.yAxisScaleMap)) {
return;
}
var min = this.yAxisScaleMap[firstIndex].min;
var max = this.yAxisScaleMap[firstIndex].max;
for (var i = 0; i < this.indicesToGraph.length; i++) {
var scale = this.yAxisScaleMap[this.indicesToGraph[i]];
if (scale && scale.min < min) {
min = scale.min;
}
if (scale && scale.max > max) {
max = scale.max;
}
}
this.chartOptions.yaxis.min = min;
this.chartOptions.yaxis.max = max;
this.addAxisMargin(this.chartOptions.yaxis);
},
/**
* Adds margins to axis so lines don't touch the chart's borders.
* Same as how flot does it.
*
* @param {Object} axis An object containing min and max number.
*/
addAxisMargin: function(axis) {
var margin = 0.2; // Flot's default for autoscaleMargin.
var min = axis.min, max = axis.max;
var delta = max - min;
if (delta == 0.0) {
min -= axis.min == 0 ? 1 : margin;
max += axis.max == 0 ? 1 : margin;
} else {
min -= delta * margin;
if (min < 0 && axis.min >= 0)
min = 0;
max += delta * margin;
if (max > 0 && axis.max <= 0)
max = 0;
}
axis.min = min;
axis.max = max;
},
/**
* Updates the list of indices of traces to plot without firing an
* event. Note: This method is called in graph.js.
* @param {Array.<number>} indices List of indices of traces.
*/
updateIndicesToGraph: function(indices) {
// Update this.indicesToGraph without causing a
// 'checkedTracesChanged' event to be fired.
this.sendCheckedEvent = false;
this.indicesToGraph = indices;
this.updateChart();
},
/**
* This method is called when the property 'indicesToGraph' changes.
* @param {(Array.<number>|null)} oldValue Old value of indicesToGraph.
*/
indicesToGraphChanged: function(oldValue) {
if (!oldValue || !this.json) {
// Just initializing.
return;
}
this.updateChart();
this.updateChartTitle();
var tests = [];
var series = this.json.annotations.series;
for (var i = 0; i < this.indicesToGraph.length; i++) {
if (series[this.indicesToGraph[i]]) {
tests.push(series[this.indicesToGraph[i]].name);
}
}
// If indicesToGraph was changed in some way other than through the
// updateIndicesToGraph method, fire a checkedTracesChanged event.
// Note: The 'checkedTracesChanged' event is listened for and handled
// in graph.js.
if (this.sendCheckedEvent) {
this.fire('checkedTracesChanged', {
'indices': this.indicesToGraph,
'tests': tests
});
}
this.sendCheckedEvent = true;
},
/**
* Sets the title of the chart based on the current state of the chart.
*/
updateChartTitle: function() {
this.chartTitle = makeChartTitle(
this.getTestPaths(),
this.getSelectedTestPaths());
},
getTestPaths: function() {
var testPaths = [];
var series = this.json.annotations.series;
for (var i = 0; i < series.length; i++) {
testPaths.push(series[i].path);
}
return testPaths;
},
getSelectedTestPaths: function() {
var testPaths = [];
var series = this.json.annotations.series;
for (var i = 0; i < this.indicesToGraph.length; i++) {
var selectedIndex = this.indicesToGraph[i];
if (series[selectedIndex]) {
testPaths.push(series[selectedIndex].path);
}
}
return testPaths;
},
/**
* This method is called when the property 'warning' changes.
* If there's a warning, we want to update the background color of the
* chart.
*/
warningChanged: function() {
if (this.warning) {
this.chartOptions.grid.backgroundColor = '#e6e6e6';
} else {
this.chartOptions.grid.backgroundColor = null;
}
if (this.chart) {
this.chart.getOptions().grid.backgroundColor =
this.chartOptions.grid.backgroundColor;
this.chart.setupGrid();
this.chart.draw();
}
},
/**
* Updates the currently displayed chart.
*/
updateChart: function() {
if (!this.drawable) {
return;
}
var data = this.getDataForFlot();
if (data.length == 0) {
this.chart = $.plot(this.$.plot, data, this.chartOptions);
return;
}
var startRev = uri.getParameter('start_rev')
var endRev = uri.getParameter('end_rev');
var firstSeriesIsEmpty = data[0].data.length == 0;
if (startRev && endRev && firstSeriesIsEmpty) {
this.warning = 'Data not available for revision range ' +
startRev + ':' + endRev + '.';
}
var isNotZoomedIn = this.$.original.hidden;
if (isNotZoomedIn) {
this.setSmartAutoscale();
}
this.createFixedXAxis(data);
this.chart = $.plot(this.$.plot, data, this.chartOptions);
this.showAlerts();
},
/**
* Puts together the array of series to pass to Flot's $.plot function.
* The data to plot is based on this.json and this.indicesToGraph.
* @return {Array.<Object>} Data to plot. This could be an empty array.
*/
getDataForFlot: function() {
if (!this.json) {
console.debug('getDataForFlot: this.json not set.');
return [];
}
if (!this.json['data'] || !this.json['error_bars']) {
console.debug('getDataForFlot: data or error bars not set.');
return [];
}
var data = [];
for (var i = 0; i < this.indicesToGraph.length; i++) {
var index = this.indicesToGraph[i];
if (index >= 0 && this.json.data[index]) {
data.push(deepCopy(this.json.data[index]));
} else {
console.debug('getDataForFlot: indicesToGraph: ',
this.indicesToGraph);
return [];
}
}
// Add in error bars after data.
for (var i = 0; i < this.indicesToGraph.length; i++) {
var index = this.indicesToGraph[i];
if (index >= 0 && this.json['error_bars'][index]) {
data.push(deepCopy(this.json['error_bars'][index][0]));
data.push(deepCopy(this.json['error_bars'][index][1]));
} else {
console.debug('getDataForFlot: indicesToGraph: ',
this.indicesToGraph);
return [];
}
}
return data;
},
/**
* Modifies the given series data so that the X-values are indexes
* instead of revision numbers. This is called before plotting.
*
* We want a fixed x-axis, with points evenly spaced. Flot has
* functions to scale the x-axis, but they break vertical zooming.
* So we plot a copy of the JSON we get from the server, with the
* revisions reordered starting from 0 and incrementing once for each
* rev.
*
* We start by getting a list of all the revisions, and ordering them
* from 0-N. Because there can be gaps in the graphs, we want to look
* at all the charts, even ones that are not plotted, so that the gaps
* are accounted for accurately.
*
* TODO(qyearsley): This function has at least two separate
* responsibilities: constructing this.revisionMap and actually
* changing the X-values. These could be separated out.
*
* @param {Array.<Object>} data
*/
createFixedXAxis: function(data) {
// Make a map of all revisions for all data series to a series index
// and data index.
var allRevisions = {};
if (!this.json || !this.json.data) {
console.debug('createFixedXAxis: !this.json or !this.json.data.');
return;
}
for (var jsonSeriesIndex = 0;
jsonSeriesIndex < this.json.data.length;
jsonSeriesIndex++) {
var series = this.json.data[jsonSeriesIndex];
var numPoints = series.data.length;
for (var dataIndex = 0; dataIndex < numPoints; dataIndex++) {
var revision = series.data[dataIndex][0];
allRevisions[revision] = [series.index, dataIndex];
}
}
// Make an ordered list of all revision numbers for any series.
var orderedRevisions = Object.keys(allRevisions).sort(function(a, b) {
return a - b;
});
if (orderedRevisions.length == 0) {
// If there's no data to plot, then this array will be empty.
return;
}
this.$.slider.startrev = orderedRevisions[0];
this.$.slider.endrev = orderedRevisions[orderedRevisions.length - 1];
// We keep a map of the ordered revision index to the [revision,
// series index, data index] so that it's easy to show the right
// label on the X-axis. This is updated here but used in formatXAxis.
this.revisionMap = {};
// We also want to make a reverse-lookup object which maps values in
// in |orderedRevisions| to their indexes. This will be used below.
var revisionToIndexMap = {};
for (var i = 0; i < orderedRevisions.length; i++) {
var rev = orderedRevisions[i];
this.revisionMap[i] =
[rev, allRevisions[rev][0], allRevisions[rev][1]];
revisionToIndexMap[rev] = i;
}
// Now that we have all the data we need cached, update the revisions
// for each of the data points that will be shown on the graph.
for (var seriesIndex = 0; seriesIndex < data.length; seriesIndex++) {
var series = data[seriesIndex];
var numPoints = series.data.length;
for (var dataIndex = 0; dataIndex < numPoints; dataIndex++) {
var revision = series.data[dataIndex][0];
series.data[dataIndex][0] = revisionToIndexMap[revision];
}
}
this.chartOptions.xaxis.minTickSize = orderedRevisions.length / 10;
},
/**
* Formats numbers that are displayed at ticks on the X-axis.
*
* If there are annotations for the first data series and for this point
* there is a revision type specified by "a_default_rev", then we can
* display that on the x-axis. Otherwise, we can just use the revision
* (point ID) as-is.
*
* Note: This is similar to xAxisTickFormatter in embed.js.
*
* @param {number} val An X-value.
* @param {Object} axis Not used.
* @return {string} A string that should be shown on the X-axis.
*/
formatXAxis: function(val, axis) {
var lookupIndex = Math.round(Math.max(val, 0));
if (!this.revisionMap) {
// this.revisionMap may not yet be set by this.createFixedXAxis.
return '';
}
var lookup = this.revisionMap[lookupIndex];
if (!lookup) {
return '';
}
var rev = lookup[0];
var seriesIndex = lookup[1];
var dataIndex = lookup[2];
var annotations = this.json.annotations;
if (annotations[seriesIndex] && annotations[seriesIndex][dataIndex]) {
var annotation = annotations[seriesIndex][dataIndex];
var defaultRev = annotation['a_default_rev'];
if (defaultRev && annotation[defaultRev]) {
return getDisplayRevision(annotation[defaultRev]);
}
}
return getDisplayRevision(rev);
},
/**
* Formats numbers that are displayed at ticks on the Y-axis.
* @param {(number|string)} val A Y value.
* @param {Object} axis Not used.
* @return {string}
*/
formatYAxis: function(val, axis) {
return formatNumber(val);
},
/**
* Displays alerts on the chart.
*/
showAlerts: function() {
// Clear out old anomalies.
var container = this.$['anomaly-container'];
container.innerHTML = '';
var minHeight = this.chart.getAxes().yaxis.min;
var maxHeight = this.chart.getAxes().yaxis.max;
var xOffset = this.chartOptions.yaxis.labelWidth;
var flotData = this.chart.getData();
var annotations = this.json.annotations;
for (var flotSeriesIndex = 0;
flotSeriesIndex < flotData.length;
flotSeriesIndex++) {
var jsonSeriesIndex = flotData[flotSeriesIndex].index;
if (jsonSeriesIndex == null || !annotations[jsonSeriesIndex]) {
continue;
}
for (var dataIndex = 0;
dataIndex < flotData[flotSeriesIndex].data.length;
dataIndex++) {
// TODO(qyearsley): Extract the inside of this loop.
if (!annotations[jsonSeriesIndex][dataIndex] ||
!annotations[jsonSeriesIndex][dataIndex]['g_anomaly'] ||
flotData[flotSeriesIndex].data[dataIndex][1] < minHeight ||
flotData[flotSeriesIndex].data[dataIndex][1] > maxHeight) {
continue;
}
var annotation = annotations[jsonSeriesIndex]
[dataIndex]['g_anomaly'];
var bugId = annotation['bug_id'];
var improvement = annotation['improvement'];
var recovered = annotation['recovered'];
var key = annotation.key;
var src = '/black_alert.png';
if (!bugId && !improvement) {
src = '/red_alert.png';
} else if (!bugId && improvement) {
src = '/green_alert.png';
}
var left = xOffset + Math.round(
flotData[flotSeriesIndex].xaxis.p2c(
flotData[flotSeriesIndex].data[dataIndex][0]));
var top = Math.round(
flotData[flotSeriesIndex].yaxis.p2c(
flotData[flotSeriesIndex].data[dataIndex][1]));
var img = document.createElement('img');
img.src = src;
img.style.left = left + 'px';
img.style.top = top + 'px';
if (bugId && bugId < 0 || recovered) {
// Triaged as invalid, ignored, or recovered; lower opacity.
img.style.opacity = 0.4;
}
img.classList.add('anomaly');
// alertKey is an optional attribute to specify that this chart
// is associated with an alert.
if (this.alertKey == key) {
img.classList.add('highlighted');
}
img.onmouseover = this.showTooltip.bind(
this, flotSeriesIndex, dataIndex);
img.onclick = this.onAlertClick.bind(
this, flotSeriesIndex, dataIndex);
this.$['anomaly-container'].appendChild(img);
}
}
},
/**
* Handler for 'mouseleave' event, hides the tooltip.
*/
onMouseLeave: function(event) {
this.hideTooltip();
},
/**
* Handler for 'plotselected' event, fired when a plot selection is
* made.
* For more information about selection in flot, see:
* http://www.flotcharts.org/flot/jquery.flot.selection.js
* @param {Event} event
* @param {Object} ranges
*/
onPlotSelected: function(event, ranges) {
this.selecting = false;
this.lastSelectedDelta = ranges;
},
/**
* Handler for 'plotselecting' event, fired repeatedly when selecting.
* @param {Event} event
* @param {Object} ranges
*/
onPlotSelecting: function(event, ranges) {
if (!ranges) {
return;
}
if (!this.selecting) {
this.firstSelectedValue = ranges.yaxis.from;
this.selecting = true;
}
this.$['delta-off'].hidden = true;
this.$['delta-drag'].hidden = false;
var delta = Math.abs(ranges.yaxis.to - ranges.yaxis.from);
this.deltaAbsolute = delta.toFixed(2);
this.deltaPercent =
(delta * 100 / this.firstSelectedValue).toFixed(2);
},
/**
* Handler for 'plotunselected' event, fired when selection is
* cancelled.
*/
onPlotUnselected: function() {
this.selecting = false;
this.$['delta-off'].hidden = false;
this.$['delta-drag'].hidden = true;
},
/**
* Handler for the 'plotclick' event, fired when the chart is clicked.
* @param {Event} event
* @param {Object} pos An object which contains the keys "x" and "y".
* @param {Object} item
*/
onPlotClick: function(event, pos, item) {
if (this.lastSelectedDelta) {
var from = this.lastSelectedDelta.yaxis.from;
var to = this.lastSelectedDelta.yaxis.to;
if (pos.y == from || pos.y == to) {
// Flot sends a spurious click event at the end of the selection.
return;
}
var min = Math.min(from, to), max = Math.max(from, to);
if (pos.y > min && pos.y < max) {
this.chartOptions.yaxis.min = min;
this.chartOptions.yaxis.max = max;
this.$.original.hidden = false;
this.updateChart();
this.$.original.style.left =
(this.$.plot.offsetWidth - 120) + 'px';
}
this.lastSelectedDelta = null;
} else if (item) {
// Click on a data point; show revision history and sticky tooltip.
if (this.stickyTooltip && item.dataIndex == this.currentItem) {
// hide the tooltip if the user clicks the same point again
this.stickyTooltip = false;
this.hideTooltip();
} else {
// show the tooltip if the user clicked on a new one
this.stickyTooltip = true;
this.currentItem = item.dataIndex;
this.showTooltip(item.seriesIndex, item.dataIndex);
}
} else {
// clicked an empty place on the graph
this.hideTooltipEvenIfSticky();
}
},
/**
* Brings up the triage dialog when an alert circle is clicked.
* @param {number} flotSeriesIndex Index into the flot data series
* array `this.chart.getData()`.
* @param {number} dataIndex Index into an array of points.
* @param {Event} The click event.
*/
onAlertClick: function(flotSeriesIndex, dataIndex, event) {
// Note: this.chart.getData() returns a list of series objects
// (including series objects for error regions).
var jsonSeriesIndex = this.chart.getData()[flotSeriesIndex].index;
if (jsonSeriesIndex == null) {
return;
}
var data = this.json.data[jsonSeriesIndex].data;
// Populate the nudgeList array with information about nearby
// revisions. Each entry in the nudgeList is an object with basic
// information about a point.
var nudgeList = [];
for (var i = Math.max(1, dataIndex - 5);
i < Math.min(dataIndex + 5, data.length -1);
i++) {
// Get annotation information about this nearby point.
// This might be null if there are gaps in the data series.
// In that case, we can just not add the point to the nudge list.
var annotation = this.json.annotations[jsonSeriesIndex][i];
if (!annotation) {
continue;
}
var displayRevision = data[i][0];
if (annotation['a_default_rev']) {
displayRevision = annotation[annotation.a_default_rev];
}
displayRevision = getDisplayRevision(displayRevision);
var amount = ' 0: ';
if (i < dataIndex) {
amount = '-' + (dataIndex - i) + ': ';
} else if (i > dataIndex) {
amount = '+' + (i - dataIndex) + ': ';
}
nudgeList.push({
startRevision: data[i - 1][0] + 1,
endRevision: data[i][0],
displayEndRevision: displayRevision,
value: Math.round(data[i][1] * 100) / 100,
selected: i == dataIndex,
dataIndex: i,
amount: amount
})
}
// Get the anomaly info object, and add a few fields.
var annotation = this.json.annotations[jsonSeriesIndex][dataIndex];
var anomalyInfo = annotation.g_anomaly;
anomalyInfo['nudgeList'] = nudgeList;
anomalyInfo['seriesIndex'] = jsonSeriesIndex;
anomalyInfo['dataIndex'] = dataIndex;
// The alerts and triaged lists are attributes of chart-tooltip
// which are used for showing information about anomalies. The alerts
// list is also used for showing the triage dialog.
var alerts = null, triaged = null;
if (anomalyInfo.bug_id) {
triaged = [anomalyInfo];
} else {
alerts = [anomalyInfo];
}
this.stickyTooltip = true;
this.showTooltip(flotSeriesIndex, dataIndex, alerts, triaged);
},
/**
* Updates the interface when a bug is triaged.
* This is the event handler for a 'triaged' event on the
* chart-tooltip.
*/
onBugTriaged: function(event) {
var jsonSeriesIndex = event.detail.alerts[0].seriesIndex;
var dataIndex = event.detail.alerts[0].dataIndex;
var bugId = event.detail.bugid;
// Update the bug id locally.
this.json.annotations[jsonSeriesIndex][dataIndex]
['g_anomaly']['bug_id'] = bugId;
this.$.tooltip.bugId = bugId;
if (!bugId) {
this.$.tooltip.alerts = this.$.tooltip.triagedAlerts;
this.$.tooltip.triagedAlerts = null;
} else {
this.$.tooltip.alertInvalidOrIgnored = bugId < 0;
this.$.tooltip.triagedAlerts = this.$.tooltip.alerts;
this.$.tooltip.alerts = null;
var data = this.json.annotations[jsonSeriesIndex][dataIndex];
this.$.tooltip.alertKey = data['g_anomaly'].key;
}
// Refresh the anomalies to make the one with the new bug id black.
this.showAlerts();
},
/**
* Handler for the alertChangedRevisions event.
*
* The alertChangedRevisions event is fired from the triage dialog when
* an alert is nudged.
*/
onAlertChangedRevisions: function(event) {
var jsonSeriesIndex = event.detail.alerts[0].seriesIndex;
var dataIndex = event.detail.alerts[0].dataIndex;
var newDataIndex = event.detail.newDataIndex;
var seriesAnnotations = this.json.annotations[jsonSeriesIndex];
var alertInfo = seriesAnnotations[dataIndex]['g_anomaly'];
alertInfo['start_revision'] = event.detail.startRev;
alertInfo['end_revision'] = event.detail.endRev;
seriesAnnotations[dataIndex]['g_anomaly'] = null;
seriesAnnotations[newDataIndex]['g_anomaly'] = alertInfo;
this.showAlerts();
},
/**
* Handler for the 'plothover' event, fired when the mouse is hovering
* over the plot area. If the user is hovering over a point, we want to
* show the tooltip for that point.
* @param {Event} event
* @param {Object} pos
* @param {Object} item
*/
onPlotHover: function(event, pos, item) {
// If the current tooltip is sticky, just leave it.
if (this.stickyTooltip) {
return;
}
// If the current tooltip isn't sticky but we're hovering over
// nothing, hide the tooltip.
if (!item) {
this.hideTooltip();
return;
}
// If the current tooltip isn't sticky and we're over a datapoint,
// show the tooltip.
this.showTooltip(item.seriesIndex, item.dataIndex);
},
/**
* Displays a tooltip for the given point on the graph.
* TODO(qyearsley): Refactor, extract sub-functions.
* NOTE: Instead of taking lists of alerts and triaged alerts, it
* would be possible for this function to just take one alert, since
* there should never generally be more than one alert at one point.
* @param {number} flotSeriesIndex The index of the series in
* `this.chart.getData()`.
* @param {number} dataIndex The index of the data in the series.
* @param {Array} opt_alerts Array of alerts.
* @param {Array} opt_triaged
*/
showTooltip: function(
flotSeriesIndex, dataIndex, opt_alerts, opt_triaged) {
// Reset the properties of the tooltip so that nothing is left over
// from the last time that showTooltip was called on another point.
this.resetTooltip();
// Don't show the tooltip if we're selecting a range on the y axis.
if (this.selecting) {
return;
}
var flotData = this.chart.getData();
var jsonSeriesIndex = this.chart.getData()[flotSeriesIndex].index;
if (jsonSeriesIndex == null) {
console.warn('showTooltip returned after ' + jsonSeriesIndex +
' was null for flotSeriesIndex ' + flotSeriesIndex);
return;
}
// Set the main properties of the tooltip.
var series = this.json.annotations.series[jsonSeriesIndex];
var annotation = this.json.annotations[jsonSeriesIndex][dataIndex];
// In the data from this.chart.getData(), the series indexes are
// different, and the point x-values are all converted to indexes.
var pointFromChart = flotData[flotSeriesIndex].data[dataIndex];
// The data from /graph_json is the data as it originally came,
// and the x-values are the original Row IDs.
var seriesFromJson = this.json.data[jsonSeriesIndex];
var pointFromJson = seriesFromJson.data[dataIndex];
var xValue = pointFromChart[0];
var pointId = pointFromJson[0];
var yValue = pointFromJson[1];
this.$.tooltip.testPath = series.path;
this.$.tooltip.value = formatNumber(yValue);
if (annotation.error > 0) {
this.$.tooltip.stddev = formatNumber(annotation.error);
}
this.$.tooltip.pointId = pointId;
this.$.tooltip.revisions = this.getRevisions(
jsonSeriesIndex, dataIndex);
this.$.tooltip.links = this.getTooltipLinks(annotation, pointId);
if (annotation.timestamp) {
var d = new Date(annotation.timestamp);
this.$.tooltip.timestamp = d.toISOString();
}
// Set the alert-related properties of the tooltip.
this.$.tooltip.alerts = opt_alerts;
this.$.tooltip.triagedAlerts = opt_triaged;
if (annotation['g_anomaly']) {
if (annotation.g_anomaly['bug_id']) {
this.$.tooltip.bugId = annotation['g_anomaly']['bug_id'];
this.$.tooltip.alertInvalidOrIgnored =
annotation['g_anomaly']['bug_id'] < 0;
this.$.tooltip.alertKey = annotation['g_anomaly']['key'];
}
this.$.tooltip.recovered = annotation['g_anomaly']['recovered'];
}
// Show information in the tooltip for use by the bisect button.
if (series.path.lastIndexOf('ref') + 3 == series.path.length) {
hideBisect = true;
}
bisectRevisions = this.getRevisionForBisect(
jsonSeriesIndex, dataIndex, series.path);
var traceRerunInfo = [];
if (annotation['a_trace_rerun_options']) {
for (var i in annotation['a_trace_rerun_options']) {
var currentOption = annotation['a_trace_rerun_options'][i];
traceRerunInfo.push({
'name': i,
'option': currentOption
});
}
} else {
traceRerunInfo.push({
'name': 'Default Trace Profiler',
'option': '--profiler=trace'
});
}
this.$.tooltip.bisectInfo = {
badRev: bisectRevisions['bad_revision'],
goodRev: bisectRevisions['good_revision'],
testPath: series.path,
traceRerunInfo: traceRerunInfo
};
// Un-hide and position the tooltip box.
this.$.tooltip.hidden = false;
this.$.tooltip.style.left =
flotData[flotSeriesIndex].xaxis.p2c(xValue) +
this.chartOptions.yaxis.labelWidth + 20 + 'px';
this.$.tooltip.style.top =
flotData[flotSeriesIndex].yaxis.p2c(yValue) + 20 + 'px';
},
/**
* Reset the chart-tooltip's properties.
*/
resetTooltip: function() {
this.$.tooltip.testPath = null;
this.$.tooltip.value = null;
this.$.tooltip.stddev = null;
this.$.tooltip.pointId = null;
this.$.tooltip.revisions = null;
this.$.tooltip.links = null;
this.$.tooltip.alerts = null;
this.$.tooltip.triagedAlerts = null;
this.$.tooltip.bugId = null;
this.$.tooltip.alertKey = null;
this.$.tooltip.recovered = null;
this.$.tooltip.timestamp = null;
this.$.tooltip.bisectInfo = null;
},
/**
* Returns good and bad revisions for bisect.
*/
getRevisionForBisect: function(index, dataIndex, testPath) {
bisectRange = this.getDefaultRevRangeFromAnnotations(
index, dataIndex);
if (bisectRange) {
return bisectRange;
}
var dataPoint = this.json.data[index];
var badRev = dataPoint.data[dataIndex][0];
if (getDateDisplay(badRev)) {
for (var i in this.$.tooltip.revisions) {
var revObj = this.$.tooltip.revisions[i];
if (revObj.hasOwnProperty('name') &&
revObj['name'].indexOf('Chromium Git Hash') != -1) {
return {
'bad_revision': revObj['end'],
'good_revision': revObj['start'] ? revObj['start'] : null,
}
}
}
}
return {
'bad_revision': badRev,
'good_revision': dataIndex ? dataPoint.data[dataIndex - 1][0] : null,
}
},
/**
* Returns good and bad revisions for bisect by checking a_default_rev.
* @param {number} seriesIndex Index into the this.json.annotations.
* @param {number} dataIndex The index of the point within the series.
* @return {Object} A map object with the keys "bad_revision" and
* "good_revision".
*/
getDefaultRevRangeFromAnnotations: function(seriesIndex, dataIndex) {
var annotations = this.json.annotations;
if (annotations[seriesIndex] && annotations[seriesIndex][dataIndex]) {
var badAnnotation = annotations[seriesIndex][dataIndex];
var badDefaultRevType = badAnnotation['a_default_rev'];
if (badDefaultRevType && badAnnotation[badDefaultRevType]) {
if (!dataIndex) {
return {
'bad_revision': badAnnotation[badDefaultRevType],
'good_revision': null,
}
}
var goodAnnotation = annotations[seriesIndex][dataIndex - 1];
if (goodAnnotation) {
var goodDefaultRevType = goodAnnotation['a_default_rev'];
if (goodDefaultRevType == badDefaultRevType) {
return {
'bad_revision': badAnnotation[badDefaultRevType],
'good_revision': goodAnnotation[goodDefaultRevType],
}
}
}
}
}
return null;
},
/**
* Hides the tooltip only if it's not sticky.
*/
hideTooltip: function() {
if (this.stickyTooltip) {
return;
}
this.$.tooltip.hidden = true;
},
/**
* Force-hides the tooltip.
*/
hideTooltipEvenIfSticky: function() {
this.stickyTooltip = false;
this.hideTooltip();
},
/**
* Gets a list of obj about links to put in the tooltip.
* @param {Object} annotation Annotation object for this point.
* @param {number} pointId Main revision at the given point.
* @return {Array.<Object>} List of objects with properties text and
* url.
*/
getTooltipLinks: function(annotation, pointId) {
var links = [];
if (annotation.a_stdio_uri) {
links.push({
'text': 'Buildbot stdio',
'url': annotation['a_stdio_uri']
});
}
if (annotation['g_anomaly']) {
var testPath = this.graphParams.masters[0] + '/' +
this.graphParams.bots[0] + '/' +
this.graphParams.tests[0];
var debugUrl = '/debug_alert?test_path=' + testPath +
'&rev=' + pointId;
links.push({
'text': 'Debug alert',
'url': debugUrl
});
}
if (annotation['a_tracing_uri']) {
links.push({
'text': 'Trace',
'url': annotation['a_tracing_uri']
})
}
return links;
},
/**
* Returns an array of revision range info objects for the given point.
*
* This array is used to show revision ranges and change log links in
* the tooltip.
* Example format for the returned array:
* [
* {
* name: "Chrome Revision",
* start: 216441,
* end: 216538,
* displayStart: 216441,
* displayEnd: 216538,
* url: "http://x.chromium.org/changelog.html?range=216441:216538",
* },
* ...
* ]
*
* @param {number} seriesIndex An index in this.json.data.
* @param {number} dataIndex The index of the point within the series.
* @return {Array.<Object>} An array of information needed for showing
* change log links for different types of revisions.
*/
getRevisions: function(seriesIndex, dataIndex) {
var annotationSeries = this.json.annotations[seriesIndex];
var annotation = annotationSeries[dataIndex];
var previousAnnotation = annotationSeries[dataIndex - 1];
var defaultRevisionRangeInfo = null;
var revisionRangeInfoObjects = [];
for (var revisionTypeKey in annotation) {
if (revisionTypeKey.indexOf('r_') != 0) {
continue; // Not a revision.
}
if (!this.revisionInfo[revisionTypeKey]) {
console.warn('No entry', revisionTypeKey, 'in', this.revisionInfo);
continue;
}
var end = annotation[revisionTypeKey];
var start = -1;
if (dataIndex > 0 && previousAnnotation &&
previousAnnotation[revisionTypeKey]) {
start = previousAnnotation[revisionTypeKey];
}
var revisionRangeInfo = this.getRevisionRangeInfo(
revisionTypeKey, start, end);
if (revisionTypeKey == annotation['a_default_rev']) {
// The default revision type will be inserted afterwards.
defaultRevisionRangeInfo = revisionRangeInfo;
} else {
revisionRangeInfoObjects.push(revisionRangeInfo);
}
}
// Revisions are sorted by name, with the default first.
revisionRangeInfoObjects.sort(function(a, b) {
var aName = a['name'].toLowerCase();
var bName = b['name'].toLowerCase();
return aName.localeCompare(bName);
});
if (defaultRevisionRangeInfo) {
revisionRangeInfoObjects.unshift(defaultRevisionRangeInfo);
}
return revisionRangeInfoObjects;
},
/**
* Returns an object with revision-related information for one type
* of revision, for two revision numbers.
*
* The property this.revisionInfo is set in graph.js from the global
* variable REVISION_INFO, which is embedded based on the content of
* chart_handler.py.
*
* @param {string} revisionTypeKey A key in this.revisionInfo.
* This will be a string starting with "r_".
* @param {(number|string|boolean)} start Optional start revision.
* False may be given if the start and end revision are the same.
* @param {(number|string)} end End revision or only revision.
* @return {Object} The information necessary for showing a link for
* a change log for a particular revision range.
*/
getRevisionRangeInfo: function(revisionTypeKey, start, end) {
var revisionInfo = this.revisionInfo[revisionTypeKey];
if (!revisionInfo) {
console.warn('No entry', revisionTypeKey, 'in', this.revisionInfo);
}
var revisionTypeKey = revisionInfo['name'];
var urlTemplate = revisionInfo['url'];
if (!isNaN(start) && !isNaN(end) && Number(start) > Number(end)) {
console.warn('Start and end revisions flipped:', start, end);
var temp = start;
start = end;
end = temp;
}
// For a regression range, start is the "last known good" revision.
// We want the "first possible bad" revision.
if (!isNaN(start) && (start != end)) {
start = Number(start) + 1;
}
// If the substring R1_trim is found in the URL template, we assume
// that it's the URL template for Crome OS versions.
var url = '';
if (urlTemplate.indexOf('{{R1_trim}}') != -1) {
url = this.fillInChromeOSChangeLogURL(urlTemplate, start, end);
} else {
url = urlTemplate.replace('{{R1}}', start).replace('{{R2}}', end);
}
if (start == end || !start) {
start = false;
}
return {
'name': revisionTypeKey,
'url': url,
'start': start,
'end': end,
'displayStart': getDisplayRevision(start),
'displayEnd': getDisplayRevision(end)
};
},
/**
* Fills in a Chrome OS or Chrome version change log URL.
* The chromeOS diff tool expects version numbers to be composed of
* only the right-most 3 components of the number (i.e., we need to
* drop the left-most component when constructing the URL below).
* The format of the specified version number should be "X.Y.Z",
* not "W.X.Y.Z".
* @param {string} urlTemplate A string with the template fields
* R1_trim and R2_trim.
* @param {string} start Start version string.
* @param {string} end End version string.
*/
fillInChromeOSChangeLogURL: function(urlTemplate, start, end) {
var urlEndVersion = end.substring(end.indexOf('.') + 1);
var urlStartVersion = '';
if (start) {
urlStartVersion = start.substring(start.indexOf('.') + 1);
} else {
urlStartVersion = urlEndVersion;
}
var url = urlTemplate.replace('{{R1_trim}}', urlStartVersion);
url = url.replace('{{R2_trim}}', urlEndVersion);
return url;
},
/**
* Handler for the click event for the 'View original graph' link.
*
* This link only appears after zooming in on a smaller Y-axis range
* (by clicking and dragging and then clicking). When the 'view
* original graph' link has been clicked, the original Y-axis range
* should be restored.
*/
onViewOriginal: function() {
this.chartOptions.yaxis.min = null;
this.chartOptions.yaxis.max = null;
this.$.original.hidden = true;
this.updateChart();
},
/**
* Handler for the click event of the select all traces button.
* Updates this.indicesToGraph to contain all traces.
* @param {Event} opt_noEvent The click event, not used.
*/
onSelectAll: function(opt_noEvent) {
if (this.loadingUnselected) {
// Still loading.
return;
}
var series = this.json.annotations.series, indices = [];
for (var i = 0; i < series.length; i++) {
indices.push(i);
}
this.indicesToGraph = indices;
},
/**
* Handler for the click event of the deselect all traces button.
* @param {Event} opt_noEvent The click event, not used.
*/
onDeselectAll: function(opt_noEvent) {
this.indicesToGraph = [];
},
/**
* Handler for the click event of the select core traces button.
* Selects only the core traces (i.e. important and ref traces).
* Note: The property 'coreTraces' is set in graph.js.
* @param {Event} opt_event The click event, not used.
*/
onSelectCore: function(opt_noEvent) {
if (this.loadingUnselected) {
// Still loading.
return;
}
var series = this.json.annotations.series, indices = [];
for (var i = 0; i < series.length; i++) {
if (this.coreTraces[series[i].name]) {
indices.push(i);
}
}
this.indicesToGraph = indices;
},
/**
* Handler for resize event.
* @param {Event} event
*/
onResize: function(event) {
// Try not to resize graphs until the user has stopped resizing
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(this.resizeGraph.bind(this), 100);
},
/**
* Resizes the chart if it's present.
*/
resizeGraph: function() {
if (!this.chart) {
return;
}
// The chart is resized when it's updated.
this.updateChart();
},
/**
* On collapseLegend change, updates graph and chart-revision sizes.
*/
collapseLegendChanged: function() {
this.onResize();
this.$.slider.onResize();
},
/**
* Toggles legend window to collapse or expand.
*/
toggleLegend: function() {
this.$['collapsible-legend'].toggle();
this.collapseLegend = !this.collapseLegend;
}
});
})();
</script>
</polymer-element>