1 <!DOCTYPE html> 2 <!-- 3 Copyright 2016 The Chromium Authors. All rights reserved. 4 Use of this source code is governed by a BSD-style license that can be 5 found in the LICENSE file. 6 --> 7 8 <polymer-element name="chart-slider" attributes="testpath startrev endrev"> 9 <template> 10 <style> 11 #revisions_container { 12 height: 60px; 13 width: 100%; 14 } 15 </style> 16 <div id="revisions_container"></div> 17 </template> 18 <script> 19 'use strict'; 20 Polymer('chart-slider', { 21 edgeDist: 2, 22 23 /** 24 * Initializes the element. This is a lifecycle callback method. 25 */ 26 ready: function() { 27 this.data = null; 28 this.dragType = 'none'; 29 this.drawable = true; 30 31 // Use addEventListener instead of polymer 'on-' attributes so that we 32 // can catch the mouse events before Flot does. 33 this.$.revisions_container.addEventListener( 34 'mousemove', this.onMouseMove.bind(this), true); 35 this.$.revisions_container.addEventListener( 36 'mousedown', this.onMouseDown.bind(this), true); 37 38 // The mouseup listener is placed on the document instead of the graph 39 // since the user could drag to outside the bounds of the graph. 40 document.addEventListener('mouseup', this.onMouseUp.bind(this), true); 41 42 this.chartOptions = { 43 series: { 44 lines: { 45 show: true, 46 fill: 0.2 47 } 48 }, 49 grid: { 50 backgroundColor: '#F1F1F1', 51 borderWidth: 1, 52 borderColor: 'rgba(0, 0, 0, 0.5)' 53 }, 54 crosshair: { 55 mode: 'x', 56 color: 'rgba(34, 34, 34, 0.3)', 57 lineWidth: 0.3 58 }, 59 selection: { 60 mode: 'x', 61 color: 'green' 62 }, 63 yaxis: { 64 show: false, 65 reserveSpace: true, 66 labelWidth: 60 67 }, 68 xaxis: { 69 show: true, 70 tickFormatter: this.tickFormatter.bind(this) 71 }, 72 colors: ['#4d90fe'] 73 }; 74 75 this.revisionToIndexMap = {}; 76 this.chart = null; 77 this.resizeHandler = this.onResize.bind(this); 78 this.resizeTimer = null; 79 window.addEventListener('resize', this.resizeHandler); 80 }, 81 82 /** 83 * Updates the element when it's removed. This is a lifecycle callback. 84 */ 85 leftView: function() { 86 this.drawable = false; 87 window.removeEventListener('resize', this.resizeHandler); 88 }, 89 90 /** 91 * Requests new data to update the graph when the test path is set. 92 */ 93 testpathChanged: function() { 94 var postdata = 'test_path=' + encodeURIComponent(this.testpath); 95 var request = new XMLHttpRequest(); 96 request.onload = this.onLoadGraph.bind(this, request); 97 request.open('post', '/graph_revisions', true); 98 request.setRequestHeader( 99 'Content-Type', 'application/x-www-form-urlencoded'); 100 request.send(postdata); 101 }, 102 103 /** 104 * Updates the chart when graph data is received. 105 * @param {XMLHttpRequest} request The request for data. 106 */ 107 onLoadGraph: function(request) { 108 if (!this.drawable) { 109 return; 110 } 111 this.data = JSON.parse(request.responseText); 112 this.selectionMax = this.data.length - 1; 113 this.revisionToIndexMap = {}; 114 var chartData = []; 115 for (var i = 0; i < this.data.length; i++) { 116 chartData.push([i, this.data[i][1]]); 117 var rev = this.data[i][0]; 118 this.revisionToIndexMap[rev] = i; 119 } 120 this.chart = $.plot( 121 this.$.revisions_container, [{data: chartData}], this.chartOptions); 122 this.updateSelection(); 123 }, 124 125 /** 126 * Updates the selection state when |this.startrev| is changed. 127 */ 128 startrevChanged: function() { 129 this.updateSelection(); 130 }, 131 132 /** 133 * Updates the selection state when |this.endrev| is changed. 134 */ 135 endrevChanged: function() { 136 this.updateSelection(); 137 }, 138 139 /** 140 * Updates the selection state when the startrev attribute is changed. 141 */ 142 updateSelection: function() { 143 if (!this.startrev || 144 !this.endrev || 145 !this.revisionToIndexMap || 146 !this.chart) { 147 return; 148 } 149 150 var startIndex = null; 151 var endIndex = null; 152 if (this.startrev in this.revisionToIndexMap) { 153 startIndex = this.revisionToIndexMap[this.startrev]; 154 } else { 155 startIndex = this.getPreviousIndexForRev(this.startrev); 156 } 157 158 if (this.endrev in this.revisionToIndexMap) { 159 endIndex = this.revisionToIndexMap[this.endrev]; 160 } else { 161 endIndex = this.getPreviousIndexForRev(this.endrev); 162 } 163 164 // If this ever happens, just expand the selector to a single bar. 165 if (startIndex == endIndex) { 166 if (endIndex == 0) { 167 endIndex = 1; 168 } else { 169 startIndex -= 1; 170 } 171 } 172 this.chart.setSelection({xaxis: {from: startIndex, to: endIndex}}, 173 true); 174 }, 175 176 /** 177 * Get the previous index for a revision number in data series. 178 * @param {number} revision An X-value. 179 * @return {number} An index number. 180 */ 181 getPreviousIndexForRev: function(revision) { 182 for (var i = this.data.length - 1; i >= 0; i--) { 183 if (revision > this.data[i][0]) { 184 return i; 185 } 186 } 187 return 0; 188 }, 189 190 /** 191 * Formats the labels on the X-axis. 192 * @param {string|number} xValue An X-value on the mini-plot. 193 * @param {Object=} opt_axis Not used. 194 * @return {string} A string to display at one point a long the X-axis. 195 */ 196 tickFormatter: function(xValue, opt_axis) { 197 xValue = Math.max(0, Math.round(xValue)); 198 xValue = Math.min(xValue, this.data.length - 1); 199 if (this.data[xValue] && this.data[xValue][2]) { 200 var d = new Date(this.data[xValue][2]); 201 return d.toISOString().substring(0, 10); // yyyy-mm-dd. 202 } 203 console.warn('No timestamp found in chart-slider data at', xValue); 204 return String(xValue); 205 }, 206 207 208 /** 209 * Determines what stage of a mouse drag selection action the user is in. 210 * @param {MouseEvent} event Mouse event object. 211 * @return {string} One of "start", "move", "end", or "none". 212 */ 213 getMouseDragType: function(event) { 214 if (!this.chart) { 215 return 'none'; 216 } 217 var pos = this.getGraphPosFromMouseEvent(event); 218 var selection = this.chart.getSelection(); 219 if (!pos || !selection) { 220 return 'none'; 221 } 222 if (pos.startDist && pos.startDist < this.edgeDist) { 223 return 'start'; 224 } 225 if (pos.endDist && pos.endDist < this.edgeDist) { 226 return 'end'; 227 } 228 if (pos.index > selection.xaxis.from && 229 pos.index < selection.xaxis.to) { 230 return 'move'; 231 } 232 return 'none'; 233 }, 234 235 /** 236 * Determines what the cursor type should be based on a drag type string. 237 * @param {string} dragType One of "start", "move", "end", or "none". 238 * @return {string} One of "move", "col-resize", or "auto". 239 */ 240 getCursorForDragType: function(dragType) { 241 switch (dragType) { 242 case 'move': 243 return 'move'; 244 case 'start': 245 case 'end': 246 return 'col-resize'; 247 default: 248 return 'auto'; 249 } 250 }, 251 252 /** 253 * Gets the position of the mouse selection relative to the chart. 254 * @param {MouseEvent} event Mouse event object. 255 */ 256 getGraphPosFromMouseEvent: function(event) { 257 var boundingRect = this.$.revisions_container.getBoundingClientRect(); 258 var plotOffset = this.chart.getPlotOffset(); 259 var posX = event.pageX - boundingRect.left - plotOffset.left; 260 posX = Math.max(0, posX); 261 posX = Math.min(posX, this.chart.width()); 262 var axes = this.chart.getAxes(); 263 var indexX = Math.round(axes.xaxis.c2p(posX)); 264 var revisionX = this.data[indexX][0]; 265 var pos = {index: indexX, revision: revisionX}; 266 var selection = this.chart.getSelection(); 267 if (selection) { 268 pos.startDist = Math.abs(axes.xaxis.p2c(selection.xaxis.from) - posX); 269 pos.endDist = Math.abs(axes.xaxis.p2c(selection.xaxis.to) - posX); 270 } 271 return pos; 272 }, 273 274 /** 275 * Updates the selected revision range as the user moves the mouse. 276 * @param {MouseEvent} event Mouse event object. 277 */ 278 onMouseMove: function(event) { 279 // Stop Flot from handling the selection. 280 event.stopPropagation(); 281 if (!this.data || this.data.length == 0) { 282 return; 283 } 284 if (this.dragType == 'none') { 285 var cursor = this.getCursorForDragType(this.getMouseDragType(event)); 286 this.$.revisions_container.style.cursor = cursor; 287 return; 288 } 289 290 var pos = this.getGraphPosFromMouseEvent(event); 291 var diff = this.selectionStart.index - pos.index; 292 var startIndex = Math.max(0, this.selectionStart.from - diff); 293 var endIndex = Math.min( 294 this.selectionStart.to - diff, this.selectionMax); 295 296 // Note: There used to be a constant that determined the max number of 297 // selectable points, and this function would return early here if the 298 // number selected exceeded that number; this could be re-added if we 299 // want to limit the number of selectable points. 300 301 if (this.dragType == 'move' || this.dragType == 'start') { 302 this.startrev = this.data[startIndex][0]; 303 } 304 if (this.dragType == 'move' || this.dragType == 'end') { 305 this.endrev = this.data[endIndex][0]; 306 } 307 }, 308 309 /** 310 * Sets the selection start when the user starts to drag. 311 * @param {MouseEvent} event Mouse event object. 312 */ 313 onMouseDown: function(event) { 314 // Stop Flot from handling the selection. 315 event.stopPropagation(); 316 317 this.dragType = this.getMouseDragType(event); 318 if (this.dragType == 'none') { 319 return; 320 } 321 322 var selection = this.chart.getSelection(); 323 var from = Math.max(0, Math.round(selection.xaxis.from)); 324 var to = Math.min(Math.round(selection.xaxis.to), this.data.length - 1); 325 var pos = this.getGraphPosFromMouseEvent(event); 326 this.selectionStart = { 327 index: pos.index, 328 from: from, 329 to: to 330 }; 331 document.body.style.cursor = this.getCursorForDragType(this.dragType); 332 333 // Stop text selection (screws up cursor). 334 event.preventDefault(); 335 }, 336 337 /** 338 * Fires a "revisionrange" event when the user is finished selecting. 339 * @param {MouseEvent} event A "mouseup" event. 340 */ 341 onMouseUp: function(event) { 342 if (this.dragType == 'none') { 343 return; 344 } 345 this.dragType = 'none'; 346 var selection = this.chart.getSelection().xaxis; 347 document.body.style.cursor = 'auto'; 348 this.$.revisions_container.style.cursor = 'auto'; 349 if (selection.from == this.selectionStart.from && 350 selection.to == this.selectionStart.to) { 351 return; 352 } 353 var detail = { 354 start_rev: this.startrev, 355 end_rev: this.endrev 356 }; 357 this.fire('revisionrange', detail); 358 }, 359 360 /** 361 * Sets a timer to resize after a certain amount of time. 362 */ 363 onResize: function(event) { 364 // Try not to resize graphs until the user has stopped resizing. 365 clearTimeout(this.resizeTimer); 366 this.resizeTimer = setTimeout(this.resizeGraph.bind(this), 100); 367 }, 368 369 /** 370 * Resizes the graph. 371 */ 372 resizeGraph: function() { 373 if (!this.chart) { 374 return; 375 } 376 this.chart.resize(); 377 this.chart.setupGrid(); 378 this.chart.draw(); 379 this.updateSelection(); 380 } 381 }); 382 </script> 383 </polymer-element> 384