| <!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> |