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