blob: 8d09a02748c0f07928ffdec94cd9209c8485b686 [file] [log] [blame]
<!DOCTYPE html>
<!--
Copyright 2016 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<!--
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="/components/iron-flex-layout/iron-flex-layout-classes.html">
<link rel="import" href="/components/iron-icon/iron-icon.html">
<link rel="import" href="/components/paper-button/paper-button.html">
<link rel="import" href="/dashboard/elements/alert-icon.html">
<link rel="import" href="/dashboard/elements/chart-legend.html">
<link rel="import" href="/dashboard/elements/chart-slider.html">
<link rel="import" href="/dashboard/elements/chart-title.html">
<link rel="import" href="/dashboard/elements/chart-tooltip.html">
<link rel="import" href="/dashboard/static/graph.html">
<link rel="import" href="/dashboard/static/simple_xhr.html">
<link rel="import" href="/dashboard/static/testselection.html">
<link rel="import" href="/dashboard/static/uri.html">
<dom-module name="chart-container">
<template>
<style include="iron-flex iron-flex-alignment iron-flex-factors">
#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;
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;
}
#loading-div {
position: relative;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
display: -webkit-flex;
align-items: center;
-webkit-align-items: center;
justify-content: center;
-webkit-justify-content: center;
}
#alert-icon-container {
width: 0px;
height: 0px;
left: 0px;
top: 0px;
position: relative;
}
alert-icon {
position: absolute;
margin-top: -5px;
margin-left: -6px;
z-index: 1000;
}
#close-chart {
color: #e91e63;
}
#close-chart > iron-icon {
margin-right: 4px;
}
#top-bar {
width: 100%;
}
</style>
<div id="container" compact$="{{showCompact}}">
<div id="top-bar" class="layout horizontal">
<div class="layout horizontal center-justified flex">
<chart-title id="title" series-group-list="{{seriesGroupList}}"
test-suites="{{testSuites}}"></chart-title>
</div>
<div class="layout horizontal center">
<paper-button id="close-chart" on-click="closeChartClicked">
Close
</paper-button>
</div>
</div>
<div id="horizontal">
<div id="chart-yaxis-container">
<div id="chart-yaxis-label">{{chartYAxisLabel}}</div>
</div>
<div id="alert-icon-container"></div>
<chart-tooltip id="tooltip"
xsrf-token="{{xsrfToken}}"></chart-tooltip>
<div id="plots-container" on-mouseleave="onMouseLeave">
<div id="plot">
<div id="loading-div">
<img src="//www.google.com/images/loading.gif">
</div>
</div>
<chart-slider id="slider" on-revisionrange="onRevisionRange"></chart-slider>
</div>
<div id="warning">
<template is="dom-repeat" items="{{warnings}}">
<span>{{item}}</span><br>
</template>
</div>
<a hidden id="original"
on-click="onViewOriginal"
href="javascript:void(0);">View original graph</a>
<chart-legend id="legend"
series-group-list="{{seriesGroupList}}"
indices-to-graph="{{indicesToGraph}}"
collapse-legend="{{collapseLegend}}"
on-dragstart="legendSeriesDragStart"
on-dragend="legendSeriesDragEnd"
delta-absolute="{{deltaAbsolute}}"
delta-percent="{{deltaPercent}}"
show-delta="{{showDelta}}">
</div>
</div>
</template>
<script>
'use strict';
(function() {
// Minimum multiple standard deviation for a value to be considered
// an outlier.
var MULTIPLE_OF_STD_DEV = 5;
// Regex for links in series annotation which are shown in tooltip.
var TOOLTIP_LINK_REGEX = /\[(.+?)\]\((.+?)\)/g;
/**
* 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} 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.toLocaleString();
}
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 A number or string.
* @return {string} A string.
*/
function formatNumber(val) {
var num = Number(val);
var options = {
minimumSignificantDigits: 2,
maximumSignificantDigits: 6
};
return num.toLocaleString('en-US', options);
};
/**
* Deep compares two dictionary parameters.
* This function converts dictionary parameters to sorted list of pairs
* of key and values and compares their JSON string.
* @param {Object} param1 An object.
* @param {Object} param2 An object.
* @return {boolean} True if equal, otherwise false.
*/
function graphParamEquals(param1, param2) {
if (!param1 || !param2) {
return param1 == param2;
}
param1 = Object.keys(param1).sort().map(function(k) {
return [k, param1[k]];
});
param2 = Object.keys(param2).sort().map(function(k) {
return [k, param2[k]];
});
return JSON.stringify(param1) == JSON.stringify(param2);
};
function arrayUnique(array) {
var dict = {};
array.forEach(function(item) {
dict[item] = true;
});
return Object.keys(dict);
};
Polymer({
is: 'chart-container',
properties: {
SERIES_SELECTION_OPTIONS: {
type: Array,
value: () => ['important', 'all', 'none']
},
// Default values for reverting series highlighting.
DEFAULT_SERIES_PROPERTIES: {
type: Object,
value: () => ({
lineWidth: 2,
opacity: 1,
fill: 0.2,
shadowSize: 3
})
},
DEFAULT_JSON_PROPERTIES: {
type: Object,
value: () => ({
annotations: {
series: {}
},
data: {},
error_bars: {},
warning: null
})
},
// Amount of time before a warning is shown for tests with no new
// data.
STALE_DATA_DELTA_MS: {
type: Number,
value: 7 * 86400000
},
alertKey: { notify: true },
collapseLegend: {
type: Boolean,
value: false,
notify: true,
observer: 'collapseLegendChanged'
},
indicesToGraph: {
type: Array,
value: () => [],
},
// TODO(chrisphan): Make seriesGroupList a class.
// A list of series group dictionary. A series group contains test
// path and list of test information. This is displayed in
// chart-legend, where each test is a checkable series.
// seriesGroupList has the form:
// [{
// 'path': 'ChromiumPerf/linux/dromaeo/Total',
// 'tests': [{
// name: 'Total',
// direction: 'Lower is better',
// units: 'm/s',
// description: 'Total time',
// etc.
// }]
// }]
seriesGroupList: {
notify: true,
type: Array,
value: () => [],
},
// Data series index of the point that was most recently clicked.
currentItem: {
value: null
},
// Meta-data about tests, which will be displayed in the legend.
legendTests: {
type: Array,
value: () => []
},
// Text to display next to the y-axis.
chartYAxisLabel: {
value: '',
type: String
},
// Y-axis delta of current zoom selection, as an absolute number
// and as a percentage of the original selection.
deltaAbsolute: {
value: 0,
type: Number
},
deltaPercent: {
value: 0,
type: Number
},
// Information about the last selection range.
lastSelectedDelta: { value: null },
// Whether or not the user is currently selecting a range.
selecting: {
type: Boolean,
value: false
},
// Y-axis start value in the last selection range.
firstSelectedValue: {
type: Number,
value: 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.
stickyTooltip: {
type: Boolean,
value: false
},
// Map of series indexes to arrays with revision information.
revisionMap: {
type: Object,
value: null
},
// Whether or not the chart can currently be drawn.
drawable: {
type: Boolean,
value: false
},
// The Flot Plot object, returned by $.plot.
chart: {
value: null
},
// Timeout ID of the timer used by this.resizeHandler.
resizeTimer: { value: null },
// Maintain the next series index as new graph json comes in.
nextSeriesIndex: { value: null },
// Whether this chart-container allows a series group to be dropped
// on-to it.
droppable: {
type: Boolean,
value: true
},
// Whether to show delta in chart-legend.
showDelta: {
type: Boolean,
value: false
},
// Chart options to be given when initializing the Flot chart.
// See: https://github.com/flot/flot/blob/master/API.md#plot-options
chartOptions: {
type: Object,
value: () => ({
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: {},
yaxis: {
labelWidth: 60
},
selection: {
mode: 'y'
}
})
},
// Warning text to be shown over the chart, if any.
warnings: {
type: Array,
value: () => [],
observer: 'warningsChanged'
},
graphParams: {
type: Object,
value: () => ({})
},
// List of XMLHttpRequests sent for graph json.
graphJsonRequests: {
type: Array,
value: () => []
},
isInternalUser: { notify: true },
onUri: { observer: 'onUriChanged' },
revisionInfo: { notify: true },
showCompact: { notify: true },
testSuites: { notify: true },
xsrfToken: { notify: true }
},
observers: [
'indicesToGraphChanged(indicesToGraph.splices)'
],
/**
* 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() {
// Handler for the 'resize' event.
this.resizeHandler = this.onResize.bind(this);
// The data fetched from the graph_json handler for this chart.
this.json = deepCopy(this.DEFAULT_JSON_PROPERTIES);
this.chartOptions.xaxis.tickFormatter = this.formatXAxis.bind(this);
this.chartOptions.yaxis.tickFormatter = this.formatYAxis.bind(this);
this.$.legend.addEventListener(
'seriesmouseover', this.seriesMouseover.bind(this), true);
this.$.legend.addEventListener(
'seriesmouseout', this.seriesMouseout.bind(this), true);
this.$.tooltip.addEventListener(
'triaged', this.onBugTriaged.bind(this), true);
this.$.tooltip.addEventListener(
'alertChangedRevisions', this.onAlertChangedRevisions.bind(this),
true);
this.$.legend.addEventListener(
'seriesgroupclosed', this.onSeriesGroupClosed.bind(this), true);
this.$.title.addEventListener(
'titleclicked', this.onTitleClicked.bind(this), true);
window.addEventListener('resize', this.resizeHandler);
this.initializePlotEventListeners();
window.addEventListener('urichange', this.onUriChanged.bind(this));
},
/**
* 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));
},
/**
* Adds series group to graph.
*
* If test path already exist, it will be ignored.
*
* @param {Array.<Array>} testPathAndSelected A list of
* pair of test path and a list of selected series.
* @return {boolean} collapse Whether unchecked and unimportant
* series should start off hidden.
*/
addSeriesGroup: function(testPathAndSelected, collapse) {
// Checks if test path already exists.
var testPathSet = {};
this.seriesGroupList.forEach(function(group) {
testPathSet[group.path] = true;
});
testPathAndSelected = testPathAndSelected.filter(function(value) {
return !(value[0] in testPathSet);
});
if (testPathAndSelected.length == 0) {
return;
}
// We want to send two requests, one for selected and unselected
// series, so there would be a quicker response for the selected
// series. But, if the selected series is unknown, we default to
// send an unselected series.
var selectedTestPathDict = {};
var unSelectedTestPathDict = {};
for (var i = 0; i < testPathAndSelected.length; i++) {
var testPath = testPathAndSelected[i][0];
var selectedTraces = testPathAndSelected[i][1];
selectedTraces = arrayUnique(selectedTraces);
var seriesGroup = {
path: testPath,
tests: [],
collapse: collapse,
numPendingRequests: 0
};
if (selectedTraces.length > 0) {
var selected = selectedTraces[0];
if (this.SERIES_SELECTION_OPTIONS.indexOf(selected) != -1) {
seriesGroup.selection = selectedTraces[0];
selectedTestPathDict[testPath] = [];
} else {
// Create test data here for selected traces so they can
// be initially shown in chart legend.
for (var j = 0; j < selectedTraces.length; j++) {
var testData = {
name: selectedTraces[j],
selected: true
};
seriesGroup.tests.push(testData);
}
selectedTestPathDict[testPath] = selectedTraces;
unSelectedTestPathDict[testPath] = selectedTraces;
}
} else {
unSelectedTestPathDict[testPath] = [];
}
this.push('seriesGroupList', seriesGroup);
}
if (Object.keys(selectedTestPathDict).length > 0) {
this.sendGraphJsonRequest(selectedTestPathDict, true);
}
if (Object.keys(unSelectedTestPathDict).length > 0) {
this.sendGraphJsonRequest(unSelectedTestPathDict, false);
}
this.updateSlider();
this.$.title.update();
},
/**
* Sends a request for graph JSON.
*
* @param {Object} testPathDict Dictionary of test path to list of
* selected series.
* @return {boolean} isSelected Whether this request is for selected
* or unselected series.
*/
sendGraphJsonRequest: function(testPathDict, isSelected) {
var params = JSON.parse(JSON.stringify(this.graphParams));
params['test_path_dict'] = testPathDict;
if (isSelected) {
params['is_selected'] = true;
}
this.updateSeriesGroupLoadingCounter(
Object.keys(testPathDict), true);
var req = simple_xhr.send(
'/graph_json',
{'graphs': JSON.stringify(params)},
function(json) {
if (this.jsonHasData(json)) {
this.updateJson(json, isSelected);
} else if (isSelected) {
this.addWarnings('No data available.');
this.checkForInternalUser();
this.updateChart();
}
this.updateSeriesGroupLoadingCounter(
Object.keys(testPathDict), false);
this.$['loading-div'].style.display = 'none';
}.bind(this),
function(error) {
if (error) {
this.addWarnings('Failed to fetch graph data.');
this.checkForInternalUser();
console.warn(error);
}
this.updateSeriesGroupLoadingCounter(
Object.keys(testPathDict), false);
this.$['loading-div'].style.display = 'none';
}.bind(this)
);
this.graphJsonRequests.push(req);
},
jsonHasData: function(json) {
if (!'data' in json) {
return;
}
var seriesIndices = Object.keys(json.data);
return (seriesIndices.length > 0 &&
json.data[seriesIndices[0]]['data'].length > 0);
},
/**
* Update loading counter for all series groups in a testPaths.
*
* @param {Array} testPaths List of test paths.
* @param {boolean} increment True to add one, False to subtract one.
*/
updateSeriesGroupLoadingCounter: function(testPaths, increment) {
var testPathToGroupIndex = {};
for (var i = 0; i < this.seriesGroupList.length; i++) {
testPathToGroupIndex[this.seriesGroupList[i].path] = i;
}
var delta = (increment) ? 1 : -1;
testPaths.forEach(function(testPath) {
var index = testPathToGroupIndex[testPath];
this.set('seriesGroupList.' + index + '.numPendingRequests',
this.seriesGroupList[index].numPendingRequests + delta);
}.bind(this));
},
checkForInternalUser: function() {
if (!this.isInternalUser) {
this.addWarnings(
'Note that some data is only available when logged in.');
}
},
/**
* Adds a warning message if it doesn't exist.
*/
addWarnings: function(warning) {
for (var i = 0; i < this.warnings.length; i++) {
if (this.warnings[i].indexOf(warning) == 0) {
return;
}
}
this.warnings.push(warning);
},
/**
* Adds warnings for selected series that are stale or have no data.
*/
updateWarningsForSelectedSeries: function() {
this.warnings = this.warnings.filter(function(value) {
return (value.indexOf('Graph out of date!') == -1 &&
value.indexOf('No data available.') == -1);
});
for (var i = 0; i < this.indicesToGraph.length; i++) {
var index = this.indicesToGraph[i];
var series = this.json.annotations[index];
if (!series) {
this.warnings.push('No data available.');
}
}
var hasRevParams = ('rev' in this.graphParams ||
'start_rev' in this.graphParams ||
'end_rev' in this.graphParams);
if (hasRevParams) {
return;
}
for (var i = 0; i < this.indicesToGraph.length; i++) {
var index = this.indicesToGraph[i];
var timestamp = this.getSeriesLatestTimestamp(index);
if (timestamp != null) {
var currentTime = new Date().getTime();
if (timestamp < currentTime - this.STALE_DATA_DELTA_MS) {
this.warnings.push('Graph out of date! Last data received: ' +
new Date(timestamp).toISOString());
break;
}
}
}
},
getSeriesLatestTimestamp: function(seriesIndex) {
var series = this.json.annotations[seriesIndex];
if (series) {
var lastIndex = Math.max.apply(Math, Object.keys(series));
if ('timestamp' in series[lastIndex]) {
return series[lastIndex]['timestamp'];
}
}
return null;
},
/**
* Updates graph data for newJson.
*
* Merge newJson with this.json and update this.seriesGroupList.
*
* @param {Object} newJson JSON data from /graph_json request.
* @param {boolean} isSelected Whether this should be selected or
* unselected set of series.
*/
updateJson: function(newJson, isSelected) {
if (!this.nextSeriesIndex) {
this.nextSeriesIndex = Object.keys(this.json.data).length;
}
var testPathToSeriesIndex = {};
var series = this.json.annotations.series;
for (var i in series) {
testPathToSeriesIndex[series[i].path] = parseInt(i);
}
var newSeries = {};
for (var index in newJson.data) {
var testPath = newJson.annotations.series[index].path;
var nextIndex = null;
if (testPath in testPathToSeriesIndex) {
nextIndex = testPathToSeriesIndex[testPath];
} else {
nextIndex = this.nextSeriesIndex;
this.nextSeriesIndex++;
}
var color = graph.stringToColor(testPath);
newJson.data[index].color = color;
this.updateJsonDataIndex(newJson.data[index], nextIndex);
this.json.data[nextIndex] = newJson.data[index];
// 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[nextIndex].index = nextIndex;
this.json.annotations.series[nextIndex] =
newJson.annotations.series[index];
this.json.annotations[nextIndex] = newJson.annotations[index];
newSeries[nextIndex] = newJson.annotations.series[index];
this.updateErrorBarsIndex(newJson['error_bars'][index], nextIndex,
color);
this.json['error_bars'][nextIndex] = newJson['error_bars'][index];
}
this.updateForNewSeries(newSeries, isSelected);
},
/**
* Updates chart for new list of series.
*
* @param {Array} series A list of series data.
* @param {boolean} isSelected Whether this should be selected or
* unselected set of series.
*/
updateForNewSeries: function(series, isSelected) {
if (!this.drawable) {
// Cache the new series until the chart is drawable.
this.newDrawableSeries = this.newDrawableSeries || [];
this.newDrawableSeries.push([series, isSelected]);
return;
}
this.$['loading-div'].style.display = 'none';
if (Object.keys(series).length == 0) {
return;
}
var testList = [];
for (var i in series) {
i = parseInt(i);
var test = {
name: series[i].name,
path: series[i].path,
direction: series[i].better,
units: series[i].units,
description: series[i].description,
color: this.json.data[i].color,
index: i,
important: testselection.isImportant(
series[i].path.split('/').slice(2).join('/'),
this.testSuites)
};
testList.push(test);
}
this.updateSeriesGroupList(testList);
this.updateSlider();
this.updateYAxisLabel();
this.updateSmartAutoscaleMap();
if (isSelected) {
this.updateIndicesToGraph();
}
},
/**
* Updates this.seriesGroupList for a list of series object.
*
* @param {Array} testList List of test object.
*/
updateSeriesGroupList: function(testList) {
var testPathToGroupIndex = {};
for (var i = 0; i < this.seriesGroupList.length; i++) {
testPathToGroupIndex[this.seriesGroupList[i].path] = i;
}
for (var i = 0; i < testList.length; i++) {
var test = testList[i];
var seriesGroupIndex = null;
if (test.path in testPathToGroupIndex) {
seriesGroupIndex = testPathToGroupIndex[test.path];
} else {
var parentPath = test.path.substring(0,
test.path.lastIndexOf('/'));
if (parentPath in testPathToGroupIndex) {
seriesGroupIndex = testPathToGroupIndex[parentPath];
}
}
if (seriesGroupIndex != null) {
this.updateSeriesGroup(seriesGroupIndex, test);
}
}
},
/**
* Updates a given test in seriesGroup or adds test if not found.
*
* At the time that this function is called, tests in a series
* group are either partially created or not yet added. This
* function replaces the existing test in its series group
* with passed |test|, or inserts if it doesn't exist.
*
* @param {Number} seriesGroupIndex An index in the series group object.
* @param {Object} newTest Contains information about a series
* displayed in chart-legend.
*/
updateSeriesGroup: function(seriesGroupIndex, newTest) {
// Update series if its data has not been loaded.
var testIndex = this.getTestIndexInSeriesGroup(
seriesGroupIndex, newTest.name);
if (testIndex != null) {
for (var key in newTest) {
this.set('seriesGroupList.' + seriesGroupIndex + '.tests.' +
testIndex + '.' + key,
newTest[key]);
}
} else {
this.addTestToSeriesGroup(seriesGroupIndex, newTest);
testIndex = this.getTestIndexInSeriesGroup(
seriesGroupIndex, newTest.name);
}
if (this.seriesGroupList[seriesGroupIndex].selection == 'all' ||
this.seriesGroupList[seriesGroupIndex].selection == 'important' &&
newTest.important) {
this.set('seriesGroupList.' + seriesGroupIndex + '.tests.' +
testIndex + '.selected', true);
}
},
/**
* Adds a test to seriesGroup and determines if it should be hidden.
*/
addTestToSeriesGroup: function(seriesGroupIndex, test) {
var seriesGroup = this.seriesGroupList[seriesGroupIndex];
if (seriesGroup.collapse && !test.selected && !test.important) {
test.hidden = true;
if (seriesGroup.numHidden == undefined) {
this.set('seriesGroupList.' + seriesGroupIndex + '.numHidden', 0);
}
this.set('seriesGroupList.' + seriesGroupIndex + '.numHidden',
seriesGroup.numHidden + 1);
}
this.push('seriesGroupList.' + seriesGroupIndex + '.tests', test);
this.set('seriesGroupList.' + seriesGroupIndex + '.tests',
seriesGroup.tests.sort(this.compareTest));
},
getTestIndexInSeriesGroup: function(seriesGroupIndex, name) {
var tests = this.seriesGroupList[seriesGroupIndex].tests;
for (var i = 0; i < tests.length; i++) {
if (name == tests[i].name) {
return i;
}
}
return null;
},
/**
* Reloads chart with current graph parameters and seriesGroupList.
*
* This is called on graph revision range changed.
*/
reloadChart: function() {
this.graphJsonRequests.forEach(function(request) {
request.abort();
});
this.hideTooltipEvenIfSticky();
this.clearAlertIcons();
this.$['loading-div'].style.display = '';
this.set('indicesToGraph', []);
this.json.warnings = [];
this.warnings = [];
// Create params out of seriesGroupList to make /graph_json request.
var selectedTestPathDict = {};
var unSelectedTestPathDict = {};
for (var i = 0; i < this.seriesGroupList.length; i++) {
var testPath = this.seriesGroupList[i].path;
var tests = this.seriesGroupList[i].tests;
var selectedTraces = [];
for (var j = 0; j < tests.length; j++) {
var test = tests[j];
if (test.selected) {
selectedTraces.push(test.name);
} else {
// Disable unselected series in chart-legend during loading.
test.index = undefined;
}
}
if (selectedTraces.length > 0) {
selectedTestPathDict[testPath] = selectedTraces;
unSelectedTestPathDict[testPath] = selectedTraces;
} else {
unSelectedTestPathDict[testPath] = [];
}
}
if (Object.keys(selectedTestPathDict).length > 0) {
this.sendGraphJsonRequest(selectedTestPathDict, true);
}
if (Object.keys(unSelectedTestPathDict).length > 0) {
this.sendGraphJsonRequest(unSelectedTestPathDict, false);
}
},
updateJsonDataIndex: function(jsonData, index) {
jsonData.index = 'line_' + index;
jsonData.id = 'line_' + index;
},
updateErrorBarsIndex: function(errorBarsData, index, color) {
var top = errorBarsData[0];
var bottom = errorBarsData[1];
top.id = 'top_' + index;
top.fillBetween = 'line_' + index;
top.color = color;
bottom.fillBetween = 'bottom_' + index;
bottom.fillBetween = 'line_' + index;
bottom.color = color;
},
/**
* Updates the list of indices of traces to plot with seriesGroupList.
*/
updateIndicesToGraph: function() {
this.set('indicesToGraph', []);
for (var i = 0; i < this.seriesGroupList.length; i++) {
var tests = this.seriesGroupList[i].tests;
for (var j = 0; j < tests.length; j++) {
if (tests[j].selected && tests[j].index != undefined) {
this.push('indicesToGraph', tests[j].index);
}
}
}
},
/**
* Sorts by tests by important and then by name.
*/
compareTest: function(testA, testB) {
if (testA.important && !testB.important) {
return -1;
}
if (testB.important && !testA.important) {
return 1;
}
if (testA.name > testB.name) {
return 1;
}
if (testA.name < testB.name) {
return -1;
}
return 0;
},
updateSlider: function() {
if (this.seriesGroupList.length == 0 ||
this.seriesGroupList[0].tests.length == 0) {
return;
}
var firstTestPath = this.seriesGroupList[0].tests[0].path;
if (firstTestPath && firstTestPath != this.$.slider.testpath) {
this.$.slider.testpath = firstTestPath;
}
},
/**
* 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
*/
detached: function() {
this.drawable = false;
window.removeEventListener('resize', this.resizeHandler);
},
attached: function() {
this.drawable = true;
// This likely only happens in the unit test, but if series have
// been added before the element became drawable, update now.
if (this.newDrawableSeries) {
for (var drawableSeries of this.newDrawableSeries) {
this.updateForNewSeries(drawableSeries[0], drawableSeries[1]);
}
}
},
/**
* Handler for the click event of X button on the top-right corner.
*/
closeChartClicked: function() {
Polymer.dom(Polymer.dom(this).parentNode).removeChild(this);
this.fire('chartclosed', {
target: this,
stateName: 'chartclosed',
params: {
seriesGroupList: this.seriesGroupList
}
});
},
/**
* Handler for 'revisionrange' event, fired when revision range
* changes.
*
* This event is fired by the chart-revision-range element. When this
* event is fired, it indicates that a new start and end revision have
* been selected.
*
* @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 'start_rev' and
* 'end_rev'.
*/
onRevisionRange: function(event, detail) {
var newGraphParams = deepCopy(this.graphParams);
newGraphParams['rev'] = null;
newGraphParams['num_points'] = null;
newGraphParams['start_rev'] = detail.start_rev;
newGraphParams['end_rev'] = detail.end_rev;
this.graphParams = newGraphParams;
this.reloadChart();
this.fireChartStateChangedEvent(null);
},
/**
* 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;
var seriesIndices = Object.keys(series);
if (seriesIndices.length == 0) {
return;
}
// For now get the first series sorted by index.
seriesIndices.sort(function(a, b) {
return a - b;
});
var seriesData = series[seriesIndices[0]];
if (seriesData.units) {
this.chartYAxisLabel = seriesData.units;
// Some unit names have improvement direction information in them.
if (seriesData.units.indexOf('is better') == -1 &&
seriesData.better) {
this.chartYAxisLabel += ' (' + seriesData.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 = {};
for (var seriesIndex in this.json.data) {
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 zeros. 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;
},
/**
* This method is called when the property 'indicesToGraph' changes.
* @param {?Array.<number>} oldValue Old value of indicesToGraph.
*/
indicesToGraphChanged: function() {
this.updateChart();
this.$.title.update();
},
/**
* This method is called when the property 'warning' changes.
* If there's a warning, we want to update the background color of the
* chart.
*/
warningsChanged: function() {
var backgroundColor = ((this.warnings.length) ? '#e6e6e6' : null);
this.chartOptions.grid.backgroundColor = backgroundColor;
if (this.chart) {
this.chart.getOptions().grid.backgroundColor = 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) {
data = [[]];
this.chart = $.plot(this.$.plot, data, this.chartOptions);
this.clearAlertIcons();
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.warnings.push('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();
this.updateWarningsForSelectedSeries();
},
/**
* 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.jsonHasData(this.json)) {
console.debug('getDataForFlot: this.json not set.');
return [];
}
if (!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 Flot graph 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 ||
this.indicesToGraph.length == 0) {
console.debug('createFixedXAxis: !this.json, !this.json.data or ' +
'this.indicesToGraph.length == 0');
return;
}
// Only create axis for selected series.
for (var i = 0; i < this.indicesToGraph.length; i++) {
var index = this.indicesToGraph[i];
var series = this.json.data[index];
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} A string.
*/
formatYAxis: function(val, axis) {
return formatNumber(val);
},
/**
* Adds all of the alert icons to the chart and sets their positions.
*/
showAlerts: function() {
this.clearAlertIcons();
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 flotIndex = 0; flotIndex < flotData.length; flotIndex++) {
var jsonSeriesIndex = flotData[flotIndex].index;
if (jsonSeriesIndex == null || !annotations[jsonSeriesIndex]) {
continue;
}
var dataLength = flotData[flotIndex].data.length;
for (var dataIndex = 0; dataIndex < dataLength; dataIndex++) {
var a = annotations[jsonSeriesIndex][dataIndex];
if (!annotations[jsonSeriesIndex][dataIndex] ||
!annotations[jsonSeriesIndex][dataIndex]['g_anomaly'] ||
flotData[flotIndex].data[dataIndex][1] < minHeight ||
flotData[flotIndex].data[dataIndex][1] > maxHeight) {
continue;
}
var flotPoint = flotData[flotIndex].data[dataIndex];
var left = xOffset + Math.round(
flotData[flotIndex].xaxis.p2c(flotPoint[0]));
var top = Math.round(
flotData[flotIndex].yaxis.p2c(flotPoint[1]));
var alertIcon = document.createElement('alert-icon');
alertIcon.initialize(
annotations[jsonSeriesIndex][dataIndex]['g_anomaly'],
this.alertKey);
Polymer.dom(
this.$['alert-icon-container']).appendChild(alertIcon);
alertIcon.setPosition(top, left);
alertIcon.onmouseover = this.onAlertMouseOver.bind(
this, flotIndex, dataIndex);
alertIcon.onclick = this.onAlertClick.bind(
this, flotIndex, dataIndex);
}
}
},
clearAlertIcons: function() {
Polymer.dom(this.$['alert-icon-container']).innerHTML = '';
},
/**
* Handler for 'onSeriesGroupClosed' event.
*/
onSeriesGroupClosed: function(event) {
var groupIndex = event.detail.groupIndex;
var seriesGroup = this.splice('seriesGroupList', groupIndex, 1)[0];
var tests = seriesGroup.tests;
for (var i = 0; i < tests.length; i++) {
var indexToRemove = tests[i].index;
delete this.json.data[indexToRemove];
delete this.json.annotations.series[indexToRemove];
delete this.json.annotations[indexToRemove];
delete this.json['error_bars'][indexToRemove];
if (this.indicesToGraph.indexOf(indexToRemove) != -1) {
this.splice('indicesToGraph',
this.indicesToGraph.indexOf(indexToRemove), 1);
}
}
this.updateSlider();
this.updateYAxisLabel();
this.updateSmartAutoscaleMap();
this.updateChart();
this.fireChartStateChangedEvent(this.seriesGroupList);
},
/**
* Handler for 'titleclicked' event fired from chart-title.
* This function clears out current chart data and re-adds a
* series group from the test path created from clicked title
* parts.
*/
onTitleClicked: function(event, detail) {
var titleParts = event.detail.titleParts;
var partIndex = event.detail.partIndex;
var testPath = titleParts.slice(0, partIndex + 1).join('/');
this.json = deepCopy(this.DEFAULT_JSON_PROPERTIES);
this.set('seriesGroupList', []);
this.set('indicesToGraph', []);
this.addSeriesGroup([[testPath, []]], true);
this.fireChartStateChangedEvent(this.seriesGroupList);
},
/**
* 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 Event object.
* @param {Object} ranges Object containing the selected range.
*/
onPlotSelected: function(event, ranges) {
this.selecting = false;
this.lastSelectedDelta = ranges;
},
/**
* Handler for 'plotselecting' event, fired repeatedly when selecting.
* @param {Event} event Event object.
* @param {Object} ranges An object containing the selected range.
*/
onPlotSelecting: function(event, ranges) {
if (!ranges) {
return;
}
if (!this.selecting) {
this.firstSelectedValue = ranges.yaxis.from;
this.selecting = true;
}
this.showDelta = true;
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.showDelta = false;
},
/**
* Handler for the 'plotclick' event, fired when the chart is clicked.
* @param {Event} event Event object.
* @param {Object} pos An object which contains the keys "x" and "y".
* @param {Object} item An object of the item clicked, null otherwise.
*/
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 && item.dataIndex != this.currentItem) {
// Clicked on a new data point.
// Show revision history and sticky tooltip.
this.stickyTooltip = true;
this.currentItem = item.dataIndex;
this.showTooltip(item.seriesIndex, item.dataIndex);
} else {
// Hide the tooltip if the user clicked the same point again
// or 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);
},
/**
* Brings up the tooltip when an alert circle is moused over.
* @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.
*/
onAlertMouseOver: function(flotSeriesIndex, dataIndex, event) {
if (!this.stickyTooltip)
this.showTooltip(flotSeriesIndex, dataIndex);
},
/**
* 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.
var annotation = this.json.annotations[jsonSeriesIndex][dataIndex];
annotation['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 Event object.
* @param {Object} pos Position object.
* @param {Object} item Item being hovered over.
*/
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 data point,
// show the tooltip.
this.showTooltip(item.seriesIndex, item.dataIndex);
},
/**
* Displays a tooltip for the given point on the graph.
*
* TODO(qyearsley): Refactor. See catapult:#1348.
*
* 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 A boolean.
*/
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, series.path,
pointId);
if (annotation.timestamp) {
var d = new Date(annotation.timestamp);
this.$.tooltip.timestamp = d.toISOString();
}
// Set the alert-related properties of the tooltip.
if (opt_alerts === undefined)
opt_alerts = null;
this.$.tooltip.alerts = opt_alerts;
if (opt_triaged === undefined)
opt_triaged = null;
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'];
}
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 = {
canBisect: series.can_bisect,
badRev: this.getRevisionForBisect(jsonSeriesIndex, dataIndex),
goodRev: this.getRevisionForBisect(jsonSeriesIndex, dataIndex - 1),
testPath: series.path,
traceRerunInfo: traceRerunInfo
};
// Un-hide and position the tooltip box.
var top = flotData[flotSeriesIndex].yaxis.p2c(yValue);
var left = flotData[flotSeriesIndex].xaxis.p2c(xValue) +
this.chartOptions.yaxis.labelWidth;
this.$.tooltip.openAtPosition(top, left);
},
/**
* 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;
},
/**
* Gets a revision value to use when populating the bisect form.
*
* @param {number} jsonSeriesIndex An index in this.json.data.
* @param {number} dataIndex The index of the point in the series.
* @return {?(number|string)} A revision to put in the bisect form.
*/
getRevisionForBisect: function(jsonSeriesIndex, dataIndex) {
// If the data point contains a default revision type, use that.
var annotation = this.json.annotations[jsonSeriesIndex][dataIndex];
if (!annotation) {
return null;
}
var defaultRevType = annotation['a_default_rev'];
if (defaultRevType && annotation[defaultRevType]) {
return annotation[defaultRevType];
}
// Otherwise, use the x-value of the point (aka the point ID).
var point = this.json.data[jsonSeriesIndex].data[dataIndex];
if (point && point[0]) {
return point[0];
}
return null;
},
/**
* Hides the tooltip only if it's not sticky.
*/
hideTooltip: function() {
if (this.stickyTooltip) {
return;
}
this.$.tooltip.close();
},
/**
* 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 {string} testPath Test path.
* @param {number} pointId Main revision at the given point.
* @return {Array.<Object>} List of objects with properties text and
* url.
*/
getTooltipLinks: function(annotation, testPath, pointId) {
var links = [];
for (var key in annotation) {
if (key == 'g_anomaly') {
var debugUrl = '/debug_alert?test_path=' + testPath +
'&rev=' + pointId;
links.push({
'text': 'Debug alert',
'url': debugUrl
});
} else if (key == 'a_tracing_uri') {
links.push({
'text': 'Trace',
'url': annotation['a_tracing_uri']
});
} else if (key.indexOf('a_') === 0) {
var match = TOOLTIP_LINK_REGEX.exec(annotation[key]);
if (match) {
links.push({
'text': match[1],
'url': match[2]
});
}
}
}
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.foo.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.
*
* @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 Chrome 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 resize event.
* @param {Event} event Event object.
*/
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 || this.$.plot.offsetWidth == 0) {
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();
},
seriesMouseover: function(event) {
var index = event.detail.index;
if (this.indicesToGraph.indexOf(index) != -1) {
this.highlightSeries([index]);
}
},
seriesMouseout: function(event) {
this.undoSeriesHighlighting();
},
/**
* Highlights each series in |targetIndices|.
* Highlighting makes a series line width and shadow thicker and lowers
* the opacity of unhighlighted series.
*
* @param {Array.<number>} targetIndices List of series indices to
* highlight.
*/
highlightSeries: function(targetIndices) {
var flotData = this.chart.getData();
if (!flotData) {
return;
}
for (var i = 0; i < flotData.length; i++) {
var series = flotData[i];
if (targetIndices.indexOf(this.getSeriesFillIndex(series)) != -1) {
continue;
}
if (targetIndices.indexOf(series.index) != -1) {
this.updateSeriesHighlighting(series, 2.5, 1, 0.2, 4);
} else {
this.updateSeriesHighlighting(series, 2, 0.25, 0, 0);
}
}
this.chart.setData(flotData);
this.chart.draw();
},
undoSeriesHighlighting: function() {
if (!this.chart) {
return;
}
var flotData = this.chart.getData();
if (!flotData) {
return;
}
for (var i = 0; i < flotData.length; i++) {
this.updateSeriesHighlighting(
flotData[i],
this.DEFAULT_SERIES_PROPERTIES.lineWidth,
this.DEFAULT_SERIES_PROPERTIES.opacity,
this.DEFAULT_SERIES_PROPERTIES.fill,
this.DEFAULT_SERIES_PROPERTIES.shadowSize);
}
this.chart.setData(flotData);
this.chart.draw();
},
/**
* Gets series's target index if series is a filled line.
* Filled lines are series that shade the upper and lower bounds
* (e.g. error bar regions).
*
* @param {Object} series The data series
* @return {?number} The index of the series that filled line
* correspond to, null if series is not a filled line.
*/
getSeriesFillIndex: function(series) {
if (!series.fillBetween) {
return null;
}
// fillBetween is a string 'line_X' (labeled by graph_json.py),
// where X is a series index that filled line correspond to.
var index = series.fillBetween.replace(/[^0-9]/g, '');
return parseInt(index);
},
updateSeriesHighlighting: function(series, lineWidth, opacity,
fill, shadowSize) {
if (series.fillBetween) {
series.lines.fill = fill;
} else {
series.shadowSize = shadowSize;
series.lines.lineWidth = lineWidth;
}
var rgb = series.color.split(/[\(\)]/g)[1];
rgb = rgb.split(',');
rgb[3] = opacity;
series.color = 'rgba(' + rgb.join(',') + ')';
},
/**
* Allows onDrop to be triggered.
*/
allowDrop: function(event) {
if (this.droppable) {
event.preventDefault();
}
},
/**
* Handler for on-drop event, fired when a series group is dropped
* onto this chart-container.
*/
onDrop: function(event) {
event.preventDefault();
var dataTransfer = event.dataTransfer;
if (dataTransfer.getData('type') != 'seriesdnd') {
return;
}
var data = JSON.parse(dataTransfer.getData('data'));
if (data) {
this.addSeriesGroup(data, true);
}
this.fireChartStateChangedEvent(this.seriesGroupList);
},
legendSeriesDragStart: function(event) {
this.droppable = false;
},
legendSeriesDragEnd: function(event) {
this.droppable = true;
},
/**
* Gets series's target index if series is a filled line.
* Filled lines are series that shade the upper and lower bounds
* (e.g. error bar regions).
*
* @param {?Object} state Chart state to send to URI controller which
* is used to generate state ID. If none is specified, only
* 'this.graphParams' is sent.
*/
fireChartStateChangedEvent: function(state) {
this.fire('chartstatechanged', {
target: this,
stateName: 'chartstatechanged',
params: this.graphParams,
state: state
});
},
onUriChanged: function(event) {
var detail = event.detail;
if (detail.stateName != 'chartstatechanged') {
return;
}
var isSameTarget = (detail.id == this.getAttribute('uniqueid') ||
detail.id == this.$.legend.getAttribute('uniqueid'));
var shouldReload = false;
if (isSameTarget) {
this.set('seriesGroupList', detail.state);
shouldReload = true;
}
if (!graphParamEquals(this.graphParams, detail.params)) {
this.graphParams = detail.params;
shouldReload = true;
}
if (shouldReload) {
this.reloadChart();
}
},
/**
* Gets the current state of the chart.
* This is called by 'report-page.html' to create page state.
*
* @return {Array} List of pair of test path and selected series.
*/
getState: function() {
var state = [];
this.seriesGroupList.forEach(function(seriesGroup) {
var selected = [];
var unselected = [];
var tests = seriesGroup.tests;
for (var i = 0; i < tests.length; i++) {
if (tests[i].selected) {
selected.push(tests[i].name);
} else {
unselected.push(tests[i].name);
}
}
if (selected.length == 0 && unselected.length == 0) {
selected = ['none'];
} else if (seriesGroup.selection) {
selected = [seriesGroup.selection];
}
selected = arrayUnique(selected);
state.push([seriesGroup.path, selected]);
});
return state;
},
listeners: {
drop: 'onDrop',
dragover: 'allowDrop'
}
});
})();
</script>
</dom-module>