// Copyright (c) 2011 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. /** * @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('gpu', 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() { this.scaleX_ = 1; this.panX_ = 0; } TimelineViewport.prototype = { __proto__: cr.EventTarget.prototype, get scaleX() { return this.scaleX_; }, set scaleX(s) { var changed = this.scaleX_ != s; if (changed) { this.scaleX_ = s; cr.dispatchSimpleEvent(this, 'change'); } }, get panX() { return this.panX_; }, set panX(p) { var changed = this.panX_ != p; if (changed) { this.panX_ = p; cr.dispatchSimpleEvent(this, 'change'); } }, setPanAndScale: function(p, s) { var changed = this.scaleX_ != s || this.panX_ != p; if (changed) { this.scaleX_ = s; this.panX_ = p; cr.dispatchSimpleEvent(this, 'change'); } }, 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; }, applyTransformToCanavs: function(ctx) { ctx.transform(this.scaleX_, 0, 0, 1, this.panX_ * this.scaleX_, 0); } }; /** * 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} */ Timeline = cr.ui.define('div'); Timeline.prototype = { __proto__: HTMLDivElement.prototype, model_: null, decorate: function() { this.classList.add('timeline'); this.needsViewportReset_ = false; this.viewport_ = new TimelineViewport(); this.viewport_.addEventListener('change', this.invalidate.bind(this)); this.invalidatePending_ = false; this.tracks_ = this.ownerDocument.createElement('div'); this.tracks_.invalidate = this.invalidate.bind(this); this.appendChild(this.tracks_); this.dragBox_ = this.ownerDocument.createElement('div'); this.dragBox_.className = 'timeline-drag-box'; this.appendChild(this.dragBox_); // The following code uses a setInterval to monitor the timeline control // for size changes. This is so that we can keep the canvas' bitmap size // correctly synchronized with its presentation size. // TODO(nduca): detect this in a more efficient way, e.g. iframe hack. this.lastSize_ = this.clientWidth + 'x' + this.clientHeight; this.ownerDocument.defaultView.setInterval(function() { var curSize = this.clientWidth + 'x' + this.clientHeight; if (this.clientWidth && curSize != this.lastSize_) { this.lastSize_ = curSize; this.onResize(); } }.bind(this), 250); document.addEventListener('keypress', this.onKeypress_.bind(this)); this.addEventListener('mousedown', this.onMouseDown_.bind(this)); this.addEventListener('mousemove', this.onMouseMove_.bind(this)); this.addEventListener('mouseup', this.onMouseUp_.bind(this)); this.lastMouseViewPos_ = {x: 0, y: 0}; this.selection_ = []; }, 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; // Create tracks. this.tracks_.textContent = ''; var threads = model.getAllThreads(); for (var tI = 0; tI < threads.length; tI++) { var thread = threads[tI]; var track = new TimelineThreadTrack(); track.thread = thread; track.viewport = this.viewport_; this.tracks_.appendChild(track); } this.needsViewportReset_ = true; }, invalidate: function() { if (this.invalidatePending_) return; this.invalidatePending_ = true; window.setTimeout(function() { this.invalidatePending_ = false; this.redrawAllTracks_(); }.bind(this), 0); }, onResize: function() { for (var i = 0; i < this.tracks_.children.length; ++i) { var track = this.tracks_.children[i]; track.onResize(); } }, redrawAllTracks_: function() { if (this.needsViewportReset_ && this.clientWidth != 0) { this.needsViewportReset_ = false; /* update viewport */ var rangeTimestamp = this.model_.maxTimestamp - this.model_.minTimestamp; var w = this.firstCanvas.width; console.log('viewport was reset with w=', w); var scaleX = w / rangeTimestamp; var panX = -this.model_.minTimestamp; this.viewport_.setPanAndScale(panX, scaleX); } for (var i = 0; i < this.tracks_.children.length; ++i) { this.tracks_.children[i].redraw(); } }, updateChildViewports_: function() { for (var cI = 0; cI < this.tracks_.children.length; ++cI) { var child = this.tracks_.children[cI]; child.setViewport(this.panX, this.scaleX); } }, onKeypress_: function(e) { var vp = this.viewport_; if (this.firstCanvas) { var viewWidth = this.firstCanvas.clientWidth; var curMouseV, curCenterW; switch (event.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 curMouseV = this.lastMouseViewPos_.x; curCenterW = vp.xViewToWorld(curMouseV); vp.scaleX = vp.scaleX * 1.5; vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); break; case 115: // s curMouseV = this.lastMouseViewPos_.x; curCenterW = vp.xViewToWorld(curMouseV); vp.scaleX = vp.scaleX / 1.5; vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); break; case 87: // W curMouseV = this.lastMouseViewPos_.x; curCenterW = vp.xViewToWorld(curMouseV); vp.scaleX = vp.scaleX * 10; vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); break; case 83: // S curMouseV = this.lastMouseViewPos_.x; curCenterW = vp.xViewToWorld(curMouseV); vp.scaleX = vp.scaleX / 10; vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); 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; } } }, get keyHelp() { return 'Keyboard shortcuts:\n' + ' w/s : Zoom in/out\n' + ' a/d : Pan left/right\n' + ' e : Center on mouse'; }, get selection() { return this.selection_; }, get firstCanvas() { return this.tracks_.firstChild ? this.tracks_.firstChild.firstCanvas : undefined; }, showDragBox_: function() { this.dragBox_.hidden = false; }, hideDragBox_: function() { this.dragBox_.hidden = true; }, 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); }, onMouseDown_: function(e) { var canv = this.firstCanvas; var pos = { x: e.clientX - canv.offsetLeft, y: e.clientY - canv.offsetTop }; var wX = this.viewport_.xViewToWorld(pos.x); // Update the drag box position this.showDragBox_(); this.setDragBoxPosition_(e, e); this.dragBeginEvent_ = e; e.preventDefault(); }, 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); // Clear old selection. for (i = 0; i < this.selection_.length; ++i) { this.selection_[i].slice.selected = false; } // Figure out what has been hit. var selection = []; function addHit(type, track, slice) { selection.push({track: track, slice: slice}); } for (i = 0; i < this.tracks_.children.length; ++i) { var track = this.tracks_.children[i]; // Only check tracks that insersect the rect. var a = Math.max(loY, track.offsetTop); var b = Math.min(hiY, track.offsetTop + track.offsetHeight); if (a <= b) { track.pickRange(loWX, hiWX, loY, hiY, addHit); } } // Activate the new selection. this.selection_ = selection; cr.dispatchSimpleEvent(this, 'selectionChange'); for (i = 0; i < this.selection_.length; ++i) { this.selection_[i].slice.selected = true; } this.invalidate(); // Cause tracks to redraw. } } }; /** * The TimelineModel being viewed by the timeline * @type {TimelineModel} */ cr.defineProperty(Timeline, 'model', cr.PropertyKind.JS); return { Timeline: Timeline }; });