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.
6 * A TimelineGraphView displays a timeline graph on a canvas element.
8 var TimelineGraphView = (function() {
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
18 var LABEL_HORIZONTAL_SPACING = 3;
19 // Horizintal spacing between two horitonally placed labels along the bottom
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;
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
44 // All measurements we have must be after this time.
46 // The current rightmost position of the graph is always at most this.
51 // Horizontal scale factor, in terms of milliseconds per pixel.
54 // Initialize the scrollbar.
55 this.updateScrollbarRange_(true);
58 TimelineGraphView.prototype = {
59 setScale: function(scale) {
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
68 return Math.floor(timeRange / this.scale_);
72 * Returns true if the graph is scrolled all the way to the right.
74 graphScrolledToRightEdge_: function() {
75 return this.scrollbar_.position_ == this.scrollbar_.range_;
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
83 updateScrollbarRange_: function(resetPosition) {
84 var scrollbarRange = this.getLength_() - this.canvas_.width;
85 if (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)
93 this.scrollbar_.range_ = scrollbarRange;
95 this.scrollbar_.position_ = scrollbarRange;
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.
104 setDateRange: function(startDate, endDate) {
105 this.startTime_ = startDate.getTime();
106 this.endTime_ = endDate.getTime();
109 if (this.endTime_ <= this.startTime_)
110 this.startTime_ = this.endTime_ - 1;
112 this.updateScrollbarRange_(true);
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.
121 updateEndDate: function(opt_date) {
122 this.endTime_ = opt_date || (new Date()).getTime();
123 this.updateScrollbarRange_(this.graphScrolledToRightEdge_());
126 getStartDate: function() {
127 return new Date(this.startTime_);
131 * Replaces the current TimelineDataSeries with |dataSeries|.
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]);
142 * Adds |dataSeries| to the current graph.
144 addDataSeries: function(dataSeries) {
146 this.graph_ = new Graph();
147 this.graph_.addDataSeries(dataSeries);
152 * Draws the graph on |canvas_|.
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');
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) {
175 // Save current transformation matrix so we can restore it later.
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
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);
202 // Layout graph and have them draw their tick marks.
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);
212 // Restore original transformation matrix.
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
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.
236 var x = Math.round((time - startTime) / this.scale_);
239 var text = (new Date(time)).toLocaleTimeString();
240 context.fillText(text, x, textHeight);
242 context.lineTo(x, 0);
243 context.lineTo(x, height);
249 getDataSeriesCount: function() {
251 return this.graph_.dataSeries_.length;
255 hasDataSeries: function(dataSeries) {
257 return this.graph_.hasDataSeries(dataSeries);
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.
268 var Graph = (function() {
273 this.dataSeries_ = [];
275 // Cached properties of the graph, set in layout.
278 this.fontHeight_ = 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.
288 // Cached text of equally spaced labels. Set in layoutLabels.
293 * A Label is the label at a particular position along the y-axis.
296 function Label(height, text) {
297 this.height = height;
302 addDataSeries: function(dataSeries) {
303 this.dataSeries_.push(dataSeries);
306 hasDataSeries: function(dataSeries) {
307 for (var i = 0; i < this.dataSeries_.length; ++i) {
308 if (this.dataSeries_[i] == dataSeries)
315 * Returns a list of all the values that should be displayed for a given
316 * data series, using the current graph layout.
318 getValues: function(dataSeries) {
319 if (!dataSeries.isVisible())
321 return dataSeries.getValues(this.startTime_, this.scale_, this.width_);
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
329 layout: function(width, height, fontHeight, startTime, scale) {
331 this.height_ = height;
332 this.fontHeight_ = fontHeight;
333 this.startTime_ = startTime;
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]);
342 for (var j = 0; j < values.length; ++j) {
345 else if (values[j] < min)
350 this.layoutLabels_(min, max);
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_|.
359 layoutLabels_: function(minValue, maxValue) {
360 if (maxValue - minValue < 1024) {
361 this.layoutLabelsBasic_(minValue, maxValue, MAX_DECIMAL_PRECISION);
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.
372 while (units[unit + 1] && maxValue - minValue >= 1024) {
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);
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|.
395 layoutLabelsBasic_: function(minValue, maxValue, maxDecimalDigits) {
397 var range = maxValue - minValue;
398 // No labels if the range is 0.
400 this.min_ = this.max_ = maxValue;
404 // The maximum number of equally spaced labels allowed. |fontHeight_|
405 // is doubled because the top two labels are both drawn in the same
407 var minLabelSpacing = 2 * this.fontHeight_ + LABEL_VERTICAL_SPACING;
409 // The + 1 is for the top label.
410 var maxLabels = 1 + this.height_ / minLabelSpacing;
413 } else if (maxLabels > MAX_VERTICAL_LABELS) {
414 maxLabels = MAX_VERTICAL_LABELS;
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.
425 // If we use a step size of |stepSize| between labels, we'll need:
427 // Math.ceil(range / stepSize) + 1
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)
435 // Check |stepSize| * 2.
436 if (Math.ceil(range / (stepSize * 2)) + 1 <= maxLabels) {
440 // Check |stepSize| * 5.
441 if (Math.ceil(range / (stepSize * 5)) + 1 <= maxLabels) {
446 if (stepSizeDecimalDigits > 0)
447 --stepSizeDecimalDigits;
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;
455 for (var label = this.max_; label >= this.min_; label -= stepSize)
456 this.labels_.push(label.toFixed(stepSizeDecimalDigits));
460 * Draws tick marks for each of the labels in |labels_|.
462 drawTicks: function(context) {
465 x1 = this.width_ - 1;
466 x2 = this.width_ - 1 - Y_AXIS_TICK_LENGTH;
468 context.fillStyle = GRID_COLOR;
470 for (var i = 1; i < this.labels_.length - 1; ++i) {
471 // The rounding is needed to avoid ugly 2-pixel wide anti-aliased
473 var y = Math.round(this.height_ * i / (this.labels_.length - 1));
474 context.moveTo(x1, y);
475 context.lineTo(x2, y);
481 * Draws a graph line for each of the data series.
483 drawLines: function(context) {
484 // Factor by which to scale all values to convert them to a number from
487 var bottom = this.height_ - 1;
489 scale = bottom / (this.max_ - this.min_);
491 // Draw in reverse order, so earlier data series are drawn on top of
493 for (var i = this.dataSeries_.length - 1; i >= 0; --i) {
494 var values = this.getValues(this.dataSeries_[i]);
497 context.strokeStyle = this.dataSeries_[i].getColor();
499 for (var x = 0; x < values.length; ++x) {
500 // The rounding is needed to avoid ugly 2-pixel wide anti-aliased
503 x, bottom - Math.round((values[x] - this.min_) * scale));
510 * Draw labels in |labels_|.
512 drawLabels: function(context) {
513 if (this.labels_.length == 0)
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
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);
537 return TimelineGraphView;