1// Copyright (c) 2012 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.
4
5'use strict';
6
7/**
8 * @fileoverview Interactive visualizaiton of TimelineModel objects
9 * based loosely on gantt charts. Each thread in the TimelineModel is given a
10 * set of TimelineTracks, one per subrow in the thread. The Timeline class
11 * acts as a controller, creating the individual tracks, while TimelineTracks
12 * do actual drawing.
13 *
14 * Visually, the Timeline produces (prettier) visualizations like the following:
15 *    Thread1:  AAAAAAAAAA         AAAAA
16 *                  BBBB              BB
17 *    Thread2:     CCCCCC                 CCCCC
18 *
19 */
20cr.define('tracing', function() {
21
22  /**
23   * The TimelineViewport manages the transform used for navigating
24   * within the timeline. It is a simple transform:
25   *   x' = (x+pan) * scale
26   *
27   * The timeline code tries to avoid directly accessing this transform,
28   * instead using this class to do conversion between world and view space,
29   * as well as the math for centering the viewport in various interesting
30   * ways.
31   *
32   * @constructor
33   * @extends {cr.EventTarget}
34   */
35  function TimelineViewport(parentEl) {
36    this.parentEl_ = parentEl;
37    this.scaleX_ = 1;
38    this.panX_ = 0;
39    this.gridTimebase_ = 0;
40    this.gridStep_ = 1000 / 60;
41    this.gridEnabled_ = false;
42    this.hasCalledSetupFunction_ = false;
43
44    this.onResizeBoundToThis_ = this.onResize_.bind(this);
45
46    // The following code uses an interval to detect when the parent element
47    // is attached to the document. That is a trigger to run the setup function
48    // and install a resize listener.
49    this.checkForAttachInterval_ = setInterval(
50        this.checkForAttach_.bind(this), 250);
51  }
52
53  TimelineViewport.prototype = {
54    __proto__: cr.EventTarget.prototype,
55
56    /**
57     * Allows initialization of the viewport when the viewport's parent element
58     * has been attached to the document and given a size.
59     * @param {Function} fn Function to call when the viewport can be safely
60     * initialized.
61     */
62    setWhenPossible: function(fn) {
63      this.pendingSetFunction_ = fn;
64    },
65
66    /**
67     * @return {boolean} Whether the current timeline is attached to the
68     * document.
69     */
70    get isAttachedToDocument_() {
71      var cur = this.parentEl_;
72      while (cur.parentNode)
73        cur = cur.parentNode;
74      return cur == this.parentEl_.ownerDocument;
75    },
76
77    onResize_: function() {
78      this.dispatchChangeEvent();
79    },
80
81    /**
82     * Checks whether the parentNode is attached to the document.
83     * When it is, it installs the iframe-based resize detection hook
84     * and then runs the pendingSetFunction_, if present.
85     */
86    checkForAttach_: function() {
87      if (!this.isAttachedToDocument_ || this.clientWidth == 0)
88        return;
89
90      if (!this.iframe_) {
91        this.iframe_ = document.createElement('iframe');
92        this.iframe_.style.cssText =
93            'position:absolute;width:100%;height:0;border:0;visibility:hidden;';
94        this.parentEl_.appendChild(this.iframe_);
95
96        this.iframe_.contentWindow.addEventListener('resize',
97                                                    this.onResizeBoundToThis_);
98      }
99
100      var curSize = this.clientWidth + 'x' + this.clientHeight;
101      if (this.pendingSetFunction_) {
102        this.lastSize_ = curSize;
103        this.pendingSetFunction_();
104        this.pendingSetFunction_ = undefined;
105      }
106
107      window.clearInterval(this.checkForAttachInterval_);
108      this.checkForAttachInterval_ = undefined;
109    },
110
111    /**
112     * Fires the change event on this viewport. Used to notify listeners
113     * to redraw when the underlying model has been mutated.
114     */
115    dispatchChangeEvent: function() {
116      cr.dispatchSimpleEvent(this, 'change');
117    },
118
119    detach: function() {
120      if (this.checkForAttachInterval_) {
121        window.clearInterval(this.checkForAttachInterval_);
122        this.checkForAttachInterval_ = undefined;
123      }
124      this.iframe_.removeEventListener('resize', this.onResizeBoundToThis_);
125      this.parentEl_.removeChild(this.iframe_);
126    },
127
128    get scaleX() {
129      return this.scaleX_;
130    },
131    set scaleX(s) {
132      var changed = this.scaleX_ != s;
133      if (changed) {
134        this.scaleX_ = s;
135        this.dispatchChangeEvent();
136      }
137    },
138
139    get panX() {
140      return this.panX_;
141    },
142    set panX(p) {
143      var changed = this.panX_ != p;
144      if (changed) {
145        this.panX_ = p;
146        this.dispatchChangeEvent();
147      }
148    },
149
150    setPanAndScale: function(p, s) {
151      var changed = this.scaleX_ != s || this.panX_ != p;
152      if (changed) {
153        this.scaleX_ = s;
154        this.panX_ = p;
155        this.dispatchChangeEvent();
156      }
157    },
158
159    xWorldToView: function(x) {
160      return (x + this.panX_) * this.scaleX_;
161    },
162
163    xWorldVectorToView: function(x) {
164      return x * this.scaleX_;
165    },
166
167    xViewToWorld: function(x) {
168      return (x / this.scaleX_) - this.panX_;
169    },
170
171    xViewVectorToWorld: function(x) {
172      return x / this.scaleX_;
173    },
174
175    xPanWorldPosToViewPos: function(worldX, viewX, viewWidth) {
176      if (typeof viewX == 'string') {
177        if (viewX == 'left') {
178          viewX = 0;
179        } else if (viewX == 'center') {
180          viewX = viewWidth / 2;
181        } else if (viewX == 'right') {
182          viewX = viewWidth - 1;
183        } else {
184          throw Error('unrecognized string for viewPos. left|center|right');
185        }
186      }
187      this.panX = (viewX / this.scaleX_) - worldX;
188    },
189
190    xPanWorldRangeIntoView: function(worldMin, worldMax, viewWidth) {
191      if (this.xWorldToView(worldMin) < 0)
192        this.xPanWorldPosToViewPos(worldMin, 'left', viewWidth);
193      else if (this.xWorldToView(worldMax) > viewWidth)
194        this.xPanWorldPosToViewPos(worldMax, 'right', viewWidth);
195    },
196
197    xSetWorldRange: function(worldMin, worldMax, viewWidth) {
198      var worldRange = worldMax - worldMin;
199      var scaleX = viewWidth / worldRange;
200      var panX = -worldMin;
201      this.setPanAndScale(panX, scaleX);
202    },
203
204    get gridEnabled() {
205      return this.gridEnabled_;
206    },
207
208    set gridEnabled(enabled) {
209      if (this.gridEnabled_ == enabled)
210        return;
211      this.gridEnabled_ = enabled && true;
212      this.dispatchChangeEvent();
213    },
214
215    get gridTimebase() {
216      return this.gridTimebase_;
217    },
218
219    set gridTimebase(timebase) {
220      if (this.gridTimebase_ == timebase)
221        return;
222      this.gridTimebase_ = timebase;
223      cr.dispatchSimpleEvent(this, 'change');
224    },
225
226    get gridStep() {
227      return this.gridStep_;
228    },
229
230    applyTransformToCanavs: function(ctx) {
231      ctx.transform(this.scaleX_, 0, 0, 1, this.panX_ * this.scaleX_, 0);
232    }
233  };
234
235  function TimelineSelectionSliceHit(track, slice) {
236    this.track = track;
237    this.slice = slice;
238  }
239  TimelineSelectionSliceHit.prototype = {
240    get selected() {
241      return this.slice.selected;
242    },
243    set selected(v) {
244      this.slice.selected = v;
245    }
246  };
247
248  function TimelineSelectionCounterSampleHit(track, counter, sampleIndex) {
249    this.track = track;
250    this.counter = counter;
251    this.sampleIndex = sampleIndex;
252  }
253  TimelineSelectionCounterSampleHit.prototype = {
254    get selected() {
255      return this.track.selectedSamples[this.sampleIndex] == true;
256    },
257    set selected(v) {
258      if (v)
259        this.track.selectedSamples[this.sampleIndex] = true;
260      else
261        this.track.selectedSamples[this.sampleIndex] = false;
262      this.track.invalidate();
263    }
264  };
265
266
267  /**
268   * Represents a selection within a Timeline and its associated set of tracks.
269   * @constructor
270   */
271  function TimelineSelection() {
272    this.range_dirty_ = true;
273    this.range_ = {};
274    this.length_ = 0;
275  }
276  TimelineSelection.prototype = {
277    __proto__: Object.prototype,
278
279    get range() {
280      if (this.range_dirty_) {
281        var wmin = Infinity;
282        var wmax = -wmin;
283        for (var i = 0; i < this.length_; i++) {
284          var hit = this[i];
285          if (hit.slice) {
286            wmin = Math.min(wmin, hit.slice.start);
287            wmax = Math.max(wmax, hit.slice.end);
288          }
289        }
290        this.range_ = {
291          min: wmin,
292          max: wmax
293        };
294        this.range_dirty_ = false;
295      }
296      return this.range_;
297    },
298
299    get duration() {
300      return this.range.max - this.range.min;
301    },
302
303    get length() {
304      return this.length_;
305    },
306
307    clear: function() {
308      for (var i = 0; i < this.length_; ++i)
309        delete this[i];
310      this.length_ = 0;
311      this.range_dirty_ = true;
312    },
313
314    push_: function(hit) {
315      this[this.length_++] = hit;
316      this.range_dirty_ = true;
317      return hit;
318    },
319
320    addSlice: function(track, slice) {
321      return this.push_(new TimelineSelectionSliceHit(track, slice));
322    },
323
324    addCounterSample: function(track, counter, sampleIndex) {
325      return this.push_(
326        new TimelineSelectionCounterSampleHit(
327          track, counter, sampleIndex));
328    },
329
330    subSelection: function(index, count) {
331      count = count || 1;
332
333      var selection = new TimelineSelection();
334      selection.range_dirty_ = true;
335      if (index < 0 || index + count > this.length_)
336        throw 'Index out of bounds';
337
338      for (var i = index; i < index + count; i++)
339        selection.push_(this[i]);
340
341      return selection;
342    },
343
344    getCounterSampleHits: function() {
345      var selection = new TimelineSelection();
346      for (var i = 0; i < this.length_; i++)
347        if (this[i] instanceof TimelineSelectionCounterSampleHit)
348          selection.push_(this[i]);
349      return selection;
350    },
351
352    getSliceHits: function() {
353      var selection = new TimelineSelection();
354      for (var i = 0; i < this.length_; i++)
355        if (this[i] instanceof TimelineSelectionSliceHit)
356          selection.push_(this[i]);
357      return selection;
358    },
359
360    map: function(fn) {
361      for (var i = 0; i < this.length_; i++)
362        fn(this[i]);
363    },
364
365    /**
366     * Helper for selection previous or next.
367     * @param {boolean} forwardp If true, select one forward (next).
368     *   Else, select previous.
369     * @return {boolean} true if current selection changed.
370     */
371    getShiftedSelection: function(offset) {
372      var newSelection = new TimelineSelection();
373      for (var i = 0; i < this.length_; i++) {
374        var hit = this[i];
375        hit.track.addItemNearToProvidedHitToSelection(
376            hit, offset, newSelection);
377      }
378
379      if (newSelection.length == 0)
380        return undefined;
381      return newSelection;
382    },
383  };
384
385  /**
386   * Renders a TimelineModel into a div element, making one
387   * TimelineTrack for each subrow in each thread of the model, managing
388   * overall track layout, and handling user interaction with the
389   * viewport.
390   *
391   * @constructor
392   * @extends {HTMLDivElement}
393   */
394  var Timeline = cr.ui.define('div');
395
396  Timeline.prototype = {
397    __proto__: HTMLDivElement.prototype,
398
399    model_: null,
400
401    decorate: function() {
402      this.classList.add('timeline');
403
404      this.viewport_ = new TimelineViewport(this);
405      this.viewportTrack = new tracing.TimelineViewportTrack();
406
407      this.tracks_ = this.ownerDocument.createElement('div');
408      this.appendChild(this.tracks_);
409
410      this.dragBox_ = this.ownerDocument.createElement('div');
411      this.dragBox_.className = 'timeline-drag-box';
412      this.appendChild(this.dragBox_);
413      this.hideDragBox_();
414
415      this.bindEventListener_(document, 'keypress', this.onKeypress_, this);
416      this.bindEventListener_(document, 'keydown', this.onKeydown_, this);
417      this.bindEventListener_(document, 'mousedown', this.onMouseDown_, this);
418      this.bindEventListener_(document, 'mousemove', this.onMouseMove_, this);
419      this.bindEventListener_(document, 'mouseup', this.onMouseUp_, this);
420      this.bindEventListener_(document, 'dblclick', this.onDblClick_, this);
421
422      this.lastMouseViewPos_ = {x: 0, y: 0};
423
424      this.selection_ = new TimelineSelection();
425    },
426
427    /**
428     * Wraps the standard addEventListener but automatically binds the provided
429     * func to the provided target, tracking the resulting closure. When detach
430     * is called, these listeners will be automatically removed.
431     */
432    bindEventListener_: function(object, event, func, target) {
433      if (!this.boundListeners_)
434        this.boundListeners_ = [];
435      var boundFunc = func.bind(target);
436      this.boundListeners_.push({object: object,
437        event: event,
438        boundFunc: boundFunc});
439      object.addEventListener(event, boundFunc);
440    },
441
442    detach: function() {
443      for (var i = 0; i < this.tracks_.children.length; i++)
444        this.tracks_.children[i].detach();
445
446      for (var i = 0; i < this.boundListeners_.length; i++) {
447        var binding = this.boundListeners_[i];
448        binding.object.removeEventListener(binding.event, binding.boundFunc);
449      }
450      this.boundListeners_ = undefined;
451      this.viewport_.detach();
452    },
453
454    get viewport() {
455      return this.viewport_;
456    },
457
458    get model() {
459      return this.model_;
460    },
461
462    set model(model) {
463      if (!model)
464        throw Error('Model cannot be null');
465      if (this.model) {
466        throw Error('Cannot set model twice.');
467      }
468      this.model_ = model;
469
470      // Figure out all the headings.
471      var allHeadings = [];
472      model.getAllThreads().forEach(function(t) {
473        allHeadings.push(t.userFriendlyName);
474      });
475      model.getAllCounters().forEach(function(c) {
476        allHeadings.push(c.name);
477      });
478      model.getAllCpus().forEach(function(c) {
479        allHeadings.push('CPU ' + c.cpuNumber);
480      });
481
482      // Figure out the maximum heading size.
483      var maxHeadingWidth = 0;
484      var measuringStick = new tracing.MeasuringStick();
485      var headingEl = document.createElement('div');
486      headingEl.style.position = 'fixed';
487      headingEl.className = 'timeline-canvas-based-track-title';
488      allHeadings.forEach(function(text) {
489        headingEl.textContent = text + ':__';
490        var w = measuringStick.measure(headingEl).width;
491        // Limit heading width to 300px.
492        if (w > 300)
493          w = 300;
494        if (w > maxHeadingWidth)
495          maxHeadingWidth = w;
496      });
497      maxHeadingWidth = maxHeadingWidth + 'px';
498
499      // Reset old tracks.
500      for (var i = 0; i < this.tracks_.children.length; i++)
501        this.tracks_.children[i].detach();
502      this.tracks_.textContent = '';
503
504      // Set up the viewport track
505      this.viewportTrack.headingWidth = maxHeadingWidth;
506      this.viewportTrack.viewport = this.viewport_;
507
508      // Get a sorted list of CPUs
509      var cpus = model.getAllCpus();
510      cpus.sort(tracing.TimelineCpu.compare);
511
512      // Create tracks for each CPU.
513      cpus.forEach(function(cpu) {
514        var track = new tracing.TimelineCpuTrack();
515        track.heading = 'CPU ' + cpu.cpuNumber + ':';
516        track.headingWidth = maxHeadingWidth;
517        track.viewport = this.viewport_;
518        track.cpu = cpu;
519        this.tracks_.appendChild(track);
520
521        for (var counterName in cpu.counters) {
522          var counter = cpu.counters[counterName];
523          track = new tracing.TimelineCounterTrack();
524          track.heading = 'CPU ' + cpu.cpuNumber + ' ' + counter.name + ':';
525          track.headingWidth = maxHeadingWidth;
526          track.viewport = this.viewport_;
527          track.counter = counter;
528          this.tracks_.appendChild(track);
529        }
530      }.bind(this));
531
532      // Get a sorted list of processes.
533      var processes = model.getAllProcesses();
534      processes.sort(tracing.TimelineProcess.compare);
535
536      // Create tracks for each process.
537      processes.forEach(function(process) {
538        // Add counter tracks for this process.
539        var counters = [];
540        for (var tid in process.counters)
541          counters.push(process.counters[tid]);
542        counters.sort(tracing.TimelineCounter.compare);
543
544        // Create the counters for this process.
545        counters.forEach(function(counter) {
546          var track = new tracing.TimelineCounterTrack();
547          track.heading = counter.name + ':';
548          track.headingWidth = maxHeadingWidth;
549          track.viewport = this.viewport_;
550          track.counter = counter;
551          this.tracks_.appendChild(track);
552        }.bind(this));
553
554        // Get a sorted list of threads.
555        var threads = [];
556        for (var tid in process.threads)
557          threads.push(process.threads[tid]);
558        threads.sort(tracing.TimelineThread.compare);
559
560        // Create the threads.
561        threads.forEach(function(thread) {
562          var track = new tracing.TimelineThreadTrack();
563          track.heading = thread.userFriendlyName + ':';
564          track.tooltip = thread.userFriendlyDetails;
565          track.headingWidth = maxHeadingWidth;
566          track.viewport = this.viewport_;
567          track.thread = thread;
568          this.tracks_.appendChild(track);
569        }.bind(this));
570      }.bind(this));
571
572      // Set up a reasonable viewport.
573      this.viewport_.setWhenPossible(function() {
574        var w = this.firstCanvas.width;
575        this.viewport_.xSetWorldRange(this.model_.minTimestamp,
576                                      this.model_.maxTimestamp,
577                                      w);
578      }.bind(this));
579    },
580
581    /**
582     * @param {TimelineFilter} filter The filter to use for finding matches.
583     * @param {TimelineSelection} selection The selection to add matches to.
584     * @return {Array} An array of objects that match the provided
585     * TimelineFilter.
586     */
587    addAllObjectsMatchingFilterToSelection: function(filter, selection) {
588      for (var i = 0; i < this.tracks_.children.length; ++i)
589        this.tracks_.children[i].addAllObjectsMatchingFilterToSelection(
590          filter, selection);
591    },
592
593    /**
594     * @return {Element} The element whose focused state determines
595     * whether to respond to keyboard inputs.
596     * Defaults to the parent element.
597     */
598    get focusElement() {
599      if (this.focusElement_)
600        return this.focusElement_;
601      return this.parentElement;
602    },
603
604    /**
605     * Sets the element whose focus state will determine whether
606     * to respond to keybaord input.
607     */
608    set focusElement(value) {
609      this.focusElement_ = value;
610    },
611
612    get listenToKeys_() {
613      if (!this.viewport_.isAttachedToDocument_)
614        return false;
615      if (!this.focusElement_)
616        return true;
617      if (this.focusElement.tabIndex >= 0)
618        return document.activeElement == this.focusElement;
619      return true;
620    },
621
622    onKeypress_: function(e) {
623      var vp = this.viewport_;
624      if (!this.firstCanvas)
625        return;
626      if (!this.listenToKeys_)
627        return;
628      var viewWidth = this.firstCanvas.clientWidth;
629      var curMouseV, curCenterW;
630      switch (e.keyCode) {
631        case 101: // e
632          var vX = this.lastMouseViewPos_.x;
633          var wX = vp.xViewToWorld(this.lastMouseViewPos_.x);
634          var distFromCenter = vX - (viewWidth / 2);
635          var percFromCenter = distFromCenter / viewWidth;
636          var percFromCenterSq = percFromCenter * percFromCenter;
637          vp.xPanWorldPosToViewPos(wX, 'center', viewWidth);
638          break;
639        case 119:  // w
640          this.zoomBy_(1.5);
641          break;
642        case 115:  // s
643          this.zoomBy_(1 / 1.5);
644          break;
645        case 103:  // g
646          this.onGridToggle_(true);
647          break;
648        case 71:  // G
649          this.onGridToggle_(false);
650          break;
651        case 87:  // W
652          this.zoomBy_(10);
653          break;
654        case 83:  // S
655          this.zoomBy_(1 / 10);
656          break;
657        case 97:  // a
658          vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1);
659          break;
660        case 100:  // d
661          vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1);
662          break;
663        case 65:  // A
664          vp.panX += vp.xViewVectorToWorld(viewWidth * 0.5);
665          break;
666        case 68:  // D
667          vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.5);
668          break;
669      }
670    },
671
672    // Not all keys send a keypress.
673    onKeydown_: function(e) {
674      if (!this.listenToKeys_)
675        return;
676      var sel;
677      switch (e.keyCode) {
678        case 37:   // left arrow
679          sel = this.selection.getShiftedSelection(-1);
680          if (sel) {
681            this.setSelectionAndMakeVisible(sel);
682            e.preventDefault();
683          }
684          break;
685        case 39:   // right arrow
686          sel = this.selection.getShiftedSelection(1);
687          if (sel) {
688            this.setSelectionAndMakeVisible(sel);
689            e.preventDefault();
690          }
691          break;
692        case 9:    // TAB
693          if (this.focusElement.tabIndex == -1) {
694            if (e.shiftKey)
695              this.selectPrevious_(e);
696            else
697              this.selectNext_(e);
698            e.preventDefault();
699          }
700          break;
701      }
702    },
703
704    /**
705     * Zoom in or out on the timeline by the given scale factor.
706     * @param {integer} scale The scale factor to apply.  If <1, zooms out.
707     */
708    zoomBy_: function(scale) {
709      if (!this.firstCanvas)
710        return;
711      var vp = this.viewport_;
712      var viewWidth = this.firstCanvas.clientWidth;
713      var curMouseV = this.lastMouseViewPos_.x;
714      var curCenterW = vp.xViewToWorld(curMouseV);
715      vp.scaleX = vp.scaleX * scale;
716      vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth);
717    },
718
719    get keyHelp() {
720      var help = 'Keyboard shortcuts:\n' +
721          ' w/s     : Zoom in/out    (with shift: go faster)\n' +
722          ' a/d     : Pan left/right\n' +
723          ' e       : Center on mouse\n' +
724          ' g/G     : Shows grid at the start/end of the selected task\n';
725
726      if (this.focusElement.tabIndex) {
727        help += ' <-      : Select previous event on current timeline\n' +
728            ' ->      : Select next event on current timeline\n';
729      } else {
730        help += ' <-,^TAB : Select previous event on current timeline\n' +
731            ' ->, TAB : Select next event on current timeline\n';
732      }
733      help +=
734          '\n' +
735          'Dbl-click to zoom in; Shift dbl-click to zoom out\n';
736      return help;
737    },
738
739    get selection() {
740      return this.selection_;
741    },
742
743    set selection(selection) {
744      if (!(selection instanceof TimelineSelection))
745          throw 'Expected TimelineSelection';
746
747      // Clear old selection.
748      var i;
749      for (i = 0; i < this.selection_.length; i++)
750        this.selection_[i].selected = false;
751
752      this.selection_ = selection;
753
754      cr.dispatchSimpleEvent(this, 'selectionChange');
755      for (i = 0; i < this.selection_.length; i++)
756        this.selection_[i].selected = true;
757      this.viewport_.dispatchChangeEvent(); // Triggers a redraw.
758    },
759
760    setSelectionAndMakeVisible: function(selection, zoomAllowed) {
761      if (!(selection instanceof TimelineSelection))
762          throw 'Expected TimelineSelection';
763      this.selection = selection;
764      var range = this.selection.range;
765      var size = this.viewport_.xWorldVectorToView(range.max - range.min);
766      if (zoomAllowed && size < 50) {
767        var worldCenter = range.min + (range.max - range.min) * 0.5;
768        var worldRange = (range.max - range.min) * 5;
769        this.viewport_.xSetWorldRange(worldCenter - worldRange * 0.5,
770                                      worldCenter + worldRange * 0.5,
771                                      this.firstCanvas.width);
772        return;
773      }
774
775      this.viewport_.xPanWorldRangeIntoView(range.min, range.max,
776                                            this.firstCanvas.width);
777    },
778
779    get firstCanvas() {
780      return this.tracks_.firstChild ?
781          this.tracks_.firstChild.firstCanvas : undefined;
782    },
783
784    hideDragBox_: function() {
785      this.dragBox_.style.left = '-1000px';
786      this.dragBox_.style.top = '-1000px';
787      this.dragBox_.style.width = 0;
788      this.dragBox_.style.height = 0;
789    },
790
791    setDragBoxPosition_: function(eDown, eCur) {
792      var loX = Math.min(eDown.clientX, eCur.clientX);
793      var hiX = Math.max(eDown.clientX, eCur.clientX);
794      var loY = Math.min(eDown.clientY, eCur.clientY);
795      var hiY = Math.max(eDown.clientY, eCur.clientY);
796
797      this.dragBox_.style.left = loX + 'px';
798      this.dragBox_.style.top = loY + 'px';
799      this.dragBox_.style.width = hiX - loX + 'px';
800      this.dragBox_.style.height = hiY - loY + 'px';
801
802      var canv = this.firstCanvas;
803      var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft);
804      var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft);
805
806      var roundedDuration = Math.round((hiWX - loWX) * 100) / 100;
807      this.dragBox_.textContent = roundedDuration + 'ms';
808
809      var e = new cr.Event('selectionChanging');
810      e.loWX = loWX;
811      e.hiWX = hiWX;
812      this.dispatchEvent(e);
813    },
814
815    onGridToggle_: function(left) {
816      var tb;
817      if (left)
818        tb = this.selection_.range.min;
819      else
820        tb = this.selection_.range.max;
821
822      // Shift the timebase left until its just left of minTimestamp.
823      var numInterfvalsSinceStart = Math.ceil((tb - this.model_.minTimestamp) /
824          this.viewport_.gridStep_);
825      this.viewport_.gridTimebase = tb -
826          (numInterfvalsSinceStart + 1) * this.viewport_.gridStep_;
827      this.viewport_.gridEnabled = true;
828    },
829
830    onMouseDown_: function(e) {
831      var canv = this.firstCanvas;
832      var rect = this.tracks_.getClientRects()[0];
833      var inside = rect &&
834          e.clientX >= rect.left &&
835          e.clientX < rect.right &&
836          e.clientY >= rect.top &&
837          e.clientY < rect.bottom &&
838          e.x >= canv.offsetLeft;
839      if (!inside)
840        return;
841
842      var pos = {
843        x: e.clientX - canv.offsetLeft,
844        y: e.clientY - canv.offsetTop
845      };
846
847      var wX = this.viewport_.xViewToWorld(pos.x);
848
849      this.dragBeginEvent_ = e;
850      e.preventDefault();
851      if (this.focusElement.tabIndex >= 0)
852        this.focusElement.focus();
853    },
854
855    onMouseMove_: function(e) {
856      if (!this.firstCanvas)
857        return;
858      var canv = this.firstCanvas;
859      var pos = {
860        x: e.clientX - canv.offsetLeft,
861        y: e.clientY - canv.offsetTop
862      };
863
864      // Remember position. Used during keyboard zooming.
865      this.lastMouseViewPos_ = pos;
866
867      // Update the drag box
868      if (this.dragBeginEvent_) {
869        this.setDragBoxPosition_(this.dragBeginEvent_, e);
870      }
871    },
872
873    onMouseUp_: function(e) {
874      var i;
875      if (this.dragBeginEvent_) {
876        // Stop the dragging.
877        this.hideDragBox_();
878        var eDown = this.dragBeginEvent_;
879        this.dragBeginEvent_ = null;
880
881        // Figure out extents of the drag.
882        var loX = Math.min(eDown.clientX, e.clientX);
883        var hiX = Math.max(eDown.clientX, e.clientX);
884        var loY = Math.min(eDown.clientY, e.clientY);
885        var hiY = Math.max(eDown.clientY, e.clientY);
886
887        // Convert to worldspace.
888        var canv = this.firstCanvas;
889        var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft);
890        var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft);
891
892        // Figure out what has been hit.
893        var selection = new TimelineSelection();
894        for (i = 0; i < this.tracks_.children.length; i++) {
895          var track = this.tracks_.children[i];
896
897          // Only check tracks that insersect the rect.
898          var trackClientRect = track.getBoundingClientRect();
899          var a = Math.max(loY, trackClientRect.top);
900          var b = Math.min(hiY, trackClientRect.bottom);
901          if (a <= b) {
902            track.addIntersectingItemsInRangeToSelection(
903              loWX, hiWX, loY, hiY, selection);
904          }
905        }
906        // Activate the new selection.
907        this.selection = selection;
908      }
909    },
910
911    onDblClick_: function(e) {
912      var canv = this.firstCanvas;
913      if (e.x < canv.offsetLeft)
914        return;
915
916      var scale = 4;
917      if (e.shiftKey)
918        scale = 1 / scale;
919      this.zoomBy_(scale);
920      e.preventDefault();
921    }
922  };
923
924  /**
925   * The TimelineModel being viewed by the timeline
926   * @type {TimelineModel}
927   */
928  cr.defineProperty(Timeline, 'model', cr.PropertyKind.JS);
929
930  return {
931    Timeline: Timeline,
932    TimelineSelectionSliceHit: TimelineSelectionSliceHit,
933    TimelineSelectionCounterSampleHit: TimelineSelectionCounterSampleHit,
934    TimelineSelection: TimelineSelection,
935    TimelineViewport: TimelineViewport
936  };
937});
938