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