• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 */
20base.requireStylesheet('timeline');
21base.require('event_target');
22base.require('measuring_stick');
23base.require('timeline_filter');
24base.require('timeline_selection');
25base.require('timeline_viewport');
26base.require('tracks.timeline_model_track');
27base.require('tracks.timeline_viewport_track');
28base.require('ui');
29
30base.exportTo('tracing', function() {
31
32  var TimelineSelection = tracing.TimelineSelection;
33  var TimelineViewport = tracing.TimelineViewport;
34
35  function intersectRect_(r1, r2) {
36    var results = new Object;
37    if (r2.left > r1.right || r2.right < r1.left ||
38         r2.top > r1.bottom || r2.bottom < r1.top) {
39      return false;
40    }
41    results.left = Math.max(r1.left, r2.left);
42    results.top = Math.max(r1.top, r2.top);
43    results.right = Math.min(r1.right, r2.right);
44    results.bottom = Math.min(r1.bottom, r2.bottom);
45    results.width = (results.right - results.left);
46    results.height = (results.bottom - results.top);
47    return results;
48  }
49
50  /**
51   * Renders a TimelineModel into a div element, making one
52   * TimelineTrack for each subrow in each thread of the model, managing
53   * overall track layout, and handling user interaction with the
54   * viewport.
55   *
56   * @constructor
57   * @extends {HTMLDivElement}
58   */
59  var Timeline = base.ui.define('div');
60
61  Timeline.prototype = {
62    __proto__: HTMLDivElement.prototype,
63
64    model_: null,
65
66    decorate: function() {
67      this.classList.add('timeline');
68
69      this.categoryFilter_ = new tracing.TimelineCategoryFilter();
70
71      this.viewport_ = new TimelineViewport(this);
72
73      // Add the viewport track.
74      this.viewportTrack_ = new tracks.TimelineViewportTrack();
75      this.viewportTrack_.viewport = this.viewport_;
76      this.appendChild(this.viewportTrack_);
77
78      this.modelTrackContainer_ = document.createElement('div');
79      this.modelTrackContainer_.className = 'timeline-model-track-container';
80      this.appendChild(this.modelTrackContainer_);
81
82      this.modelTrack_ = new tracks.TimelineModelTrack();
83      this.modelTrackContainer_.appendChild(this.modelTrack_);
84
85      this.dragBox_ = this.ownerDocument.createElement('div');
86      this.dragBox_.className = 'timeline-drag-box';
87      this.appendChild(this.dragBox_);
88      this.hideDragBox_();
89
90      this.bindEventListener_(document, 'keypress', this.onKeypress_, this);
91      this.bindEventListener_(document, 'keydown', this.onKeydown_, this);
92      this.bindEventListener_(document, 'keyup', this.onKeyup_, this);
93      this.bindEventListener_(document, 'mousemove', this.onMouseMove_, this);
94      this.bindEventListener_(document, 'mouseup', this.onMouseUp_, this);
95
96      this.addEventListener('mousewheel', this.onMouseWheel_);
97      this.addEventListener('mousedown', this.onMouseDown_);
98      this.addEventListener('dblclick', this.onDblClick_);
99
100      this.lastMouseViewPos_ = {x: 0, y: 0};
101      this.maxHeadingWidth_ = 0;
102
103      this.selection_ = new TimelineSelection();
104    },
105
106    /**
107     * Wraps the standard addEventListener but automatically binds the provided
108     * func to the provided target, tracking the resulting closure. When detach
109     * is called, these listeners will be automatically removed.
110     */
111    bindEventListener_: function(object, event, func, target) {
112      if (!this.boundListeners_)
113        this.boundListeners_ = [];
114      var boundFunc = func.bind(target);
115      this.boundListeners_.push({object: object,
116        event: event,
117        boundFunc: boundFunc});
118      object.addEventListener(event, boundFunc);
119    },
120
121    detach: function() {
122      this.modelTrack_.detach();
123
124      for (var i = 0; i < this.boundListeners_.length; i++) {
125        var binding = this.boundListeners_[i];
126        binding.object.removeEventListener(binding.event, binding.boundFunc);
127      }
128      this.boundListeners_ = undefined;
129      this.viewport_.detach();
130    },
131
132    get viewport() {
133      return this.viewport_;
134    },
135
136    get categoryFilter() {
137      return this.categoryFilter_;
138    },
139
140    set categoryFilter(filter) {
141      this.categoryFilter_ = filter;
142      this.modelTrack_.categoryFilter = filter;
143    },
144
145    get model() {
146      return this.model_;
147    },
148
149    set model(model) {
150      if (!model)
151        throw new Error('Model cannot be null');
152
153      var modelInstanceChanged = this.model_ != model;
154      this.model_ = model;
155      this.modelTrack_.model = model;
156      this.modelTrack_.viewport = this.viewport_;
157      this.modelTrack_.categoryFilter = this.categoryFilter;
158      this.viewportTrack_.headingWidth = this.modelTrack_.headingWidth;
159
160      // Set up a reasonable viewport.
161      if (modelInstanceChanged)
162        this.viewport_.setWhenPossible(this.setInitialViewport_.bind(this));
163    },
164
165    get numVisibleTracks() {
166      return this.modelTrack_.numVisibleTracks;
167    },
168
169    setInitialViewport_: function() {
170      var w = this.firstCanvas.width;
171      var boost =
172          (this.model_.maxTimestamp - this.model_.minTimestamp) * 0.15;
173      this.viewport_.xSetWorldRange(this.model_.minTimestamp - boost,
174                                    this.model_.maxTimestamp + boost,
175                                    w);
176    },
177
178    /**
179     * @param {TimelineFilter} filter The filter to use for finding matches.
180     * @param {TimelineSelection} selection The selection to add matches to.
181     * @return {Array} An array of objects that match the provided
182     * TimelineTitleFilter.
183     */
184    addAllObjectsMatchingFilterToSelection: function(filter, selection) {
185      this.modelTrack_.addAllObjectsMatchingFilterToSelection(filter,
186                                                              selection);
187    },
188
189    /**
190     * @return {Element} The element whose focused state determines
191     * whether to respond to keyboard inputs.
192     * Defaults to the parent element.
193     */
194    get focusElement() {
195      if (this.focusElement_)
196        return this.focusElement_;
197      return this.parentElement;
198    },
199
200    /**
201     * Sets the element whose focus state will determine whether
202     * to respond to keybaord input.
203     */
204    set focusElement(value) {
205      this.focusElement_ = value;
206    },
207
208    get listenToKeys_() {
209      if (!this.viewport_.isAttachedToDocument_)
210        return false;
211      if (this.activeElement instanceof tracing.TimelineFindControl)
212        return false;
213      if (!this.focusElement_)
214        return true;
215      if (this.focusElement.tabIndex >= 0)
216        return document.activeElement == this.focusElement;
217      return true;
218    },
219
220    onKeypress_: function(e) {
221      var vp = this.viewport_;
222      if (!this.firstCanvas)
223        return;
224      if (!this.listenToKeys_)
225        return;
226      if (document.activeElement.nodeName == 'INPUT')
227        return;
228      var viewWidth = this.firstCanvas.clientWidth;
229      var curMouseV, curCenterW;
230      switch (e.keyCode) {
231        case 119:  // w
232        case 44:   // ,
233          this.zoomBy_(1.5);
234          break;
235        case 115:  // s
236        case 111:  // o
237          this.zoomBy_(1 / 1.5);
238          break;
239        case 103:  // g
240          this.onGridToggle_(true);
241          break;
242        case 71:  // G
243          this.onGridToggle_(false);
244          break;
245        case 87:  // W
246        case 60:  // <
247          this.zoomBy_(10);
248          break;
249        case 83:  // S
250        case 79:  // O
251          this.zoomBy_(1 / 10);
252          break;
253        case 97:  // a
254          vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1);
255          break;
256        case 100:  // d
257        case 101:  // e
258          vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1);
259          break;
260        case 65:  // A
261          vp.panX += vp.xViewVectorToWorld(viewWidth * 0.5);
262          break;
263        case 68:  // D
264          vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.5);
265          break;
266        case 48:  // 0
267        case 122: // z
268          this.setInitialViewport_();
269          break;
270        case 102:  // f
271          this.zoomToSelection_();
272          break;
273      }
274    },
275
276    onMouseWheel_: function(e) {
277      if (e.altKey) {
278        var delta = e.wheelDeltaY / 120;
279        var zoomScale = Math.pow(1.5, delta);
280        this.zoomBy_(zoomScale);
281        e.preventDefault();
282      }
283    },
284
285    // Not all keys send a keypress.
286    onKeydown_: function(e) {
287      if (!this.listenToKeys_)
288        return;
289      var sel;
290      var vp = this.viewport_;
291      var viewWidth = this.firstCanvas.clientWidth;
292      switch (e.keyCode) {
293        case 37:   // left arrow
294          sel = this.selection.getShiftedSelection(-1);
295          if (sel) {
296            this.setSelectionAndMakeVisible(sel);
297            e.preventDefault();
298          } else {
299            if (!this.firstCanvas)
300              return;
301            vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1);
302          }
303          break;
304        case 39:   // right arrow
305          sel = this.selection.getShiftedSelection(1);
306          if (sel) {
307            this.setSelectionAndMakeVisible(sel);
308            e.preventDefault();
309          } else {
310            if (!this.firstCanvas)
311              return;
312            vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1);
313          }
314          break;
315        case 9:    // TAB
316          if (this.focusElement.tabIndex == -1) {
317            if (e.shiftKey)
318              this.selectPrevious_(e);
319            else
320              this.selectNext_(e);
321            e.preventDefault();
322          }
323          break;
324      }
325      if (e.shiftKey && this.dragBeginEvent_) {
326          var vertical = e.shiftKey;
327          if (this.dragBeginEvent_) {
328            this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_,
329                               this.dragBoxXEnd_, this.dragBoxYEnd_, vertical);
330          }
331      }
332    },
333
334    onKeyup_: function(e) {
335      if (!this.listenToKeys_)
336        return;
337      if (!e.shiftKey) {
338        if (this.dragBeginEvent_) {
339          var vertical = e.shiftKey;
340          this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_,
341                                this.dragBoxXEnd_, this.dragBoxYEnd_, vertical);
342          }
343      }
344    },
345
346    /**
347     * Zoom in or out on the timeline by the given scale factor.
348     * @param {integer} scale The scale factor to apply.  If <1, zooms out.
349     */
350    zoomBy_: function(scale) {
351      if (!this.firstCanvas)
352        return;
353      var vp = this.viewport_;
354      var viewWidth = this.firstCanvas.clientWidth;
355      var curMouseV = this.lastMouseViewPos_.x;
356      var curCenterW = vp.xViewToWorld(curMouseV);
357      vp.scaleX = vp.scaleX * scale;
358      vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth);
359    },
360
361    /**
362     * Zoom into the current selection.
363     */
364    zoomToSelection_: function() {
365      if (!this.selection)
366        return;
367      var range = this.selection.range;
368      var worldCenter = range.min + (range.max - range.min) * 0.5;
369      var worldRange = (range.max - range.min) * 0.5;
370      var boost = worldRange * 0.15;
371      this.viewport_.xSetWorldRange(worldCenter - worldRange - boost,
372                                    worldCenter + worldRange + boost,
373                                    this.firstCanvas.width);
374    },
375
376    get keyHelp() {
377      var mod = navigator.platform.indexOf('Mac') == 0 ? 'cmd' : 'ctrl';
378      var help = 'Qwerty Controls\n' +
379          ' w/s           : Zoom in/out    (with shift: go faster)\n' +
380          ' a/d           : Pan left/right\n\n' +
381          'Dvorak Controls\n' +
382          ' ,/o           : Zoom in/out     (with shift: go faster)\n' +
383          ' a/e           : Pan left/right\n\n' +
384          'Mouse Controls\n' +
385          ' drag          : Select slices   (with ' + mod +
386                                                        ': zoom to slices)\n' +
387          ' drag + shift  : Select all slices vertically\n\n';
388
389      if (this.focusElement.tabIndex) {
390        help +=
391          ' <-            : Select previous event on current timeline\n' +
392          ' ->            : Select next event on current timeline\n';
393      } else {
394        help += 'General Navigation\n' +
395          ' g/General     : Shows grid at the start/end of the selected' +
396                                                                  ' task\n' +
397          ' <-,^TAB       : Select previous event on current timeline\n' +
398          ' ->, TAB       : Select next event on current timeline\n';
399      }
400      help +=
401          '\n' +
402          'Alt + Scroll to zoom in/out\n' +
403          'Dbl-click to zoom in; Shift dbl-click to zoom out\n' +
404          'f to zoom into selection\n' +
405          'z to reset zoom and pan to initial view\n';
406      return help;
407    },
408
409    get selection() {
410      return this.selection_;
411    },
412
413    set selection(selection) {
414      if (!(selection instanceof TimelineSelection))
415        throw new Error('Expected TimelineSelection');
416
417      // Clear old selection.
418      var i;
419      for (i = 0; i < this.selection_.length; i++)
420        this.selection_[i].selected = false;
421
422      this.selection_ = selection;
423
424      base.dispatchSimpleEvent(this, 'selectionChange');
425      for (i = 0; i < this.selection_.length; i++)
426        this.selection_[i].selected = true;
427      this.viewport_.dispatchChangeEvent(); // Triggers a redraw.
428    },
429
430    setSelectionAndMakeVisible: function(selection, zoomAllowed) {
431      if (!(selection instanceof TimelineSelection))
432        throw new Error('Expected TimelineSelection');
433      this.selection = selection;
434      var range = this.selection.range;
435      var size = this.viewport_.xWorldVectorToView(range.max - range.min);
436      if (zoomAllowed && size < 50) {
437        var worldCenter = range.min + (range.max - range.min) * 0.5;
438        var worldRange = (range.max - range.min) * 5;
439        this.viewport_.xSetWorldRange(worldCenter - worldRange * 0.5,
440                                      worldCenter + worldRange * 0.5,
441                                      this.firstCanvas.width);
442        return;
443      }
444
445      this.viewport_.xPanWorldRangeIntoView(range.min, range.max,
446                                            this.firstCanvas.width);
447    },
448
449    get firstCanvas() {
450      if (this.viewportTrack_)
451        return this.viewportTrack_.firstCanvas;
452      if (this.modelTrack_)
453        return this.modelTrack_.firstCanvas;
454      return undefined;
455    },
456
457    hideDragBox_: function() {
458      this.dragBox_.style.left = '-1000px';
459      this.dragBox_.style.top = '-1000px';
460      this.dragBox_.style.width = 0;
461      this.dragBox_.style.height = 0;
462    },
463
464    setDragBoxPosition_: function(xStart, yStart, xEnd, yEnd, vertical) {
465      var loY;
466      var hiY;
467      var loX = Math.min(xStart, xEnd);
468      var hiX = Math.max(xStart, xEnd);
469      var modelTrackRect = this.modelTrack_.getBoundingClientRect();
470
471      if (vertical) {
472        loY = modelTrackRect.top;
473        hiY = modelTrackRect.bottom;
474      } else {
475        loY = Math.min(yStart, yEnd);
476        hiY = Math.max(yStart, yEnd);
477      }
478
479      var dragRect = {left: loX, top: loY, width: hiX - loX, height: hiY - loY};
480      dragRect.right = dragRect.left + dragRect.width;
481      dragRect.bottom = dragRect.top + dragRect.height;
482      var modelTrackContainerRect =
483                              this.modelTrackContainer_.getBoundingClientRect();
484      var clipRect = {
485        left: modelTrackContainerRect.left,
486        top: modelTrackContainerRect.top,
487        right: modelTrackContainerRect.right,
488        bottom: modelTrackContainerRect.bottom,
489      };
490      var trackTitleWidth = parseInt(this.modelTrack_.headingWidth);
491      clipRect.left = clipRect.left + trackTitleWidth;
492
493      var finalDragBox = intersectRect_(clipRect, dragRect);
494
495      this.dragBox_.style.left = finalDragBox.left + 'px';
496      this.dragBox_.style.width = finalDragBox.width + 'px';
497      this.dragBox_.style.top = finalDragBox.top + 'px';
498      this.dragBox_.style.height = finalDragBox.height + 'px';
499
500      var canv = this.firstCanvas;
501      var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft);
502      var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft);
503
504      var roundedDuration = Math.round((hiWX - loWX) * 100) / 100;
505      this.dragBox_.textContent = roundedDuration + 'ms';
506
507      var e = new base.Event('selectionChanging');
508      e.loWX = loWX;
509      e.hiWX = hiWX;
510      this.dispatchEvent(e);
511    },
512
513    onGridToggle_: function(left) {
514      var tb;
515      if (left)
516        tb = this.selection_.range.min;
517      else
518        tb = this.selection_.range.max;
519
520      // Shift the timebase left until its just left of minTimestamp.
521      var numInterfvalsSinceStart = Math.ceil((tb - this.model_.minTimestamp) /
522          this.viewport_.gridStep_);
523      this.viewport_.gridTimebase = tb -
524          (numInterfvalsSinceStart + 1) * this.viewport_.gridStep_;
525      this.viewport_.gridEnabled = true;
526    },
527
528    isChildOfThis_: function(el) {
529      if (el == this)
530        return;
531
532      var isChildOfThis = false;
533      var cur = el;
534      while (cur.parentNode) {
535        if (cur == this)
536          return true;
537        cur = cur.parentNode;
538      }
539      return false;
540    },
541
542    onMouseDown_: function(e) {
543      if (e.button !== 0)
544        return;
545
546      if (e.shiftKey) {
547        this.viewportTrack_.placeAndBeginDraggingMarker(e.clientX);
548        return;
549      }
550
551      var canv = this.firstCanvas;
552      var rect = this.modelTrack_.getBoundingClientRect();
553      var canvRect = this.firstCanvas.getBoundingClientRect();
554
555      var inside = rect &&
556          e.clientX >= rect.left &&
557          e.clientX < rect.right &&
558          e.clientY >= rect.top &&
559          e.clientY < rect.bottom &&
560          e.clientX >= canvRect.left &&
561          e.clientX < canvRect.right;
562
563      if (!inside)
564        return;
565
566      var pos = {
567        x: e.clientX - canv.offsetLeft,
568        y: e.clientY - canv.offsetTop
569      };
570
571      var wX = this.viewport_.xViewToWorld(pos.x);
572
573      this.dragBeginEvent_ = e;
574      e.preventDefault();
575      if (document.activeElement)
576        document.activeElement.blur();
577      if (this.focusElement.tabIndex >= 0)
578        this.focusElement.focus();
579    },
580
581    onMouseMove_: function(e) {
582      if (!this.firstCanvas)
583        return;
584      var canv = this.firstCanvas;
585      var pos = {
586        x: e.clientX - canv.offsetLeft,
587        y: e.clientY - canv.offsetTop
588      };
589
590      // Remember position. Used during keyboard zooming.
591      this.lastMouseViewPos_ = pos;
592
593      // Update the drag box
594      if (this.dragBeginEvent_) {
595        this.dragBoxXStart_ = this.dragBeginEvent_.clientX;
596        this.dragBoxXEnd_ = e.clientX;
597        this.dragBoxYStart_ = this.dragBeginEvent_.clientY;
598        this.dragBoxYEnd_ = e.clientY;
599        var vertical = e.shiftKey;
600        this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_,
601                                this.dragBoxXEnd_, this.dragBoxYEnd_, vertical);
602      }
603    },
604
605    onMouseUp_: function(e) {
606      var i;
607      if (this.dragBeginEvent_) {
608        // Stop the dragging.
609        this.hideDragBox_();
610        var eDown = this.dragBeginEvent_;
611        this.dragBeginEvent_ = null;
612
613        // Figure out extents of the drag.
614        var loY;
615        var hiY;
616        var loX = Math.min(eDown.clientX, e.clientX);
617        var hiX = Math.max(eDown.clientX, e.clientX);
618        var tracksContainer = this.modelTrackContainer_.getBoundingClientRect();
619        var topBoundary = tracksContainer.height;
620        var vertical = e.shiftKey;
621        if (vertical) {
622          var modelTrackRect = this.modelTrack_.getBoundingClientRect();
623          loY = modelTrackRect.top;
624          hiY = modelTrackRect.bottom;
625        } else {
626          loY = Math.min(eDown.clientY, e.clientY);
627          hiY = Math.max(eDown.clientY, e.clientY);
628        }
629
630        // Convert to worldspace.
631        var canv = this.firstCanvas;
632        var loVX = loX - canv.offsetLeft;
633        var hiVX = hiX - canv.offsetLeft;
634
635        // Figure out what has been hit.
636        var selection = new TimelineSelection();
637        this.modelTrack_.addIntersectingItemsInRangeToSelection(
638            loVX, hiVX, loY, hiY, selection);
639
640        // Activate the new selection, and zoom if ctrl key held down.
641        this.selection = selection;
642        var isMac = navigator.platform.indexOf('Mac') == 0;
643        if ((isMac && e.metaKey) || (!isMac && e.ctrlKey)) {
644          this.zoomToSelection_();
645        }
646      }
647    },
648
649    onDblClick_: function(e) {
650      var canv = this.firstCanvas;
651
652      var scale = 4;
653      if (e.shiftKey)
654        scale = 1 / scale;
655      this.zoomBy_(scale);
656      e.preventDefault();
657    }
658  };
659
660  /**
661   * The TimelineModel being viewed by the timeline
662   * @type {TimelineModel}
663   */
664  base.defineProperty(Timeline, 'model', base.PropertyKind.JS);
665
666  return {
667    Timeline: Timeline
668  };
669});
670