// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * @fileoverview Interactive visualizaiton of Model objects * based loosely on gantt charts. Each thread in the Model is given a * set of Tracks, one per subrow in the thread. The TimelineTrackView class * acts as a controller, creating the individual tracks, while Tracks * do actual drawing. * * Visually, the TimelineTrackView produces (prettier) visualizations like the * following: * Thread1: AAAAAAAAAA AAAAA * BBBB BB * Thread2: CCCCCC CCCCC * */ base.requireStylesheet('timeline_track_view'); base.require('event_target'); base.require('measuring_stick'); base.require('filter'); base.require('selection'); base.require('timeline_viewport'); base.require('tracks.model_track'); base.require('tracks.ruler_track'); base.require('ui'); base.exportTo('tracing', function() { var Selection = tracing.Selection; var Viewport = tracing.TimelineViewport; function intersectRect_(r1, r2) { var results = new Object; if (r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top) { return false; } results.left = Math.max(r1.left, r2.left); results.top = Math.max(r1.top, r2.top); results.right = Math.min(r1.right, r2.right); results.bottom = Math.min(r1.bottom, r2.bottom); results.width = (results.right - results.left); results.height = (results.bottom - results.top); return results; } /** * Renders a Model into a div element, making one * Track for each subrow in each thread of the model, managing * overall track layout, and handling user interaction with the * viewport. * * @constructor * @extends {HTMLDivElement} */ var TimelineTrackView = tracing.ui.define('div'); TimelineTrackView.prototype = { __proto__: HTMLDivElement.prototype, model_: null, decorate: function() { this.classList.add('timeline-track-view'); this.categoryFilter_ = new tracing.CategoryFilter(); this.viewport_ = new Viewport(this); // Add the viewport track. this.rulerTrack_ = new tracing.tracks.RulerTrack(); this.rulerTrack_.viewport = this.viewport_; this.appendChild(this.rulerTrack_); this.modelTrackContainer_ = document.createElement('div'); this.modelTrackContainer_.className = 'model-track-container'; this.appendChild(this.modelTrackContainer_); this.modelTrack_ = new tracing.tracks.ModelTrack(); this.modelTrackContainer_.appendChild(this.modelTrack_); this.dragBox_ = this.ownerDocument.createElement('div'); this.dragBox_.className = 'drag-box'; this.appendChild(this.dragBox_); this.hideDragBox_(); this.bindEventListener_(document, 'keypress', this.onKeypress_, this); this.bindEventListener_(document, 'keydown', this.onKeydown_, this); this.bindEventListener_(document, 'keyup', this.onKeyup_, this); this.bindEventListener_(document, 'mousemove', this.onMouseMove_, this); this.bindEventListener_(document, 'mouseup', this.onMouseUp_, this); this.addEventListener('mousewheel', this.onMouseWheel_); this.addEventListener('mousedown', this.onMouseDown_); this.addEventListener('dblclick', this.onDblClick_); this.lastMouseViewPos_ = {x: 0, y: 0}; this.maxHeadingWidth_ = 0; this.selection_ = new Selection(); }, /** * Wraps the standard addEventListener but automatically binds the provided * func to the provided target, tracking the resulting closure. When detach * is called, these listeners will be automatically removed. */ bindEventListener_: function(object, event, func, target) { if (!this.boundListeners_) this.boundListeners_ = []; var boundFunc = func.bind(target); this.boundListeners_.push({object: object, event: event, boundFunc: boundFunc}); object.addEventListener(event, boundFunc); }, detach: function() { this.modelTrack_.detach(); for (var i = 0; i < this.boundListeners_.length; i++) { var binding = this.boundListeners_[i]; binding.object.removeEventListener(binding.event, binding.boundFunc); } this.boundListeners_ = undefined; this.viewport_.detach(); }, get viewport() { return this.viewport_; }, get categoryFilter() { return this.categoryFilter_; }, set categoryFilter(filter) { this.categoryFilter_ = filter; this.modelTrack_.categoryFilter = filter; }, get model() { return this.model_; }, set model(model) { if (!model) throw new Error('Model cannot be null'); var modelInstanceChanged = this.model_ != model; this.model_ = model; this.modelTrack_.model = model; this.modelTrack_.viewport = this.viewport_; this.modelTrack_.categoryFilter = this.categoryFilter; this.rulerTrack_.headingWidth = this.modelTrack_.headingWidth; // Set up a reasonable viewport. if (modelInstanceChanged) this.viewport_.setWhenPossible(this.setInitialViewport_.bind(this)); }, get numVisibleTracks() { return this.modelTrack_.numVisibleTracks; }, setInitialViewport_: function() { var w = this.firstCanvas.width; var boost = (this.model_.bounds.max - this.model_.bounds.min) * 0.15; this.viewport_.xSetWorldBounds(this.model_.bounds.min - boost, this.model_.bounds.max + boost, w); }, /** * @param {Filter} filter The filter to use for finding matches. * @param {Selection} selection The selection to add matches to. * @return {Array} An array of objects that match the provided * TitleFilter. */ addAllObjectsMatchingFilterToSelection: function(filter, selection) { this.modelTrack_.addAllObjectsMatchingFilterToSelection(filter, selection); }, /** * @return {Element} The element whose focused state determines * whether to respond to keyboard inputs. * Defaults to the parent element. */ get focusElement() { if (this.focusElement_) return this.focusElement_; return this.parentElement; }, /** * Sets the element whose focus state will determine whether * to respond to keybaord input. */ set focusElement(value) { this.focusElement_ = value; }, get listenToKeys_() { if (!this.viewport_.isAttachedToDocument_) return false; if (this.activeElement instanceof tracing.FindControl) return false; if (!this.focusElement_) return true; if (this.focusElement.tabIndex >= 0) return document.activeElement == this.focusElement; return true; }, onKeypress_: function(e) { var vp = this.viewport_; if (!this.firstCanvas) return; if (!this.listenToKeys_) return; if (document.activeElement.nodeName == 'INPUT') return; var viewWidth = this.firstCanvas.clientWidth; var curMouseV, curCenterW; switch (e.keyCode) { case 119: // w case 44: // , this.zoomBy_(1.5); break; case 115: // s case 111: // o this.zoomBy_(1 / 1.5); break; case 103: // g this.onGridToggle_(true); break; case 71: // G this.onGridToggle_(false); break; case 87: // W case 60: // < this.zoomBy_(10); break; case 83: // S case 79: // O this.zoomBy_(1 / 10); break; case 97: // a vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1); break; case 100: // d case 101: // e vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1); break; case 65: // A vp.panX += vp.xViewVectorToWorld(viewWidth * 0.5); break; case 68: // D vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.5); break; case 48: // 0 case 122: // z this.setInitialViewport_(); break; case 102: // f this.zoomToSelection_(); break; } }, onMouseWheel_: function(e) { if (e.altKey) { var delta = e.wheelDeltaY / 120; var zoomScale = Math.pow(1.5, delta); this.zoomBy_(zoomScale); e.preventDefault(); } }, // Not all keys send a keypress. onKeydown_: function(e) { if (!this.listenToKeys_) return; var sel; var vp = this.viewport_; var viewWidth = this.firstCanvas.clientWidth; switch (e.keyCode) { case 37: // left arrow sel = this.selection.getShiftedSelection(-1); if (sel) { this.setSelectionAndMakeVisible(sel); e.preventDefault(); } else { if (!this.firstCanvas) return; vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1); } break; case 39: // right arrow sel = this.selection.getShiftedSelection(1); if (sel) { this.setSelectionAndMakeVisible(sel); e.preventDefault(); } else { if (!this.firstCanvas) return; vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1); } break; case 9: // TAB if (this.focusElement.tabIndex == -1) { if (e.shiftKey) this.selectPrevious_(e); else this.selectNext_(e); e.preventDefault(); } break; } if (e.shiftKey && this.dragBeginEvent_) { var vertical = e.shiftKey; if (this.dragBeginEvent_) { this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_, this.dragBoxXEnd_, this.dragBoxYEnd_, vertical); } } }, onKeyup_: function(e) { if (!this.listenToKeys_) return; if (!e.shiftKey) { if (this.dragBeginEvent_) { var vertical = e.shiftKey; this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_, this.dragBoxXEnd_, this.dragBoxYEnd_, vertical); } } }, /** * Zoom in or out on the timeline by the given scale factor. * @param {integer} scale The scale factor to apply. If <1, zooms out. */ zoomBy_: function(scale) { if (!this.firstCanvas) return; var vp = this.viewport_; var viewWidth = this.firstCanvas.clientWidth; var pixelRatio = window.devicePixelRatio || 1; var curMouseV = this.lastMouseViewPos_.x * pixelRatio; var curCenterW = vp.xViewToWorld(curMouseV); vp.scaleX = vp.scaleX * scale; vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); }, /** * Zoom into the current selection. */ zoomToSelection_: function() { if (!this.selection) return; var bounds = this.selection.bounds; var worldCenter = bounds.min + (bounds.max - bounds.min) * 0.5; var worldBounds = (bounds.max - bounds.min) * 0.5; var boost = worldBounds * 0.15; this.viewport_.xSetWorldBounds(worldCenter - worldBounds - boost, worldCenter + worldBounds + boost, this.firstCanvas.width); }, get keyHelp() { var mod = navigator.platform.indexOf('Mac') == 0 ? 'cmd' : 'ctrl'; var help = 'Qwerty Controls\n' + ' w/s : Zoom in/out (with shift: go faster)\n' + ' a/d : Pan left/right\n\n' + 'Dvorak Controls\n' + ' ,/o : Zoom in/out (with shift: go faster)\n' + ' a/e : Pan left/right\n\n' + 'Mouse Controls\n' + ' drag : Select slices (with ' + mod + ': zoom to slices)\n' + ' drag + shift : Select all slices vertically\n\n'; if (this.focusElement.tabIndex) { help += ' <- : Select previous event on current timeline\n' + ' -> : Select next event on current timeline\n'; } else { help += 'General Navigation\n' + ' g/General : Shows grid at the start/end of the selected' + ' task\n' + ' <-,^TAB : Select previous event on current timeline\n' + ' ->, TAB : Select next event on current timeline\n'; } help += '\n' + 'Alt + Scroll to zoom in/out\n' + 'Dbl-click to zoom in; Shift dbl-click to zoom out\n' + 'f to zoom into selection\n' + 'z to reset zoom and pan to initial view\n'; return help; }, get selection() { return this.selection_; }, set selection(selection) { if (!(selection instanceof Selection)) throw new Error('Expected Selection'); // Clear old selection. var i; for (i = 0; i < this.selection_.length; i++) this.selection_[i].selected = false; this.selection_ = selection; base.dispatchSimpleEvent(this, 'selectionChange'); for (i = 0; i < this.selection_.length; i++) this.selection_[i].selected = true; this.viewport_.dispatchChangeEvent(); // Triggers a redraw. }, setSelectionAndMakeVisible: function(selection, zoomAllowed) { if (!(selection instanceof Selection)) throw new Error('Expected Selection'); this.selection = selection; var bounds = this.selection.bounds; var size = this.viewport_.xWorldVectorToView(bounds.max - bounds.min); if (zoomAllowed && size < 50) { var worldCenter = bounds.min + (bounds.max - bounds.min) * 0.5; var worldBounds = (bounds.max - bounds.min) * 5; this.viewport_.xSetWorldBounds(worldCenter - worldBounds * 0.5, worldCenter + worldBounds * 0.5, this.firstCanvas.width); return; } this.viewport_.xPanWorldBoundsIntoView(bounds.min, bounds.max, this.firstCanvas.width); }, get firstCanvas() { if (this.rulerTrack_) return this.rulerTrack_.firstCanvas; if (this.modelTrack_) return this.modelTrack_.firstCanvas; return undefined; }, hideDragBox_: function() { this.dragBox_.style.left = '-1000px'; this.dragBox_.style.top = '-1000px'; this.dragBox_.style.width = 0; this.dragBox_.style.height = 0; }, setDragBoxPosition_: function(xStart, yStart, xEnd, yEnd, vertical) { var loY; var hiY; var loX = Math.min(xStart, xEnd); var hiX = Math.max(xStart, xEnd); var modelTrackRect = this.modelTrack_.getBoundingClientRect(); if (vertical) { loY = modelTrackRect.top; hiY = modelTrackRect.bottom; } else { loY = Math.min(yStart, yEnd); hiY = Math.max(yStart, yEnd); } var dragRect = {left: loX, top: loY, width: hiX - loX, height: hiY - loY}; dragRect.right = dragRect.left + dragRect.width; dragRect.bottom = dragRect.top + dragRect.height; var modelTrackContainerRect = this.modelTrackContainer_.getBoundingClientRect(); var clipRect = { left: modelTrackContainerRect.left, top: modelTrackContainerRect.top, right: modelTrackContainerRect.right, bottom: modelTrackContainerRect.bottom, }; var trackTitleWidth = parseInt(this.modelTrack_.headingWidth); clipRect.left = clipRect.left + trackTitleWidth; var finalDragBox = intersectRect_(clipRect, dragRect); this.dragBox_.style.left = finalDragBox.left + 'px'; this.dragBox_.style.width = finalDragBox.width + 'px'; this.dragBox_.style.top = finalDragBox.top + 'px'; this.dragBox_.style.height = finalDragBox.height + 'px'; var pixelRatio = window.devicePixelRatio || 1; var canv = this.firstCanvas; var loWX = this.viewport_.xViewToWorld( (loX - canv.offsetLeft) * pixelRatio); var hiWX = this.viewport_.xViewToWorld( (hiX - canv.offsetLeft) * pixelRatio); var roundedDuration = Math.round((hiWX - loWX) * 100) / 100; this.dragBox_.textContent = roundedDuration + 'ms'; var e = new base.Event('selectionChanging'); e.loWX = loWX; e.hiWX = hiWX; this.dispatchEvent(e); }, onGridToggle_: function(left) { var tb; if (left) tb = this.selection_.bounds.min; else tb = this.selection_.bounds.max; // Shift the timebase left until its just left of model_.bounds.min. var numInterfvalsSinceStart = Math.ceil((tb - this.model_.bounds.min) / this.viewport_.gridStep_); this.viewport_.gridTimebase = tb - (numInterfvalsSinceStart + 1) * this.viewport_.gridStep_; this.viewport_.gridEnabled = true; }, isChildOfThis_: function(el) { if (el == this) return; var isChildOfThis = false; var cur = el; while (cur.parentNode) { if (cur == this) return true; cur = cur.parentNode; } return false; }, onMouseDown_: function(e) { if (e.button !== 0) return; if (e.shiftKey) { this.rulerTrack_.placeAndBeginDraggingMarker(e.clientX); return; } var canv = this.firstCanvas; var rect = this.modelTrack_.getBoundingClientRect(); var canvRect = this.firstCanvas.getBoundingClientRect(); var inside = rect && e.clientX >= rect.left && e.clientX < rect.right && e.clientY >= rect.top && e.clientY < rect.bottom && e.clientX >= canvRect.left && e.clientX < canvRect.right; if (!inside) return; var pos = { x: e.clientX - canv.offsetLeft, y: e.clientY - canv.offsetTop }; var wX = this.viewport_.xViewToWorld(pos.x); this.dragBeginEvent_ = e; e.preventDefault(); if (document.activeElement) document.activeElement.blur(); if (this.focusElement.tabIndex >= 0) this.focusElement.focus(); }, onMouseMove_: function(e) { if (!this.firstCanvas) return; var canv = this.firstCanvas; var pos = { x: e.clientX - canv.offsetLeft, y: e.clientY - canv.offsetTop }; // Remember position. Used during keyboard zooming. this.lastMouseViewPos_ = pos; // Update the drag box if (this.dragBeginEvent_) { this.dragBoxXStart_ = this.dragBeginEvent_.clientX; this.dragBoxXEnd_ = e.clientX; this.dragBoxYStart_ = this.dragBeginEvent_.clientY; this.dragBoxYEnd_ = e.clientY; var vertical = e.shiftKey; this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_, this.dragBoxXEnd_, this.dragBoxYEnd_, vertical); } }, onMouseUp_: function(e) { var i; if (this.dragBeginEvent_) { // Stop the dragging. this.hideDragBox_(); var eDown = this.dragBeginEvent_; this.dragBeginEvent_ = null; // Figure out extents of the drag. var loY; var hiY; var loX = Math.min(eDown.clientX, e.clientX); var hiX = Math.max(eDown.clientX, e.clientX); var tracksContainer = this.modelTrackContainer_.getBoundingClientRect(); var topBoundary = tracksContainer.height; var vertical = e.shiftKey; if (vertical) { var modelTrackRect = this.modelTrack_.getBoundingClientRect(); loY = modelTrackRect.top; hiY = modelTrackRect.bottom; } else { loY = Math.min(eDown.clientY, e.clientY); hiY = Math.max(eDown.clientY, e.clientY); } // Convert to worldspace. var canv = this.firstCanvas; var loVX = loX - canv.offsetLeft; var hiVX = hiX - canv.offsetLeft; // Figure out what has been hit. var selection = new Selection(); this.modelTrack_.addIntersectingItemsInRangeToSelection( loVX, hiVX, loY, hiY, selection); // Activate the new selection, and zoom if ctrl key held down. this.selection = selection; if ((base.isMac && e.metaKey) || (!base.isMac && e.ctrlKey)) { this.zoomToSelection_(); } } }, onDblClick_: function(e) { var modelTrackContainerRect = this.modelTrackContainer_.getBoundingClientRect(); var clipBounds = { left: modelTrackContainerRect.left, right: modelTrackContainerRect.right, }; var trackTitleWidth = parseInt(this.modelTrack_.headingWidth); clipBounds.left = clipBounds.left + trackTitleWidth; if (e.clientX < clipBounds.left || e.clientX > clipBounds.right) return; var canv = this.firstCanvas; var scale = 4; if (e.shiftKey) scale = 1 / scale; this.zoomBy_(scale); e.preventDefault(); } }; /** * The Model being viewed by the timeline * @type {Model} */ base.defineProperty(TimelineTrackView, 'model', base.PropertyKind.JS); return { TimelineTrackView: TimelineTrackView }; });