// 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 TraceModel objects * based loosely on gantt charts. Each thread in the TraceModel 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('tracing.timeline_track_view'); base.require('base.events'); base.require('base.properties'); base.require('base.settings'); base.require('tracing.filter'); base.require('tracing.selection'); base.require('tracing.timeline_viewport'); base.require('tracing.mouse_mode_constants'); base.require('tracing.tracks.drawing_container'); base.require('tracing.tracks.trace_model_track'); base.require('tracing.tracks.ruler_track'); base.require('ui'); base.require('ui.mouse_mode_selector'); base.exportTo('tracing', function() { var Selection = tracing.Selection; var Viewport = tracing.TimelineViewport; var MIN_SELECTION_DISTANCE = 4; 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 TraceModel 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 = 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); this.viewportStateAtMouseDown_ = null; this.rulerTrackContainer_ = new tracing.tracks.DrawingContainer(this.viewport_); this.appendChild(this.rulerTrackContainer_); this.rulerTrackContainer_.invalidate(); this.rulerTrack_ = new tracing.tracks.RulerTrack(this.viewport_); this.rulerTrackContainer_.appendChild(this.rulerTrack_); this.modelTrackContainer_ = new tracing.tracks.DrawingContainer(this.viewport_); this.appendChild(this.modelTrackContainer_); this.modelTrackContainer_.style.display = 'block'; this.modelTrackContainer_.invalidate(); this.viewport_.modelTrackContainer = this.modelTrackContainer_; this.modelTrack_ = new tracing.tracks.TraceModelTrack(this.viewport_); this.modelTrackContainer_.appendChild(this.modelTrack_); this.mouseModeSelector_ = new ui.MouseModeSelector(this); this.appendChild(this.mouseModeSelector_); 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, 'beginpan', this.onBeginPanScan_, this); this.bindEventListener_(document, 'updatepan', this.onUpdatePanScan_, this); this.bindEventListener_(document, 'endpan', this.onEndPanScan_, this); this.bindEventListener_(document, 'beginselection', this.onBeginSelection_, this); this.bindEventListener_(document, 'updateselection', this.onUpdateSelection_, this); this.bindEventListener_(document, 'endselection', this.onEndSelection_, this); this.bindEventListener_(document, 'beginzoom', this.onBeginZoom_, this); this.bindEventListener_(document, 'updatezoom', this.onUpdateZoom_, this); this.bindEventListener_(document, 'endzoom', this.onEndZoom_, this); this.bindEventListener_(document, 'keydown', this.onKeydown_, this); this.bindEventListener_(document, 'keyup', this.onKeyup_, this); this.addEventListener('mousemove', this.onMouseMove_); this.addEventListener('dblclick', this.onDblClick_); this.mouseViewPosAtMouseDown_ = {x: 0, y: 0}; this.lastMouseViewPos_ = {x: 0, y: 0}; this.selection_ = new Selection(); this.isPanningAndScanning_ = false; this.isZooming_ = false; }, distanceCoveredInPanScan_: function(e) { var x = this.lastMouseViewPos_.x - this.mouseViewPosAtMouseDown_.x; var y = this.lastMouseViewPos_.y - this.mouseViewPosAtMouseDown_.y; return Math.sqrt(x * x + y * y); }, /** * 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.modelTrackContainer_.invalidate(); 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_.categoryFilter = this.categoryFilter; // Set up a reasonable viewport. if (modelInstanceChanged) this.viewport_.setWhenPossible(this.setInitialViewport_.bind(this)); base.setPropertyAndDispatchChange(this, 'model', model); }, get hasVisibleContent() { return this.modelTrack_.hasVisibleContent; }, setInitialViewport_: function() { var w = this.modelTrackContainer_.canvas.width; var min; var range; if (this.model_.bounds.isEmpty) { min = 0; range = 1000; } else if (this.model_.bounds.range == 0) { min = this.model_.bounds.min; range = 1000; } else { min = this.model_.bounds.min; range = this.model_.bounds.range; } var boost = range * 0.15; this.viewport_.xSetWorldBounds(min - boost, min + range + 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) { if (document.activeElement == this.focusElement) return true; return ui.elementIsChildOf(document.activeElement, this.focusElement); } return true; }, onMouseMove_: function(e) { // Zooming requires the delta since the last mousemove so we need to avoid // tracking it when the zoom interaction is active. if (this.isZooming_) return; this.storeLastMousePos_(e); }, onKeypress_: function(e) { var mouseModeConstants = tracing.mouseModeConstants; var vp = this.viewport_; if (!this.listenToKeys_) return; if (document.activeElement.nodeName == 'INPUT') return; var viewWidth = this.modelTrackContainer_.canvas.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; } }, // Not all keys send a keypress. onKeydown_: function(e) { if (!this.listenToKeys_) return; var sel; var mouseModeConstants = tracing.mouseModeConstants; var vp = this.viewport_; var viewWidth = this.modelTrackContainer_.canvas.clientWidth; switch (e.keyCode) { case 37: // left arrow sel = this.selection.getShiftedSelection(-1); if (sel) { this.selection = sel; this.panToSelection(); e.preventDefault(); } else { vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1); } break; case 39: // right arrow sel = this.selection.getShiftedSelection(1); if (sel) { this.selection = sel; this.panToSelection(); e.preventDefault(); } else { 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; } }, onKeyup_: function(e) { if (!this.listenToKeys_) return; if (!e.shiftKey) { if (this.dragBeginEvent_) { this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_, this.dragBoxXEnd_, this.dragBoxYEnd_); } } }, /** * 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) { var vp = this.viewport_; var viewWidth = this.modelTrackContainer_.canvas.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 || !this.selection.length) return; var bounds = this.selection.bounds; if (!bounds.range) return; var worldCenter = bounds.center; var worldRangeHalf = bounds.range * 0.5; var boost = worldRangeHalf * 0.5; this.viewport_.xSetWorldBounds(worldCenter - worldRangeHalf - boost, worldCenter + worldRangeHalf + boost, this.modelTrackContainer_.canvas.width); }, /** * Pan the view so the current selection becomes visible. */ panToSelection: function() { if (!this.selection || !this.selection.length) return; var bounds = this.selection.bounds; var worldCenter = bounds.center; var viewWidth = this.modelTrackContainer_.canvas.width; if (!bounds.range) { if (this.viewport_.xWorldToView(bounds.center) < 0 || this.viewport_.xWorldToView(bounds.center) > viewWidth) { this.viewport_.xPanWorldPosToViewPos( worldCenter, 'center', viewWidth); } return; } var worldRangeHalf = bounds.range * 0.5; var boost = worldRangeHalf * 0.5; this.viewport_.xPanWorldBoundsIntoView( worldCenter - worldRangeHalf - boost, worldCenter + worldRangeHalf + boost, viewWidth); this.viewport_.xPanWorldBoundsIntoView(bounds.min, bounds.max, viewWidth); }, 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 (Selection mode) : Select slices (with ' + mod + ': zoom to slices)\n' + ' drag (Pan mode) : Pan left/right/up/down)\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' + 'Space to switch between select / pan modes\n' + 'Shift to temporarily switch between select / pan modes\n' + 'Scroll to zoom in/out (in pan mode)\n' + 'Dbl-click to add timing markers\n' + 'f to zoom into selection\n' + 'z to reset zoom and pan to initial view\n' + '/ to search\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_.clear(); this.selection_.addSelection(selection); base.dispatchSimpleEvent(this, 'selectionChange'); for (i = 0; i < this.selection_.length; i++) this.selection_[i].selected = true; if (this.selection_.length && this.selection_[0].track) this.selection_[0].track.scrollIntoViewIfNeeded(); this.viewport_.dispatchChangeEvent(); // Triggers a redraw. }, 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) { var loY = Math.min(yStart, yEnd); var hiY = Math.max(yStart, yEnd); var loX = Math.min(xStart, xEnd); var hiX = Math.max(xStart, xEnd); var modelTrackRect = this.modelTrack_.getBoundingClientRect(); 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 headingWidth = window.getComputedStyle( this.querySelector('heading')).width; var trackTitleWidth = parseInt(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.modelTrackContainer_.canvas; 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 = left ? this.selection_.bounds.min : this.selection_.bounds.max; // Toggle the grid off if the grid is on, the marker position is the same // and the same element is selected (same timebase). if (this.viewport_.gridEnabled && this.viewport_.gridSide === left && this.viewport_.gridTimebase === tb) { this.viewport_.gridside = undefined; this.viewport_.gridEnabled = false; this.viewport_.gridTimebase = undefined; return; } // 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; this.viewport_.gridSide = left; this.viewport_.gridTimebase = tb; }, canBeginInteraction_: function(e) { if (e.button != 0) return false; // Ensure that we do not interfere with the user adding markers. if (ui.elementIsChildOf(e.target, this.rulerTrack_)) return false; return true; }, onDblClick_: function(e) { if (this.isPanningAndScanning_) { var endPanEvent = new base.Event('endpan'); endPanEvent.data = e; this.onEndPanScan_(endPanEvent); } if (this.isZooming_) { var endZoomEvent = new base.Event('endzoom'); endZoomEvent.data = e; this.onEndZoom_(endZoomEvent); } this.rulerTrack_.placeAndBeginDraggingMarker(e.clientX); e.preventDefault(); }, storeLastMousePos_: function(e) { this.lastMouseViewPos_ = this.extractRelativeMousePosition_(e); }, extractRelativeMousePosition_: function(e) { var canv = this.modelTrackContainer_.canvas; return { x: e.clientX - canv.offsetLeft, y: e.clientY - canv.offsetTop }; }, storeInitialMouseDownPos_: function(e) { var position = this.extractRelativeMousePosition_(e); this.mouseViewPosAtMouseDown_.x = position.x; this.mouseViewPosAtMouseDown_.y = position.y; }, focusElements_: function() { if (document.activeElement) document.activeElement.blur(); if (this.focusElement.tabIndex >= 0) this.focusElement.focus(); }, storeInitialInteractionPositionsAndFocus_: function(mouseEvent) { this.storeInitialMouseDownPos_(mouseEvent); this.storeLastMousePos_(mouseEvent); this.focusElements_(); }, onBeginPanScan_: function(e) { var vp = this.viewport_; var mouseEvent = e.data; if (!this.canBeginInteraction_(mouseEvent)) return; this.viewportStateAtMouseDown_ = vp.getStateInViewCoordinates(); this.isPanningAndScanning_ = true; this.storeInitialInteractionPositionsAndFocus_(mouseEvent); mouseEvent.preventDefault(); }, onUpdatePanScan_: function(e) { if (!this.isPanningAndScanning_) return; var vp = this.viewport_; var viewWidth = this.modelTrackContainer_.canvas.clientWidth; var mouseEvent = e.data; var x = this.viewportStateAtMouseDown_.panX + (this.lastMouseViewPos_.x - this.mouseViewPosAtMouseDown_.x); var y = this.viewportStateAtMouseDown_.panY - (this.lastMouseViewPos_.y - this.mouseViewPosAtMouseDown_.y); vp.setStateInViewCoordinates({ panX: x, panY: y }); mouseEvent.preventDefault(); mouseEvent.stopPropagation(); this.storeLastMousePos_(mouseEvent); }, onEndPanScan_: function(e) { var mouseEvent = e.data; this.isPanningAndScanning_ = false; this.storeLastMousePos_(mouseEvent); if (this.distanceCoveredInPanScan_(mouseEvent) > MIN_SELECTION_DISTANCE) return; this.dragBeginEvent_ = mouseEvent; this.onEndSelection_(e); }, onBeginSelection_: function(e) { var mouseEvent = e.data; if (!this.canBeginInteraction_(mouseEvent)) return; var canv = this.modelTrackContainer_.canvas; var rect = this.modelTrack_.getBoundingClientRect(); var canvRect = canv.getBoundingClientRect(); var inside = rect && mouseEvent.clientX >= rect.left && mouseEvent.clientX < rect.right && mouseEvent.clientY >= rect.top && mouseEvent.clientY < rect.bottom && mouseEvent.clientX >= canvRect.left && mouseEvent.clientX < canvRect.right; if (!inside) return; this.dragBeginEvent_ = mouseEvent; this.storeInitialInteractionPositionsAndFocus_(mouseEvent); mouseEvent.preventDefault(); }, onUpdateSelection_: function(e) { var mouseEvent = e.data; if (!this.dragBeginEvent_) return; // Update the drag box this.dragBoxXStart_ = this.dragBeginEvent_.clientX; this.dragBoxXEnd_ = mouseEvent.clientX; this.dragBoxYStart_ = this.dragBeginEvent_.clientY; this.dragBoxYEnd_ = mouseEvent.clientY; this.setDragBoxPosition_(this.dragBoxXStart_, this.dragBoxYStart_, this.dragBoxXEnd_, this.dragBoxYEnd_); }, onEndSelection_: function(e) { if (!this.dragBeginEvent_) return; var mouseEvent = e.data; // Stop the dragging. this.hideDragBox_(); var eDown = this.dragBeginEvent_ || mouseEvent; this.dragBeginEvent_ = null; // Figure out extents of the drag. var loY = Math.min(eDown.clientY, mouseEvent.clientY); var hiY = Math.max(eDown.clientY, mouseEvent.clientY); var loX = Math.min(eDown.clientX, mouseEvent.clientX); var hiX = Math.max(eDown.clientX, mouseEvent.clientX); var tracksContainerBoundingRect = this.modelTrackContainer_.getBoundingClientRect(); var topBoundary = tracksContainerBoundingRect.height; // Convert to worldspace. var canv = this.modelTrackContainer_.canvas; 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_(); }, onBeginZoom_: function(e) { var mouseEvent = e.data; if (!this.canBeginInteraction_(mouseEvent)) return; this.isZooming_ = true; this.storeInitialInteractionPositionsAndFocus_(mouseEvent); mouseEvent.preventDefault(); }, onUpdateZoom_: function(e) { if (!this.isZooming_) return; var mouseEvent = e.data; var newPosition = this.extractRelativeMousePosition_(mouseEvent); var zoomScaleValue = 1 + (this.lastMouseViewPos_.y - newPosition.y) * 0.01; this.zoomBy_(zoomScaleValue); this.storeLastMousePos_(mouseEvent); }, onEndZoom_: function(e) { this.isZooming_ = false; } }; return { TimelineTrackView: TimelineTrackView }; });