// 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 TimelineModel objects * based loosely on gantt charts. Each thread in the TimelineModel is given a * set of TimelineTracks, one per subrow in the thread. The Timeline class * acts as a controller, creating the individual tracks, while TimelineTracks * do actual drawing. * * Visually, the Timeline produces (prettier) visualizations like the following: * Thread1: AAAAAAAAAA AAAAA * BBBB BB * Thread2: CCCCCC CCCCC * */ cr.define('tracing', function() { /** * The TimelineViewport manages the transform used for navigating * within the timeline. It is a simple transform: * x' = (x+pan) * scale * * The timeline code tries to avoid directly accessing this transform, * instead using this class to do conversion between world and view space, * as well as the math for centering the viewport in various interesting * ways. * * @constructor * @extends {cr.EventTarget} */ function TimelineViewport(parentEl) { this.parentEl_ = parentEl; this.scaleX_ = 1; this.panX_ = 0; this.gridTimebase_ = 0; this.gridStep_ = 1000 / 60; this.gridEnabled_ = false; this.hasCalledSetupFunction_ = false; this.onResizeBoundToThis_ = this.onResize_.bind(this); // The following code uses an interval to detect when the parent element // is attached to the document. That is a trigger to run the setup function // and install a resize listener. this.checkForAttachInterval_ = setInterval( this.checkForAttach_.bind(this), 250); } TimelineViewport.prototype = { __proto__: cr.EventTarget.prototype, /** * Allows initialization of the viewport when the viewport's parent element * has been attached to the document and given a size. * @param {Function} fn Function to call when the viewport can be safely * initialized. */ setWhenPossible: function(fn) { this.pendingSetFunction_ = fn; }, /** * @return {boolean} Whether the current timeline is attached to the * document. */ get isAttachedToDocument_() { var cur = this.parentEl_; while (cur.parentNode) cur = cur.parentNode; return cur == this.parentEl_.ownerDocument; }, onResize_: function() { this.dispatchChangeEvent(); }, /** * Checks whether the parentNode is attached to the document. * When it is, it installs the iframe-based resize detection hook * and then runs the pendingSetFunction_, if present. */ checkForAttach_: function() { if (!this.isAttachedToDocument_ || this.clientWidth == 0) return; if (!this.iframe_) { this.iframe_ = document.createElement('iframe'); this.iframe_.style.cssText = 'position:absolute;width:100%;height:0;border:0;visibility:hidden;'; this.parentEl_.appendChild(this.iframe_); this.iframe_.contentWindow.addEventListener('resize', this.onResizeBoundToThis_); } var curSize = this.clientWidth + 'x' + this.clientHeight; if (this.pendingSetFunction_) { this.lastSize_ = curSize; this.pendingSetFunction_(); this.pendingSetFunction_ = undefined; } window.clearInterval(this.checkForAttachInterval_); this.checkForAttachInterval_ = undefined; }, /** * Fires the change event on this viewport. Used to notify listeners * to redraw when the underlying model has been mutated. */ dispatchChangeEvent: function() { cr.dispatchSimpleEvent(this, 'change'); }, detach: function() { if (this.checkForAttachInterval_) { window.clearInterval(this.checkForAttachInterval_); this.checkForAttachInterval_ = undefined; } this.iframe_.removeEventListener('resize', this.onResizeBoundToThis_); this.parentEl_.removeChild(this.iframe_); }, get scaleX() { return this.scaleX_; }, set scaleX(s) { var changed = this.scaleX_ != s; if (changed) { this.scaleX_ = s; this.dispatchChangeEvent(); } }, get panX() { return this.panX_; }, set panX(p) { var changed = this.panX_ != p; if (changed) { this.panX_ = p; this.dispatchChangeEvent(); } }, setPanAndScale: function(p, s) { var changed = this.scaleX_ != s || this.panX_ != p; if (changed) { this.scaleX_ = s; this.panX_ = p; this.dispatchChangeEvent(); } }, xWorldToView: function(x) { return (x + this.panX_) * this.scaleX_; }, xWorldVectorToView: function(x) { return x * this.scaleX_; }, xViewToWorld: function(x) { return (x / this.scaleX_) - this.panX_; }, xViewVectorToWorld: function(x) { return x / this.scaleX_; }, xPanWorldPosToViewPos: function(worldX, viewX, viewWidth) { if (typeof viewX == 'string') { if (viewX == 'left') { viewX = 0; } else if (viewX == 'center') { viewX = viewWidth / 2; } else if (viewX == 'right') { viewX = viewWidth - 1; } else { throw Error('unrecognized string for viewPos. left|center|right'); } } this.panX = (viewX / this.scaleX_) - worldX; }, xPanWorldRangeIntoView: function(worldMin, worldMax, viewWidth) { if (this.xWorldToView(worldMin) < 0) this.xPanWorldPosToViewPos(worldMin, 'left', viewWidth); else if (this.xWorldToView(worldMax) > viewWidth) this.xPanWorldPosToViewPos(worldMax, 'right', viewWidth); }, xSetWorldRange: function(worldMin, worldMax, viewWidth) { var worldRange = worldMax - worldMin; var scaleX = viewWidth / worldRange; var panX = -worldMin; this.setPanAndScale(panX, scaleX); }, get gridEnabled() { return this.gridEnabled_; }, set gridEnabled(enabled) { if (this.gridEnabled_ == enabled) return; this.gridEnabled_ = enabled && true; this.dispatchChangeEvent(); }, get gridTimebase() { return this.gridTimebase_; }, set gridTimebase(timebase) { if (this.gridTimebase_ == timebase) return; this.gridTimebase_ = timebase; cr.dispatchSimpleEvent(this, 'change'); }, get gridStep() { return this.gridStep_; }, applyTransformToCanavs: function(ctx) { ctx.transform(this.scaleX_, 0, 0, 1, this.panX_ * this.scaleX_, 0); } }; function TimelineSelectionSliceHit(track, slice) { this.track = track; this.slice = slice; } TimelineSelectionSliceHit.prototype = { get selected() { return this.slice.selected; }, set selected(v) { this.slice.selected = v; } }; function TimelineSelectionCounterSampleHit(track, counter, sampleIndex) { this.track = track; this.counter = counter; this.sampleIndex = sampleIndex; } TimelineSelectionCounterSampleHit.prototype = { get selected() { return this.track.selectedSamples[this.sampleIndex] == true; }, set selected(v) { if (v) this.track.selectedSamples[this.sampleIndex] = true; else this.track.selectedSamples[this.sampleIndex] = false; this.track.invalidate(); } }; /** * Represents a selection within a Timeline and its associated set of tracks. * @constructor */ function TimelineSelection() { this.range_dirty_ = true; this.range_ = {}; this.length_ = 0; } TimelineSelection.prototype = { __proto__: Object.prototype, get range() { if (this.range_dirty_) { var wmin = Infinity; var wmax = -wmin; for (var i = 0; i < this.length_; i++) { var hit = this[i]; if (hit.slice) { wmin = Math.min(wmin, hit.slice.start); wmax = Math.max(wmax, hit.slice.end); } } this.range_ = { min: wmin, max: wmax }; this.range_dirty_ = false; } return this.range_; }, get duration() { return this.range.max - this.range.min; }, get length() { return this.length_; }, clear: function() { for (var i = 0; i < this.length_; ++i) delete this[i]; this.length_ = 0; this.range_dirty_ = true; }, push_: function(hit) { this[this.length_++] = hit; this.range_dirty_ = true; return hit; }, addSlice: function(track, slice) { return this.push_(new TimelineSelectionSliceHit(track, slice)); }, addCounterSample: function(track, counter, sampleIndex) { return this.push_( new TimelineSelectionCounterSampleHit( track, counter, sampleIndex)); }, subSelection: function(index, count) { count = count || 1; var selection = new TimelineSelection(); selection.range_dirty_ = true; if (index < 0 || index + count > this.length_) throw 'Index out of bounds'; for (var i = index; i < index + count; i++) selection.push_(this[i]); return selection; }, getCounterSampleHits: function() { var selection = new TimelineSelection(); for (var i = 0; i < this.length_; i++) if (this[i] instanceof TimelineSelectionCounterSampleHit) selection.push_(this[i]); return selection; }, getSliceHits: function() { var selection = new TimelineSelection(); for (var i = 0; i < this.length_; i++) if (this[i] instanceof TimelineSelectionSliceHit) selection.push_(this[i]); return selection; }, map: function(fn) { for (var i = 0; i < this.length_; i++) fn(this[i]); }, /** * Helper for selection previous or next. * @param {boolean} forwardp If true, select one forward (next). * Else, select previous. * @return {boolean} true if current selection changed. */ getShiftedSelection: function(offset) { var newSelection = new TimelineSelection(); for (var i = 0; i < this.length_; i++) { var hit = this[i]; hit.track.addItemNearToProvidedHitToSelection( hit, offset, newSelection); } if (newSelection.length == 0) return undefined; return newSelection; }, }; /** * Renders a TimelineModel into a div element, making one * TimelineTrack for each subrow in each thread of the model, managing * overall track layout, and handling user interaction with the * viewport. * * @constructor * @extends {HTMLDivElement} */ var Timeline = cr.ui.define('div'); Timeline.prototype = { __proto__: HTMLDivElement.prototype, model_: null, decorate: function() { this.classList.add('timeline'); this.viewport_ = new TimelineViewport(this); this.viewportTrack = new tracing.TimelineViewportTrack(); this.tracks_ = this.ownerDocument.createElement('div'); this.appendChild(this.tracks_); this.dragBox_ = this.ownerDocument.createElement('div'); this.dragBox_.className = 'timeline-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, 'mousedown', this.onMouseDown_, this); this.bindEventListener_(document, 'mousemove', this.onMouseMove_, this); this.bindEventListener_(document, 'mouseup', this.onMouseUp_, this); this.bindEventListener_(document, 'dblclick', this.onDblClick_, this); this.lastMouseViewPos_ = {x: 0, y: 0}; this.selection_ = new TimelineSelection(); }, /** * 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() { for (var i = 0; i < this.tracks_.children.length; i++) this.tracks_.children[i].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 model() { return this.model_; }, set model(model) { if (!model) throw Error('Model cannot be null'); if (this.model) { throw Error('Cannot set model twice.'); } this.model_ = model; // Figure out all the headings. var allHeadings = []; model.getAllThreads().forEach(function(t) { allHeadings.push(t.userFriendlyName); }); model.getAllCounters().forEach(function(c) { allHeadings.push(c.name); }); model.getAllCpus().forEach(function(c) { allHeadings.push('CPU ' + c.cpuNumber); }); // Figure out the maximum heading size. var maxHeadingWidth = 0; var measuringStick = new tracing.MeasuringStick(); var headingEl = document.createElement('div'); headingEl.style.position = 'fixed'; headingEl.className = 'timeline-canvas-based-track-title'; allHeadings.forEach(function(text) { headingEl.textContent = text + ':__'; var w = measuringStick.measure(headingEl).width; // Limit heading width to 300px. if (w > 300) w = 300; if (w > maxHeadingWidth) maxHeadingWidth = w; }); maxHeadingWidth = maxHeadingWidth + 'px'; // Reset old tracks. for (var i = 0; i < this.tracks_.children.length; i++) this.tracks_.children[i].detach(); this.tracks_.textContent = ''; // Set up the viewport track this.viewportTrack.headingWidth = maxHeadingWidth; this.viewportTrack.viewport = this.viewport_; // Get a sorted list of CPUs var cpus = model.getAllCpus(); cpus.sort(tracing.TimelineCpu.compare); // Create tracks for each CPU. cpus.forEach(function(cpu) { var track = new tracing.TimelineCpuTrack(); track.heading = 'CPU ' + cpu.cpuNumber + ':'; track.headingWidth = maxHeadingWidth; track.viewport = this.viewport_; track.cpu = cpu; this.tracks_.appendChild(track); for (var counterName in cpu.counters) { var counter = cpu.counters[counterName]; track = new tracing.TimelineCounterTrack(); track.heading = 'CPU ' + cpu.cpuNumber + ' ' + counter.name + ':'; track.headingWidth = maxHeadingWidth; track.viewport = this.viewport_; track.counter = counter; this.tracks_.appendChild(track); } }.bind(this)); // Get a sorted list of processes. var processes = model.getAllProcesses(); processes.sort(tracing.TimelineProcess.compare); // Create tracks for each process. processes.forEach(function(process) { // Add counter tracks for this process. var counters = []; for (var tid in process.counters) counters.push(process.counters[tid]); counters.sort(tracing.TimelineCounter.compare); // Create the counters for this process. counters.forEach(function(counter) { var track = new tracing.TimelineCounterTrack(); track.heading = counter.name + ':'; track.headingWidth = maxHeadingWidth; track.viewport = this.viewport_; track.counter = counter; this.tracks_.appendChild(track); }.bind(this)); // Get a sorted list of threads. var threads = []; for (var tid in process.threads) threads.push(process.threads[tid]); threads.sort(tracing.TimelineThread.compare); // Create the threads. threads.forEach(function(thread) { var track = new tracing.TimelineThreadTrack(); track.heading = thread.userFriendlyName + ':'; track.tooltip = thread.userFriendlyDetails; track.headingWidth = maxHeadingWidth; track.viewport = this.viewport_; track.thread = thread; this.tracks_.appendChild(track); }.bind(this)); }.bind(this)); // Set up a reasonable viewport. this.viewport_.setWhenPossible(function() { var w = this.firstCanvas.width; this.viewport_.xSetWorldRange(this.model_.minTimestamp, this.model_.maxTimestamp, w); }.bind(this)); }, /** * @param {TimelineFilter} filter The filter to use for finding matches. * @param {TimelineSelection} selection The selection to add matches to. * @return {Array} An array of objects that match the provided * TimelineFilter. */ addAllObjectsMatchingFilterToSelection: function(filter, selection) { for (var i = 0; i < this.tracks_.children.length; ++i) this.tracks_.children[i].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.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; var viewWidth = this.firstCanvas.clientWidth; var curMouseV, curCenterW; switch (e.keyCode) { case 101: // e var vX = this.lastMouseViewPos_.x; var wX = vp.xViewToWorld(this.lastMouseViewPos_.x); var distFromCenter = vX - (viewWidth / 2); var percFromCenter = distFromCenter / viewWidth; var percFromCenterSq = percFromCenter * percFromCenter; vp.xPanWorldPosToViewPos(wX, 'center', viewWidth); break; case 119: // w this.zoomBy_(1.5); break; case 115: // s this.zoomBy_(1 / 1.5); break; case 103: // g this.onGridToggle_(true); break; case 71: // G this.onGridToggle_(false); break; case 87: // W this.zoomBy_(10); break; case 83: // S this.zoomBy_(1 / 10); break; case 97: // a vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1); break; case 100: // d 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; } }, // Not all keys send a keypress. onKeydown_: function(e) { if (!this.listenToKeys_) return; var sel; switch (e.keyCode) { case 37: // left arrow sel = this.selection.getShiftedSelection(-1); if (sel) { this.setSelectionAndMakeVisible(sel); e.preventDefault(); } break; case 39: // right arrow sel = this.selection.getShiftedSelection(1); if (sel) { this.setSelectionAndMakeVisible(sel); e.preventDefault(); } break; case 9: // TAB if (this.focusElement.tabIndex == -1) { if (e.shiftKey) this.selectPrevious_(e); else this.selectNext_(e); e.preventDefault(); } break; } }, /** * 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 curMouseV = this.lastMouseViewPos_.x; var curCenterW = vp.xViewToWorld(curMouseV); vp.scaleX = vp.scaleX * scale; vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); }, get keyHelp() { var help = 'Keyboard shortcuts:\n' + ' w/s : Zoom in/out (with shift: go faster)\n' + ' a/d : Pan left/right\n' + ' e : Center on mouse\n' + ' g/G : Shows grid at the start/end of the selected task\n'; if (this.focusElement.tabIndex) { help += ' <- : Select previous event on current timeline\n' + ' -> : Select next event on current timeline\n'; } else { help += ' <-,^TAB : Select previous event on current timeline\n' + ' ->, TAB : Select next event on current timeline\n'; } help += '\n' + 'Dbl-click to zoom in; Shift dbl-click to zoom out\n'; return help; }, get selection() { return this.selection_; }, set selection(selection) { if (!(selection instanceof TimelineSelection)) throw 'Expected TimelineSelection'; // Clear old selection. var i; for (i = 0; i < this.selection_.length; i++) this.selection_[i].selected = false; this.selection_ = selection; cr.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 TimelineSelection)) throw 'Expected TimelineSelection'; this.selection = selection; var range = this.selection.range; var size = this.viewport_.xWorldVectorToView(range.max - range.min); if (zoomAllowed && size < 50) { var worldCenter = range.min + (range.max - range.min) * 0.5; var worldRange = (range.max - range.min) * 5; this.viewport_.xSetWorldRange(worldCenter - worldRange * 0.5, worldCenter + worldRange * 0.5, this.firstCanvas.width); return; } this.viewport_.xPanWorldRangeIntoView(range.min, range.max, this.firstCanvas.width); }, get firstCanvas() { return this.tracks_.firstChild ? this.tracks_.firstChild.firstCanvas : 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(eDown, eCur) { var loX = Math.min(eDown.clientX, eCur.clientX); var hiX = Math.max(eDown.clientX, eCur.clientX); var loY = Math.min(eDown.clientY, eCur.clientY); var hiY = Math.max(eDown.clientY, eCur.clientY); this.dragBox_.style.left = loX + 'px'; this.dragBox_.style.top = loY + 'px'; this.dragBox_.style.width = hiX - loX + 'px'; this.dragBox_.style.height = hiY - loY + 'px'; var canv = this.firstCanvas; var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft); var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft); var roundedDuration = Math.round((hiWX - loWX) * 100) / 100; this.dragBox_.textContent = roundedDuration + 'ms'; var e = new cr.Event('selectionChanging'); e.loWX = loWX; e.hiWX = hiWX; this.dispatchEvent(e); }, onGridToggle_: function(left) { var tb; if (left) tb = this.selection_.range.min; else tb = this.selection_.range.max; // Shift the timebase left until its just left of minTimestamp. var numInterfvalsSinceStart = Math.ceil((tb - this.model_.minTimestamp) / this.viewport_.gridStep_); this.viewport_.gridTimebase = tb - (numInterfvalsSinceStart + 1) * this.viewport_.gridStep_; this.viewport_.gridEnabled = true; }, onMouseDown_: function(e) { var canv = this.firstCanvas; var rect = this.tracks_.getClientRects()[0]; var inside = rect && e.clientX >= rect.left && e.clientX < rect.right && e.clientY >= rect.top && e.clientY < rect.bottom && e.x >= canv.offsetLeft; 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 (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.setDragBoxPosition_(this.dragBeginEvent_, e); } }, 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 loX = Math.min(eDown.clientX, e.clientX); var hiX = Math.max(eDown.clientX, e.clientX); var loY = Math.min(eDown.clientY, e.clientY); var hiY = Math.max(eDown.clientY, e.clientY); // Convert to worldspace. var canv = this.firstCanvas; var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft); var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft); // Figure out what has been hit. var selection = new TimelineSelection(); for (i = 0; i < this.tracks_.children.length; i++) { var track = this.tracks_.children[i]; // Only check tracks that insersect the rect. var trackClientRect = track.getBoundingClientRect(); var a = Math.max(loY, trackClientRect.top); var b = Math.min(hiY, trackClientRect.bottom); if (a <= b) { track.addIntersectingItemsInRangeToSelection( loWX, hiWX, loY, hiY, selection); } } // Activate the new selection. this.selection = selection; } }, onDblClick_: function(e) { var canv = this.firstCanvas; if (e.x < canv.offsetLeft) return; var scale = 4; if (e.shiftKey) scale = 1 / scale; this.zoomBy_(scale); e.preventDefault(); } }; /** * The TimelineModel being viewed by the timeline * @type {TimelineModel} */ cr.defineProperty(Timeline, 'model', cr.PropertyKind.JS); return { Timeline: Timeline, TimelineSelectionSliceHit: TimelineSelectionSliceHit, TimelineSelectionCounterSampleHit: TimelineSelectionCounterSampleHit, TimelineSelection: TimelineSelection, TimelineViewport: TimelineViewport }; });