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 Code for the timeline viewport.
9 */
10base.require('event_target');
11
12base.exportTo('tracing', function() {
13
14  /**
15   * The TimelineViewport manages the transform used for navigating
16   * within the timeline. It is a simple transform:
17   *   x' = (x+pan) * scale
18   *
19   * The timeline code tries to avoid directly accessing this transform,
20   * instead using this class to do conversion between world and viewspace,
21   * as well as the math for centering the viewport in various interesting
22   * ways.
23   *
24   * @constructor
25   * @extends {base.EventTarget}
26   */
27  function TimelineViewport(parentEl) {
28    this.parentEl_ = parentEl;
29    this.scaleX_ = 1;
30    this.panX_ = 0;
31    this.gridTimebase_ = 0;
32    this.gridStep_ = 1000 / 60;
33    this.gridEnabled_ = false;
34    this.hasCalledSetupFunction_ = false;
35
36    this.onResizeBoundToThis_ = this.onResize_.bind(this);
37
38    // The following code uses an interval to detect when the parent element
39    // is attached to the document. That is a trigger to run the setup function
40    // and install a resize listener.
41    this.checkForAttachInterval_ = setInterval(
42        this.checkForAttach_.bind(this), 250);
43
44    this.markers = [];
45  }
46
47  TimelineViewport.prototype = {
48    __proto__: base.EventTarget.prototype,
49
50    drawUnderContent: function(ctx, viewLWorld, viewRWorld, canvasH) {
51    },
52
53    drawOverContent: function(ctx, viewLWorld, viewRWorld, canvasH) {
54      if (this.gridEnabled) {
55        var x = this.gridTimebase;
56
57        ctx.beginPath();
58        while (x < viewRWorld) {
59          if (x >= viewLWorld) {
60            // Do conversion to viewspace here rather than on
61            // x to avoid precision issues.
62            var vx = this.xWorldToView(x);
63            ctx.moveTo(vx, 0);
64            ctx.lineTo(vx, canvasH);
65          }
66          x += this.gridStep;
67        }
68        ctx.strokeStyle = 'rgba(255,0,0,0.25)';
69        ctx.stroke();
70      }
71
72      for (var i = 0; i < this.markers.length; ++i) {
73        this.markers[i].drawLine(ctx, viewLWorld, viewRWorld, canvasH, this);
74      }
75    },
76
77    /**
78     * Allows initialization of the viewport when the viewport's parent element
79     * has been attached to the document and given a size.
80     * @param {Function} fn Function to call when the viewport can be safely
81     * initialized.
82     */
83    setWhenPossible: function(fn) {
84      this.pendingSetFunction_ = fn;
85    },
86
87    /**
88     * @return {boolean} Whether the current timeline is attached to the
89     * document.
90     */
91    get isAttachedToDocument_() {
92      var cur = this.parentEl_;
93      while (cur.parentNode)
94        cur = cur.parentNode;
95      return cur == this.parentEl_.ownerDocument;
96    },
97
98    onResize_: function() {
99      this.dispatchChangeEvent();
100    },
101
102    /**
103     * Checks whether the parentNode is attached to the document.
104     * When it is, it installs the iframe-based resize detection hook
105     * and then runs the pendingSetFunction_, if present.
106     */
107    checkForAttach_: function() {
108      if (!this.isAttachedToDocument_ || this.clientWidth == 0)
109        return;
110
111      if (!this.iframe_) {
112        this.iframe_ = document.createElement('iframe');
113        this.iframe_.style.cssText =
114            'position:absolute;width:100%;height:0;border:0;visibility:hidden;';
115        this.parentEl_.appendChild(this.iframe_);
116
117        this.iframe_.contentWindow.addEventListener('resize',
118                                                    this.onResizeBoundToThis_);
119      }
120
121      var curSize = this.clientWidth + 'x' + this.clientHeight;
122      if (this.pendingSetFunction_) {
123        this.lastSize_ = curSize;
124        try {
125          this.pendingSetFunction_();
126        } catch (ex) {
127          console.log('While running setWhenPossible:', ex);
128        }
129        this.pendingSetFunction_ = undefined;
130      }
131
132      window.clearInterval(this.checkForAttachInterval_);
133      this.checkForAttachInterval_ = undefined;
134    },
135
136    /**
137     * Fires the change event on this viewport. Used to notify listeners
138     * to redraw when the underlying model has been mutated.
139     */
140    dispatchChangeEvent: function() {
141      base.dispatchSimpleEvent(this, 'change');
142    },
143
144    dispatchMarkersChangeEvent_: function() {
145      base.dispatchSimpleEvent(this, 'markersChange');
146    },
147
148    detach: function() {
149      if (this.checkForAttachInterval_) {
150        window.clearInterval(this.checkForAttachInterval_);
151        this.checkForAttachInterval_ = undefined;
152      }
153      if (this.iframe_) {
154        this.iframe_.removeEventListener('resize', this.onResizeBoundToThis_);
155        this.parentEl_.removeChild(this.iframe_);
156      }
157    },
158
159    get scaleX() {
160      return this.scaleX_;
161    },
162    set scaleX(s) {
163      var changed = this.scaleX_ != s;
164      if (changed) {
165        this.scaleX_ = s;
166        this.dispatchChangeEvent();
167      }
168    },
169
170    get panX() {
171      return this.panX_;
172    },
173    set panX(p) {
174      var changed = this.panX_ != p;
175      if (changed) {
176        this.panX_ = p;
177        this.dispatchChangeEvent();
178      }
179    },
180
181    setPanAndScale: function(p, s) {
182      var changed = this.scaleX_ != s || this.panX_ != p;
183      if (changed) {
184        this.scaleX_ = s;
185        this.panX_ = p;
186        this.dispatchChangeEvent();
187      }
188    },
189
190    xWorldToView: function(x) {
191      return (x + this.panX_) * this.scaleX_;
192    },
193
194    xWorldVectorToView: function(x) {
195      return x * this.scaleX_;
196    },
197
198    xViewToWorld: function(x) {
199      return (x / this.scaleX_) - this.panX_;
200    },
201
202    xViewVectorToWorld: function(x) {
203      return x / this.scaleX_;
204    },
205
206    xPanWorldPosToViewPos: function(worldX, viewX, viewWidth) {
207      if (typeof viewX == 'string') {
208        if (viewX == 'left') {
209          viewX = 0;
210        } else if (viewX == 'center') {
211          viewX = viewWidth / 2;
212        } else if (viewX == 'right') {
213          viewX = viewWidth - 1;
214        } else {
215          throw new Error('unrecognized string for viewPos. left|center|right');
216        }
217      }
218      this.panX = (viewX / this.scaleX_) - worldX;
219    },
220
221    xPanWorldRangeIntoView: function(worldMin, worldMax, viewWidth) {
222      if (this.xWorldToView(worldMin) < 0)
223        this.xPanWorldPosToViewPos(worldMin, 'left', viewWidth);
224      else if (this.xWorldToView(worldMax) > viewWidth)
225        this.xPanWorldPosToViewPos(worldMax, 'right', viewWidth);
226    },
227
228    xSetWorldRange: function(worldMin, worldMax, viewWidth) {
229      var worldRange = worldMax - worldMin;
230      var scaleX = viewWidth / worldRange;
231      var panX = -worldMin;
232      this.setPanAndScale(panX, scaleX);
233    },
234
235    get gridEnabled() {
236      return this.gridEnabled_;
237    },
238
239    set gridEnabled(enabled) {
240      if (this.gridEnabled_ == enabled)
241        return;
242      this.gridEnabled_ = enabled && true;
243      this.dispatchChangeEvent();
244    },
245
246    get gridTimebase() {
247      return this.gridTimebase_;
248    },
249
250    set gridTimebase(timebase) {
251      if (this.gridTimebase_ == timebase)
252        return;
253      this.gridTimebase_ = timebase;
254      base.dispatchSimpleEvent(this, 'change');
255    },
256
257    get gridStep() {
258      return this.gridStep_;
259    },
260
261    applyTransformToCanvas: function(ctx) {
262      ctx.transform(this.scaleX_, 0, 0, 1, this.panX_ * this.scaleX_, 0);
263    },
264
265    addMarker: function(positionWorld) {
266      var marker = new TimelineViewportMarker(this, positionWorld);
267      this.markers.push(marker);
268      this.dispatchChangeEvent();
269      this.dispatchMarkersChangeEvent_();
270      return marker;
271    },
272
273    removeMarker: function(marker) {
274      for (var i = 0; i < this.markers.length; ++i) {
275        if (this.markers[i] === marker) {
276          this.markers.splice(i, 1);
277          this.dispatchChangeEvent();
278          this.dispatchMarkersChangeEvent_();
279          return true;
280        }
281      }
282    },
283
284    findMarkerNear: function(positionWorld, nearnessInViewPixels) {
285      // Converts pixels into distance in world.
286      var nearnessThresholdWorld = this.xViewVectorToWorld(
287          nearnessInViewPixels);
288      for (var i = 0; i < this.markers.length; ++i) {
289        if (Math.abs(this.markers[i].positionWorld - positionWorld) <=
290            nearnessThresholdWorld) {
291          var marker = this.markers[i];
292          return marker;
293        }
294      }
295      return undefined;
296    }
297  };
298
299  /**
300   * Represents a marked position in the world, at a viewport level.
301   * @constructor
302   */
303  function TimelineViewportMarker(vp, positionWorld) {
304    this.viewport_ = vp;
305    this.positionWorld_ = positionWorld;
306    this.selected_ = false;
307  }
308
309  TimelineViewportMarker.prototype = {
310    get positionWorld() {
311      return this.positionWorld_;
312    },
313
314    set positionWorld(positionWorld) {
315      this.positionWorld_ = positionWorld;
316      this.viewport_.dispatchChangeEvent();
317    },
318
319    set selected(selected) {
320      this.selected_ = selected;
321      this.viewport_.dispatchChangeEvent();
322    },
323
324    get selected() {
325      return this.selected_;
326    },
327
328    get color() {
329      if (this.selected)
330        return 'rgb(255,0,0)';
331      return 'rgb(0,0,0)';
332    },
333
334    drawTriangle_: function(ctx, viewLWorld, viewRWorld,
335                            canvasH, rulerHeight, vp) {
336      ctx.beginPath();
337      var ts = this.positionWorld_;
338      if (ts >= viewLWorld && ts < viewRWorld) {
339        var viewX = vp.xWorldToView(ts);
340        ctx.moveTo(viewX, rulerHeight);
341        ctx.lineTo(viewX - 3, rulerHeight / 2);
342        ctx.lineTo(viewX + 3, rulerHeight / 2);
343        ctx.lineTo(viewX, rulerHeight);
344        ctx.closePath();
345        ctx.fillStyle = this.color;
346        ctx.fill();
347        if (rulerHeight != canvasH) {
348          ctx.beginPath();
349          ctx.moveTo(viewX, rulerHeight);
350          ctx.lineTo(viewX, canvasH);
351          ctx.closePath();
352          ctx.strokeStyle = this.color;
353          ctx.stroke();
354        }
355      }
356    },
357
358    drawLine: function(ctx, viewLWorld, viewRWorld, canvasH, vp) {
359      ctx.beginPath();
360      var ts = this.positionWorld_;
361      if (ts >= viewLWorld && ts < viewRWorld) {
362        var viewX = vp.xWorldToView(ts);
363        ctx.moveTo(viewX, 0);
364        ctx.lineTo(viewX, canvasH);
365      }
366      ctx.strokeStyle = this.color;
367      ctx.stroke();
368    }
369  };
370
371  return {
372    TimelineViewport: TimelineViewport,
373    TimelineViewportMarker: TimelineViewportMarker
374  };
375});
376