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 */ 10 base.require('base.events'); 11 12 base.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