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