Re-subimission of https://codereview.chromium.org/1041213003/
[chromium-blink-merge.git] / content / browser / resources / media / timeline_graph_view.js
blobd2b12329da240d872ec3f8370792f3bd060b2b58
1 // Copyright (c) 2013 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 /**
6  * A TimelineGraphView displays a timeline graph on a canvas element.
7  */
8 var TimelineGraphView = (function() {
9   'use strict';
11   // Maximum number of labels placed vertically along the sides of the graph.
12   var MAX_VERTICAL_LABELS = 6;
14   // Vertical spacing between labels and between the graph and labels.
15   var LABEL_VERTICAL_SPACING = 4;
16   // Horizontal spacing between vertically placed labels and the edges of the
17   // graph.
18   var LABEL_HORIZONTAL_SPACING = 3;
19   // Horizintal spacing between two horitonally placed labels along the bottom
20   // of the graph.
21   var LABEL_LABEL_HORIZONTAL_SPACING = 25;
23   // Length of ticks, in pixels, next to y-axis labels.  The x-axis only has
24   // one set of labels, so it can use lines instead.
25   var Y_AXIS_TICK_LENGTH = 10;
27   var GRID_COLOR = '#CCC';
28   var TEXT_COLOR = '#000';
29   var BACKGROUND_COLOR = '#FFF';
31   var MAX_DECIMAL_PRECISION = 2;
32   /**
33    * @constructor
34    */
35   function TimelineGraphView(divId, canvasId) {
36     this.scrollbar_ = {position_: 0, range_: 0};
38     this.graphDiv_ = $(divId);
39     this.canvas_ = $(canvasId);
41     // Set the range and scale of the graph.  Times are in milliseconds since
42     // the Unix epoch.
44     // All measurements we have must be after this time.
45     this.startTime_ = 0;
46     // The current rightmost position of the graph is always at most this.
47     this.endTime_ = 1;
49     this.graph_ = null;
51     // Horizontal scale factor, in terms of milliseconds per pixel.
52     this.scale_ = 1000;
54     // Initialize the scrollbar.
55     this.updateScrollbarRange_(true);
56   }
58   TimelineGraphView.prototype = {
59     setScale: function(scale) {
60       this.scale_ = scale;
61     },
63     // Returns the total length of the graph, in pixels.
64     getLength_: function() {
65       var timeRange = this.endTime_ - this.startTime_;
66       // Math.floor is used to ignore the last partial area, of length less
67       // than this.scale_.
68       return Math.floor(timeRange / this.scale_);
69     },
71     /**
72      * Returns true if the graph is scrolled all the way to the right.
73      */
74     graphScrolledToRightEdge_: function() {
75       return this.scrollbar_.position_ == this.scrollbar_.range_;
76     },
78     /**
79      * Update the range of the scrollbar.  If |resetPosition| is true, also
80      * sets the slider to point at the rightmost position and triggers a
81      * repaint.
82      */
83     updateScrollbarRange_: function(resetPosition) {
84       var scrollbarRange = this.getLength_() - this.canvas_.width;
85       if (scrollbarRange < 0)
86         scrollbarRange = 0;
88       // If we've decreased the range to less than the current scroll position,
89       // we need to move the scroll position.
90       if (this.scrollbar_.position_ > scrollbarRange)
91         resetPosition = true;
93       this.scrollbar_.range_ = scrollbarRange;
94       if (resetPosition) {
95         this.scrollbar_.position_ = scrollbarRange;
96         this.repaint();
97       }
98     },
100     /**
101      * Sets the date range displayed on the graph, switches to the default
102      * scale factor, and moves the scrollbar all the way to the right.
103      */
104     setDateRange: function(startDate, endDate) {
105       this.startTime_ = startDate.getTime();
106       this.endTime_ = endDate.getTime();
108       // Safety check.
109       if (this.endTime_ <= this.startTime_)
110         this.startTime_ = this.endTime_ - 1;
112       this.updateScrollbarRange_(true);
113     },
115     /**
116      * Updates the end time at the right of the graph to be the current time.
117      * Specifically, updates the scrollbar's range, and if the scrollbar is
118      * all the way to the right, keeps it all the way to the right.  Otherwise,
119      * leaves the view as-is and doesn't redraw anything.
120      */
121     updateEndDate: function(opt_date) {
122       this.endTime_ = opt_date || (new Date()).getTime();
123       this.updateScrollbarRange_(this.graphScrolledToRightEdge_());
124     },
126     getStartDate: function() {
127       return new Date(this.startTime_);
128     },
130     /**
131      * Replaces the current TimelineDataSeries with |dataSeries|.
132      */
133     setDataSeries: function(dataSeries) {
134       // Simply recreates the Graph.
135       this.graph_ = new Graph();
136       for (var i = 0; i < dataSeries.length; ++i)
137         this.graph_.addDataSeries(dataSeries[i]);
138       this.repaint();
139     },
141     /**
142     * Adds |dataSeries| to the current graph.
143     */
144     addDataSeries: function(dataSeries) {
145       if (!this.graph_)
146         this.graph_ = new Graph();
147       this.graph_.addDataSeries(dataSeries);
148       this.repaint();
149     },
151     /**
152      * Draws the graph on |canvas_|.
153      */
154     repaint: function() {
155       this.repaintTimerRunning_ = false;
157       var width = this.canvas_.width;
158       var height = this.canvas_.height;
159       var context = this.canvas_.getContext('2d');
161       // Clear the canvas.
162       context.fillStyle = BACKGROUND_COLOR;
163       context.fillRect(0, 0, width, height);
165       // Try to get font height in pixels.  Needed for layout.
166       var fontHeightString = context.font.match(/([0-9]+)px/)[1];
167       var fontHeight = parseInt(fontHeightString);
169       // Safety check, to avoid drawing anything too ugly.
170       if (fontHeightString.length == 0 || fontHeight <= 0 ||
171           fontHeight * 4 > height || width < 50) {
172         return;
173       }
175       // Save current transformation matrix so we can restore it later.
176       context.save();
178       // The center of an HTML canvas pixel is technically at (0.5, 0.5).  This
179       // makes near straight lines look bad, due to anti-aliasing.  This
180       // translation reduces the problem a little.
181       context.translate(0.5, 0.5);
183       // Figure out what time values to display.
184       var position = this.scrollbar_.position_;
185       // If the entire time range is being displayed, align the right edge of
186       // the graph to the end of the time range.
187       if (this.scrollbar_.range_ == 0)
188         position = this.getLength_() - this.canvas_.width;
189       var visibleStartTime = this.startTime_ + position * this.scale_;
191       // Make space at the bottom of the graph for the time labels, and then
192       // draw the labels.
193       var textHeight = height;
194       height -= fontHeight + LABEL_VERTICAL_SPACING;
195       this.drawTimeLabels(context, width, height, textHeight, visibleStartTime);
197       // Draw outline of the main graph area.
198       context.strokeStyle = GRID_COLOR;
199       context.strokeRect(0, 0, width - 1, height - 1);
201       if (this.graph_) {
202         // Layout graph and have them draw their tick marks.
203         this.graph_.layout(
204             width, height, fontHeight, visibleStartTime, this.scale_);
205         this.graph_.drawTicks(context);
207         // Draw the lines of all graphs, and then draw their labels.
208         this.graph_.drawLines(context);
209         this.graph_.drawLabels(context);
210       }
212       // Restore original transformation matrix.
213       context.restore();
214     },
216     /**
217      * Draw time labels below the graph.  Takes in start time as an argument
218      * since it may not be |startTime_|, when we're displaying the entire
219      * time range.
220      */
221     drawTimeLabels: function(context, width, height, textHeight, startTime) {
222       // Draw the labels 1 minute apart.
223       var timeStep = 1000 * 60;
225       // Find the time for the first label.  This time is a perfect multiple of
226       // timeStep because of how UTC times work.
227       var time = Math.ceil(startTime / timeStep) * timeStep;
229       context.textBaseline = 'bottom';
230       context.textAlign = 'center';
231       context.fillStyle = TEXT_COLOR;
232       context.strokeStyle = GRID_COLOR;
234       // Draw labels and vertical grid lines.
235       while (true) {
236         var x = Math.round((time - startTime) / this.scale_);
237         if (x >= width)
238           break;
239         var text = (new Date(time)).toLocaleTimeString();
240         context.fillText(text, x, textHeight);
241         context.beginPath();
242         context.lineTo(x, 0);
243         context.lineTo(x, height);
244         context.stroke();
245         time += timeStep;
246       }
247     },
249     getDataSeriesCount: function() {
250       if (this.graph_)
251         return this.graph_.dataSeries_.length;
252       return 0;
253     },
255     hasDataSeries: function(dataSeries) {
256       if (this.graph_)
257         return this.graph_.hasDataSeries(dataSeries);
258       return false;
259     },
261   };
263   /**
264    * A Graph is responsible for drawing all the TimelineDataSeries that have
265    * the same data type.  Graphs are responsible for scaling the values, laying
266    * out labels, and drawing both labels and lines for its data series.
267    */
268   var Graph = (function() {
269     /**
270      * @constructor
271      */
272     function Graph() {
273       this.dataSeries_ = [];
275       // Cached properties of the graph, set in layout.
276       this.width_ = 0;
277       this.height_ = 0;
278       this.fontHeight_ = 0;
279       this.startTime_ = 0;
280       this.scale_ = 0;
282       // The lowest/highest values adjusted by the vertical label step size
283       // in the displayed range of the graph. Used for scaling and setting
284       // labels.  Set in layoutLabels.
285       this.min_ = 0;
286       this.max_ = 0;
288       // Cached text of equally spaced labels.  Set in layoutLabels.
289       this.labels_ = [];
290     }
292     /**
293      * A Label is the label at a particular position along the y-axis.
294      * @constructor
295      */
296     function Label(height, text) {
297       this.height = height;
298       this.text = text;
299     }
301     Graph.prototype = {
302       addDataSeries: function(dataSeries) {
303         this.dataSeries_.push(dataSeries);
304       },
306       hasDataSeries: function(dataSeries) {
307         for (var i = 0; i < this.dataSeries_.length; ++i) {
308           if (this.dataSeries_[i] == dataSeries)
309             return true;
310         }
311         return false;
312       },
314       /**
315        * Returns a list of all the values that should be displayed for a given
316        * data series, using the current graph layout.
317        */
318       getValues: function(dataSeries) {
319         if (!dataSeries.isVisible())
320           return null;
321         return dataSeries.getValues(this.startTime_, this.scale_, this.width_);
322       },
324       /**
325        * Updates the graph's layout.  In particular, both the max value and
326        * label positions are updated.  Must be called before calling any of the
327        * drawing functions.
328        */
329       layout: function(width, height, fontHeight, startTime, scale) {
330         this.width_ = width;
331         this.height_ = height;
332         this.fontHeight_ = fontHeight;
333         this.startTime_ = startTime;
334         this.scale_ = scale;
336         // Find largest value.
337         var max = 0, min = 0;
338         for (var i = 0; i < this.dataSeries_.length; ++i) {
339           var values = this.getValues(this.dataSeries_[i]);
340           if (!values)
341             continue;
342           for (var j = 0; j < values.length; ++j) {
343             if (values[j] > max)
344               max = values[j];
345             else if (values[j] < min)
346               min = values[j];
347           }
348         }
350         this.layoutLabels_(min, max);
351       },
353       /**
354        * Lays out labels and sets |max_|/|min_|, taking the time units into
355        * consideration.  |maxValue| is the actual maximum value, and
356        * |max_| will be set to the value of the largest label, which
357        * will be at least |maxValue|. Similar for |min_|.
358        */
359       layoutLabels_: function(minValue, maxValue) {
360         if (maxValue - minValue < 1024) {
361           this.layoutLabelsBasic_(minValue, maxValue, MAX_DECIMAL_PRECISION);
362           return;
363         }
365         // Find appropriate units to use.
366         var units = ['', 'k', 'M', 'G', 'T', 'P'];
367         // Units to use for labels.  0 is '1', 1 is K, etc.
368         // We start with 1, and work our way up.
369         var unit = 1;
370         minValue /= 1024;
371         maxValue /= 1024;
372         while (units[unit + 1] && maxValue - minValue >= 1024) {
373           minValue /= 1024;
374           maxValue /= 1024;
375           ++unit;
376         }
378         // Calculate labels.
379         this.layoutLabelsBasic_(minValue, maxValue, MAX_DECIMAL_PRECISION);
381         // Append units to labels.
382         for (var i = 0; i < this.labels_.length; ++i)
383           this.labels_[i] += ' ' + units[unit];
385         // Convert |min_|/|max_| back to unit '1'.
386         this.min_ *= Math.pow(1024, unit);
387         this.max_ *= Math.pow(1024, unit);
388       },
390       /**
391        * Same as layoutLabels_, but ignores units.  |maxDecimalDigits| is the
392        * maximum number of decimal digits allowed.  The minimum allowed
393        * difference between two adjacent labels is 10^-|maxDecimalDigits|.
394        */
395       layoutLabelsBasic_: function(minValue, maxValue, maxDecimalDigits) {
396         this.labels_ = [];
397         var range = maxValue - minValue;
398         // No labels if the range is 0.
399         if (range == 0) {
400           this.min_ = this.max_ = maxValue;
401           return;
402         }
404         // The maximum number of equally spaced labels allowed.  |fontHeight_|
405         // is doubled because the top two labels are both drawn in the same
406         // gap.
407         var minLabelSpacing = 2 * this.fontHeight_ + LABEL_VERTICAL_SPACING;
409         // The + 1 is for the top label.
410         var maxLabels = 1 + this.height_ / minLabelSpacing;
411         if (maxLabels < 2) {
412           maxLabels = 2;
413         } else if (maxLabels > MAX_VERTICAL_LABELS) {
414           maxLabels = MAX_VERTICAL_LABELS;
415         }
417         // Initial try for step size between conecutive labels.
418         var stepSize = Math.pow(10, -maxDecimalDigits);
419         // Number of digits to the right of the decimal of |stepSize|.
420         // Used for formating label strings.
421         var stepSizeDecimalDigits = maxDecimalDigits;
423         // Pick a reasonable step size.
424         while (true) {
425           // If we use a step size of |stepSize| between labels, we'll need:
426           //
427           // Math.ceil(range / stepSize) + 1
428           //
429           // labels.  The + 1 is because we need labels at both at 0 and at
430           // the top of the graph.
432           // Check if we can use steps of size |stepSize|.
433           if (Math.ceil(range / stepSize) + 1 <= maxLabels)
434             break;
435           // Check |stepSize| * 2.
436           if (Math.ceil(range / (stepSize * 2)) + 1 <= maxLabels) {
437             stepSize *= 2;
438             break;
439           }
440           // Check |stepSize| * 5.
441           if (Math.ceil(range / (stepSize * 5)) + 1 <= maxLabels) {
442             stepSize *= 5;
443             break;
444           }
445           stepSize *= 10;
446           if (stepSizeDecimalDigits > 0)
447             --stepSizeDecimalDigits;
448         }
450         // Set the min/max so it's an exact multiple of the chosen step size.
451         this.max_ = Math.ceil(maxValue / stepSize) * stepSize;
452         this.min_ = Math.floor(minValue / stepSize) * stepSize;
454         // Create labels.
455         for (var label = this.max_; label >= this.min_; label -= stepSize)
456           this.labels_.push(label.toFixed(stepSizeDecimalDigits));
457       },
459       /**
460        * Draws tick marks for each of the labels in |labels_|.
461        */
462       drawTicks: function(context) {
463         var x1;
464         var x2;
465         x1 = this.width_ - 1;
466         x2 = this.width_ - 1 - Y_AXIS_TICK_LENGTH;
468         context.fillStyle = GRID_COLOR;
469         context.beginPath();
470         for (var i = 1; i < this.labels_.length - 1; ++i) {
471           // The rounding is needed to avoid ugly 2-pixel wide anti-aliased
472           // lines.
473           var y = Math.round(this.height_ * i / (this.labels_.length - 1));
474           context.moveTo(x1, y);
475           context.lineTo(x2, y);
476         }
477         context.stroke();
478       },
480       /**
481        * Draws a graph line for each of the data series.
482        */
483       drawLines: function(context) {
484         // Factor by which to scale all values to convert them to a number from
485         // 0 to height - 1.
486         var scale = 0;
487         var bottom = this.height_ - 1;
488         if (this.max_)
489           scale = bottom / (this.max_ - this.min_);
491         // Draw in reverse order, so earlier data series are drawn on top of
492         // subsequent ones.
493         for (var i = this.dataSeries_.length - 1; i >= 0; --i) {
494           var values = this.getValues(this.dataSeries_[i]);
495           if (!values)
496             continue;
497           context.strokeStyle = this.dataSeries_[i].getColor();
498           context.beginPath();
499           for (var x = 0; x < values.length; ++x) {
500             // The rounding is needed to avoid ugly 2-pixel wide anti-aliased
501             // horizontal lines.
502             context.lineTo(
503                 x, bottom - Math.round((values[x] - this.min_) * scale));
504           }
505           context.stroke();
506         }
507       },
509       /**
510        * Draw labels in |labels_|.
511        */
512       drawLabels: function(context) {
513         if (this.labels_.length == 0)
514           return;
515         var x = this.width_ - LABEL_HORIZONTAL_SPACING;
517         // Set up the context.
518         context.fillStyle = TEXT_COLOR;
519         context.textAlign = 'right';
521         // Draw top label, which is the only one that appears below its tick
522         // mark.
523         context.textBaseline = 'top';
524         context.fillText(this.labels_[0], x, 0);
526         // Draw all the other labels.
527         context.textBaseline = 'bottom';
528         var step = (this.height_ - 1) / (this.labels_.length - 1);
529         for (var i = 1; i < this.labels_.length; ++i)
530           context.fillText(this.labels_[i], x, step * i);
531       }
532     };
534     return Graph;
535   })();
537   return TimelineGraphView;
538 })();