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