1// Copyright (c) 2011 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
6/**
7 * @fileoverview Interactive visualizaiton of TimelineModel objects
8 * based loosely on gantt charts. Each thread in the TimelineModel is given a
9 * set of TimelineTracks, one per subrow in the thread. The Timeline class
10 * acts as a controller, creating the individual tracks, while TimelineTracks
11 * do actual drawing.
12 *
13 * Visually, the Timeline produces (prettier) visualizations like the following:
14 *    Thread1:  AAAAAAAAAA         AAAAA
15 *                  BBBB              BB
16 *    Thread2:     CCCCCC                 CCCCC
17 *
18 */
19cr.define('gpu', function() {
20
21  /**
22   * The TimelineViewport manages the transform used for navigating
23   * within the timeline. It is a simple transform:
24   *   x' = (x+pan) * scale
25   *
26   * The timeline code tries to avoid directly accessing this transform,
27   * instead using this class to do conversion between world and view space,
28   * as well as the math for centering the viewport in various interesting
29   * ways.
30   *
31   * @constructor
32   * @extends {cr.EventTarget}
33   */
34  function TimelineViewport() {
35    this.scaleX_ = 1;
36    this.panX_ = 0;
37  }
38
39  TimelineViewport.prototype = {
40    __proto__: cr.EventTarget.prototype,
41
42    get scaleX() {
43      return this.scaleX_;
44    },
45    set scaleX(s) {
46      var changed = this.scaleX_ != s;
47      if (changed) {
48        this.scaleX_ = s;
49        cr.dispatchSimpleEvent(this, 'change');
50      }
51    },
52
53    get panX() {
54      return this.panX_;
55    },
56    set panX(p) {
57      var changed = this.panX_ != p;
58      if (changed) {
59        this.panX_ = p;
60        cr.dispatchSimpleEvent(this, 'change');
61      }
62    },
63
64    setPanAndScale: function(p, s) {
65      var changed = this.scaleX_ != s || this.panX_ != p;
66      if (changed) {
67        this.scaleX_ = s;
68        this.panX_ = p;
69        cr.dispatchSimpleEvent(this, 'change');
70      }
71    },
72
73    xWorldToView: function(x) {
74      return (x + this.panX_) * this.scaleX_;
75    },
76
77    xWorldVectorToView: function(x) {
78      return x * this.scaleX_;
79    },
80
81    xViewToWorld: function(x) {
82      return (x / this.scaleX_) - this.panX_;
83    },
84
85    xViewVectorToWorld: function(x) {
86      return x / this.scaleX_;
87    },
88
89    xPanWorldPosToViewPos: function(worldX, viewX, viewWidth) {
90      if (typeof viewX == 'string') {
91        if (viewX == 'left') {
92          viewX = 0;
93        } else if (viewX == 'center') {
94          viewX = viewWidth / 2;
95        } else if (viewX == 'right') {
96          viewX = viewWidth - 1;
97        } else {
98          throw Error('unrecognized string for viewPos. left|center|right');
99        }
100      }
101      this.panX = (viewX / this.scaleX_) - worldX;
102    },
103
104    applyTransformToCanavs: function(ctx) {
105      ctx.transform(this.scaleX_, 0, 0, 1, this.panX_ * this.scaleX_, 0);
106    }
107  };
108
109  /**
110   * Renders a TimelineModel into a div element, making one
111   * TimelineTrack for each subrow in each thread of the model, managing
112   * overall track layout, and handling user interaction with the
113   * viewport.
114   *
115   * @constructor
116   * @extends {HTMLDivElement}
117   */
118  Timeline = cr.ui.define('div');
119
120  Timeline.prototype = {
121    __proto__: HTMLDivElement.prototype,
122
123    model_: null,
124
125    decorate: function() {
126      this.classList.add('timeline');
127      this.needsViewportReset_ = false;
128
129      this.viewport_ = new TimelineViewport();
130      this.viewport_.addEventListener('change', this.invalidate.bind(this));
131
132      this.invalidatePending_ = false;
133
134      this.tracks_ = this.ownerDocument.createElement('div');
135      this.tracks_.invalidate = this.invalidate.bind(this);
136      this.appendChild(this.tracks_);
137
138      this.dragBox_ = this.ownerDocument.createElement('div');
139      this.dragBox_.className = 'timeline-drag-box';
140      this.appendChild(this.dragBox_);
141
142      // The following code uses a setInterval to monitor the timeline control
143      // for size changes. This is so that we can keep the canvas' bitmap size
144      // correctly synchronized with its presentation size.
145      // TODO(nduca): detect this in a more efficient way, e.g. iframe hack.
146      this.lastSize_ = this.clientWidth + 'x' + this.clientHeight;
147      this.ownerDocument.defaultView.setInterval(function() {
148        var curSize = this.clientWidth + 'x' + this.clientHeight;
149        if (this.clientWidth && curSize != this.lastSize_) {
150          this.lastSize_ = curSize;
151          this.onResize();
152        }
153      }.bind(this), 250);
154
155      document.addEventListener('keypress', this.onKeypress_.bind(this));
156      this.addEventListener('mousedown', this.onMouseDown_.bind(this));
157      this.addEventListener('mousemove', this.onMouseMove_.bind(this));
158      this.addEventListener('mouseup', this.onMouseUp_.bind(this));
159      this.lastMouseViewPos_ = {x: 0, y: 0};
160
161      this.selection_ = [];
162    },
163
164    get model() {
165      return this.model_;
166    },
167
168    set model(model) {
169      if (!model)
170        throw Error('Model cannot be null');
171      if (this.model) {
172        throw Error('Cannot set model twice.');
173      }
174      this.model_ = model;
175
176      // Create tracks.
177      this.tracks_.textContent = '';
178      var threads = model.getAllThreads();
179      for (var tI = 0; tI < threads.length; tI++) {
180        var thread = threads[tI];
181        var track = new TimelineThreadTrack();
182        track.thread = thread;
183        track.viewport = this.viewport_;
184        this.tracks_.appendChild(track);
185
186      }
187
188      this.needsViewportReset_ = true;
189    },
190
191    invalidate: function() {
192      if (this.invalidatePending_)
193        return;
194      this.invalidatePending_ = true;
195      window.setTimeout(function() {
196        this.invalidatePending_ = false;
197        this.redrawAllTracks_();
198      }.bind(this), 0);
199    },
200
201    onResize: function() {
202      for (var i = 0; i < this.tracks_.children.length; ++i) {
203        var track = this.tracks_.children[i];
204        track.onResize();
205      }
206    },
207
208    redrawAllTracks_: function() {
209      if (this.needsViewportReset_ && this.clientWidth != 0) {
210        this.needsViewportReset_ = false;
211        /* update viewport */
212        var rangeTimestamp = this.model_.maxTimestamp -
213            this.model_.minTimestamp;
214        var w = this.firstCanvas.width;
215        console.log('viewport was reset with w=', w);
216        var scaleX = w / rangeTimestamp;
217        var panX = -this.model_.minTimestamp;
218        this.viewport_.setPanAndScale(panX, scaleX);
219      }
220      for (var i = 0; i < this.tracks_.children.length; ++i) {
221        this.tracks_.children[i].redraw();
222      }
223    },
224
225    updateChildViewports_: function() {
226      for (var cI = 0; cI < this.tracks_.children.length; ++cI) {
227        var child = this.tracks_.children[cI];
228        child.setViewport(this.panX, this.scaleX);
229      }
230    },
231
232    onKeypress_: function(e) {
233      var vp = this.viewport_;
234      if (this.firstCanvas) {
235        var viewWidth = this.firstCanvas.clientWidth;
236        var curMouseV, curCenterW;
237        switch (event.keyCode) {
238          case 101: // e
239            var vX = this.lastMouseViewPos_.x;
240            var wX = vp.xViewToWorld(this.lastMouseViewPos_.x);
241            var distFromCenter = vX - (viewWidth / 2);
242            var percFromCenter = distFromCenter / viewWidth;
243            var percFromCenterSq = percFromCenter * percFromCenter;
244            vp.xPanWorldPosToViewPos(wX, 'center', viewWidth);
245            break;
246          case 119:  // w
247            curMouseV = this.lastMouseViewPos_.x;
248            curCenterW = vp.xViewToWorld(curMouseV);
249            vp.scaleX = vp.scaleX * 1.5;
250            vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth);
251            break;
252          case 115:  // s
253            curMouseV = this.lastMouseViewPos_.x;
254            curCenterW = vp.xViewToWorld(curMouseV);
255            vp.scaleX = vp.scaleX / 1.5;
256            vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth);
257            break;
258          case 87:  // W
259            curMouseV = this.lastMouseViewPos_.x;
260            curCenterW = vp.xViewToWorld(curMouseV);
261            vp.scaleX = vp.scaleX * 10;
262            vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth);
263            break;
264          case 83:  // S
265            curMouseV = this.lastMouseViewPos_.x;
266            curCenterW = vp.xViewToWorld(curMouseV);
267            vp.scaleX = vp.scaleX / 10;
268            vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth);
269            break;
270          case 97:  // a
271            vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1);
272            break;
273          case 100:  // d
274            vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1);
275            break;
276          case 65:  // A
277            vp.panX += vp.xViewVectorToWorld(viewWidth * 0.5);
278            break;
279          case 68:  // D
280            vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.5);
281            break;
282        }
283      }
284    },
285
286    get keyHelp() {
287      return 'Keyboard shortcuts:\n' +
288          ' w/s   : Zoom in/out\n' +
289          ' a/d   : Pan left/right\n' +
290          ' e     : Center on mouse';
291    },
292
293    get selection() {
294      return this.selection_;
295    },
296
297    get firstCanvas() {
298      return this.tracks_.firstChild ?
299          this.tracks_.firstChild.firstCanvas : undefined;
300    },
301
302    showDragBox_: function() {
303      this.dragBox_.hidden = false;
304    },
305
306    hideDragBox_: function() {
307      this.dragBox_.hidden = true;
308    },
309
310    setDragBoxPosition_: function(eDown, eCur) {
311      var loX = Math.min(eDown.clientX, eCur.clientX);
312      var hiX = Math.max(eDown.clientX, eCur.clientX);
313      var loY = Math.min(eDown.clientY, eCur.clientY);
314      var hiY = Math.max(eDown.clientY, eCur.clientY);
315
316      this.dragBox_.style.left = loX + 'px';
317      this.dragBox_.style.top = loY + 'px';
318      this.dragBox_.style.width = hiX - loX + 'px';
319      this.dragBox_.style.height = hiY - loY + 'px';
320
321      var canv = this.firstCanvas;
322      var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft);
323      var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft);
324
325      var roundedDuration = Math.round((hiWX - loWX) * 100) / 100;
326      this.dragBox_.textContent = roundedDuration + 'ms';
327
328      var e = new cr.Event('selectionChanging');
329      e.loWX = loWX;
330      e.hiWX = hiWX;
331      this.dispatchEvent(e);
332    },
333
334    onMouseDown_: function(e) {
335      var canv = this.firstCanvas;
336      var pos = {
337        x: e.clientX - canv.offsetLeft,
338        y: e.clientY - canv.offsetTop
339      };
340      var wX = this.viewport_.xViewToWorld(pos.x);
341
342      // Update the drag box position
343      this.showDragBox_();
344      this.setDragBoxPosition_(e, e);
345      this.dragBeginEvent_ = e;
346      e.preventDefault();
347    },
348
349    onMouseMove_: function(e) {
350      if (!this.firstCanvas)
351        return;
352      var canv = this.firstCanvas;
353      var pos = {
354        x: e.clientX - canv.offsetLeft,
355        y: e.clientY - canv.offsetTop
356      };
357
358      // Remember position. Used during keyboard zooming.
359      this.lastMouseViewPos_ = pos;
360
361      // Update the drag box
362      if (this.dragBeginEvent_) {
363        this.setDragBoxPosition_(this.dragBeginEvent_, e);
364      }
365    },
366
367    onMouseUp_: function(e) {
368      var i;
369      if (this.dragBeginEvent_) {
370        // Stop the dragging.
371        this.hideDragBox_();
372        var eDown = this.dragBeginEvent_;
373        this.dragBeginEvent_ = null;
374
375        // Figure out extents of the drag.
376        var loX = Math.min(eDown.clientX, e.clientX);
377        var hiX = Math.max(eDown.clientX, e.clientX);
378        var loY = Math.min(eDown.clientY, e.clientY);
379        var hiY = Math.max(eDown.clientY, e.clientY);
380
381        // Convert to worldspace.
382        var canv = this.firstCanvas;
383        var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft);
384        var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft);
385
386        // Clear old selection.
387        for (i = 0; i < this.selection_.length; ++i) {
388          this.selection_[i].slice.selected = false;
389        }
390        // Figure out what has been hit.
391        var selection = [];
392        function addHit(type, track, slice) {
393          selection.push({track: track, slice: slice});
394        }
395        for (i = 0; i < this.tracks_.children.length; ++i) {
396          var track = this.tracks_.children[i];
397
398          // Only check tracks that insersect the rect.
399          var a = Math.max(loY, track.offsetTop);
400          var b = Math.min(hiY, track.offsetTop + track.offsetHeight);
401          if (a <= b) {
402            track.pickRange(loWX, hiWX, loY, hiY, addHit);
403          }
404        }
405        // Activate the new selection.
406        this.selection_ = selection;
407        cr.dispatchSimpleEvent(this, 'selectionChange');
408        for (i = 0; i < this.selection_.length; ++i) {
409          this.selection_[i].slice.selected = true;
410        }
411        this.invalidate();  // Cause tracks to redraw.
412      }
413    }
414  };
415
416  /**
417   * The TimelineModel being viewed by the timeline
418   * @type {TimelineModel}
419   */
420  cr.defineProperty(Timeline, 'model', cr.PropertyKind.JS);
421
422  return {
423    Timeline: Timeline
424  };
425});
426