OLD | NEW |
| (Empty) |
1 // Copyright (c) 2011 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 /** | |
6 * @fileoverview Interactive visualizaiton of TimelineModel objects | |
7 * based loosely on gantt charts. Each thread in the TimelineModel is given a | |
8 * set of TimelineTracks, one per subrow in the thread. The Timeline class | |
9 * acts as a controller, creating the individual tracks, while TimelineTracks | |
10 * do actual drawing. | |
11 * | |
12 * Visually, the Timeline produces (prettier) visualizations like the following: | |
13 * Thread1: AAAAAAAAAA AAAAA | |
14 * BBBB BB | |
15 * Thread2: CCCCCC CCCCC | |
16 * | |
17 */ | |
18 cr.define('gpu', function() { | |
19 | |
20 /** | |
21 * The TimelineViewport manages the transform used for navigating | |
22 * within the timeline. It is a simple transform: | |
23 * x' = (x+pan) * scale | |
24 * | |
25 * The timeline code tries to avoid directly accessing this transform, | |
26 * instead using this class to do conversion between world and view space, | |
27 * as well as the math for centering the viewport in various interesting | |
28 * ways. | |
29 * | |
30 * @constructor | |
31 * @extends {cr.EventTarget} | |
32 */ | |
33 function TimelineViewport() { | |
34 this.scaleX_ = 1; | |
35 this.panX_ = 0; | |
36 this.gridTimebase_ = 0; | |
37 this.gridStep_ = 1000 / 60; | |
38 this.gridEnabled_ = false; | |
39 } | |
40 | |
41 TimelineViewport.prototype = { | |
42 __proto__: cr.EventTarget.prototype, | |
43 | |
44 get scaleX() { | |
45 return this.scaleX_; | |
46 }, | |
47 set scaleX(s) { | |
48 var changed = this.scaleX_ != s; | |
49 if (changed) { | |
50 this.scaleX_ = s; | |
51 cr.dispatchSimpleEvent(this, 'change'); | |
52 } | |
53 }, | |
54 | |
55 get panX() { | |
56 return this.panX_; | |
57 }, | |
58 set panX(p) { | |
59 var changed = this.panX_ != p; | |
60 if (changed) { | |
61 this.panX_ = p; | |
62 cr.dispatchSimpleEvent(this, 'change'); | |
63 } | |
64 }, | |
65 | |
66 setPanAndScale: function(p, s) { | |
67 var changed = this.scaleX_ != s || this.panX_ != p; | |
68 if (changed) { | |
69 this.scaleX_ = s; | |
70 this.panX_ = p; | |
71 cr.dispatchSimpleEvent(this, 'change'); | |
72 } | |
73 }, | |
74 | |
75 xWorldToView: function(x) { | |
76 return (x + this.panX_) * this.scaleX_; | |
77 }, | |
78 | |
79 xWorldVectorToView: function(x) { | |
80 return x * this.scaleX_; | |
81 }, | |
82 | |
83 xViewToWorld: function(x) { | |
84 return (x / this.scaleX_) - this.panX_; | |
85 }, | |
86 | |
87 xViewVectorToWorld: function(x) { | |
88 return x / this.scaleX_; | |
89 }, | |
90 | |
91 xPanWorldPosToViewPos: function(worldX, viewX, viewWidth) { | |
92 if (typeof viewX == 'string') { | |
93 if (viewX == 'left') { | |
94 viewX = 0; | |
95 } else if (viewX == 'center') { | |
96 viewX = viewWidth / 2; | |
97 } else if (viewX == 'right') { | |
98 viewX = viewWidth - 1; | |
99 } else { | |
100 throw Error('unrecognized string for viewPos. left|center|right'); | |
101 } | |
102 } | |
103 this.panX = (viewX / this.scaleX_) - worldX; | |
104 }, | |
105 | |
106 get gridEnabled() { | |
107 return this.gridEnabled_; | |
108 }, | |
109 | |
110 set gridEnabled(enabled) { | |
111 if (this.gridEnabled_ == enabled) | |
112 return; | |
113 this.gridEnabled_ = enabled && true; | |
114 cr.dispatchSimpleEvent(this, 'change'); | |
115 }, | |
116 | |
117 get gridTimebase() { | |
118 return this.gridTimebase_; | |
119 }, | |
120 | |
121 set gridTimebase(timebase) { | |
122 if (this.gridTimebase_ == timebase) | |
123 return; | |
124 this.gridTimebase_ = timebase; | |
125 cr.dispatchSimpleEvent(this, 'change'); | |
126 }, | |
127 | |
128 get gridStep() { | |
129 return this.gridStep_; | |
130 }, | |
131 | |
132 applyTransformToCanavs: function(ctx) { | |
133 ctx.transform(this.scaleX_, 0, 0, 1, this.panX_ * this.scaleX_, 0); | |
134 } | |
135 }; | |
136 | |
137 /** | |
138 * Renders a TimelineModel into a div element, making one | |
139 * TimelineTrack for each subrow in each thread of the model, managing | |
140 * overall track layout, and handling user interaction with the | |
141 * viewport. | |
142 * | |
143 * @constructor | |
144 * @extends {HTMLDivElement} | |
145 */ | |
146 Timeline = cr.ui.define('div'); | |
147 | |
148 Timeline.prototype = { | |
149 __proto__: HTMLDivElement.prototype, | |
150 | |
151 model_: null, | |
152 | |
153 decorate: function() { | |
154 this.classList.add('timeline'); | |
155 this.needsViewportReset_ = false; | |
156 | |
157 this.viewport_ = new TimelineViewport(); | |
158 this.viewport_.addEventListener('change', | |
159 this.viewportChange_.bind(this)); | |
160 | |
161 this.invalidatePending_ = false; | |
162 | |
163 this.tracks_ = this.ownerDocument.createElement('div'); | |
164 this.tracks_.invalidate = this.invalidate.bind(this); | |
165 this.appendChild(this.tracks_); | |
166 | |
167 this.dragBox_ = this.ownerDocument.createElement('div'); | |
168 this.dragBox_.className = 'timeline-drag-box'; | |
169 this.appendChild(this.dragBox_); | |
170 | |
171 // The following code uses a setInterval to monitor the timeline control | |
172 // for size changes. This is so that we can keep the canvas' bitmap size | |
173 // correctly synchronized with its presentation size. | |
174 // TODO(nduca): detect this in a more efficient way, e.g. iframe hack. | |
175 this.lastSize_ = this.clientWidth + 'x' + this.clientHeight; | |
176 this.ownerDocument.defaultView.setInterval(function() { | |
177 var curSize = this.clientWidth + 'x' + this.clientHeight; | |
178 if (this.clientWidth && curSize != this.lastSize_) { | |
179 this.lastSize_ = curSize; | |
180 this.onResize(); | |
181 } | |
182 }.bind(this), 250); | |
183 | |
184 document.addEventListener('keypress', this.onKeypress_.bind(this)); | |
185 document.addEventListener('keydown', this.onKeydown_.bind(this)); | |
186 document.addEventListener('mousedown', this.onMouseDown_.bind(this)); | |
187 document.addEventListener('mousemove', this.onMouseMove_.bind(this)); | |
188 document.addEventListener('mouseup', this.onMouseUp_.bind(this)); | |
189 document.addEventListener('dblclick', this.onDblClick_.bind(this)); | |
190 | |
191 this.lastMouseViewPos_ = {x: 0, y: 0}; | |
192 | |
193 this.selection_ = []; | |
194 }, | |
195 | |
196 get model() { | |
197 return this.model_; | |
198 }, | |
199 | |
200 set model(model) { | |
201 if (!model) | |
202 throw Error('Model cannot be null'); | |
203 if (this.model) { | |
204 throw Error('Cannot set model twice.'); | |
205 } | |
206 this.model_ = model; | |
207 | |
208 // Create tracks. | |
209 this.tracks_.textContent = ''; | |
210 var threads = model.getAllThreads(); | |
211 threads.sort(gpu.TimelineThread.compare); | |
212 for (var tI = 0; tI < threads.length; tI++) { | |
213 var thread = threads[tI]; | |
214 var track = new TimelineThreadTrack(); | |
215 track.thread = thread; | |
216 track.viewport = this.viewport_; | |
217 this.tracks_.appendChild(track); | |
218 | |
219 } | |
220 | |
221 this.needsViewportReset_ = true; | |
222 }, | |
223 | |
224 viewportChange_: function() { | |
225 this.invalidate(); | |
226 }, | |
227 | |
228 invalidate: function() { | |
229 if (this.invalidatePending_) | |
230 return; | |
231 this.invalidatePending_ = true; | |
232 window.setTimeout(function() { | |
233 this.invalidatePending_ = false; | |
234 this.redrawAllTracks_(); | |
235 }.bind(this), 0); | |
236 }, | |
237 | |
238 onResize: function() { | |
239 for (var i = 0; i < this.tracks_.children.length; ++i) { | |
240 var track = this.tracks_.children[i]; | |
241 track.onResize(); | |
242 } | |
243 }, | |
244 | |
245 redrawAllTracks_: function() { | |
246 if (this.needsViewportReset_ && this.clientWidth != 0) { | |
247 this.needsViewportReset_ = false; | |
248 /* update viewport */ | |
249 var rangeTimestamp = this.model_.maxTimestamp - | |
250 this.model_.minTimestamp; | |
251 var w = this.firstCanvas.width; | |
252 console.log('viewport was reset with w=', w); | |
253 var scaleX = w / rangeTimestamp; | |
254 var panX = -this.model_.minTimestamp; | |
255 this.viewport_.setPanAndScale(panX, scaleX); | |
256 } | |
257 for (var i = 0; i < this.tracks_.children.length; ++i) { | |
258 this.tracks_.children[i].redraw(); | |
259 } | |
260 }, | |
261 | |
262 updateChildViewports_: function() { | |
263 for (var cI = 0; cI < this.tracks_.children.length; ++cI) { | |
264 var child = this.tracks_.children[cI]; | |
265 child.setViewport(this.panX, this.scaleX); | |
266 } | |
267 }, | |
268 | |
269 onKeypress_: function(e) { | |
270 var vp = this.viewport_; | |
271 if (!this.firstCanvas) | |
272 return; | |
273 var viewWidth = this.firstCanvas.clientWidth; | |
274 var curMouseV, curCenterW; | |
275 switch (e.keyCode) { | |
276 case 101: // e | |
277 var vX = this.lastMouseViewPos_.x; | |
278 var wX = vp.xViewToWorld(this.lastMouseViewPos_.x); | |
279 var distFromCenter = vX - (viewWidth / 2); | |
280 var percFromCenter = distFromCenter / viewWidth; | |
281 var percFromCenterSq = percFromCenter * percFromCenter; | |
282 vp.xPanWorldPosToViewPos(wX, 'center', viewWidth); | |
283 break; | |
284 case 119: // w | |
285 this.zoomBy_(1.5); | |
286 break; | |
287 case 115: // s | |
288 this.zoomBy_(1 / 1.5); | |
289 break; | |
290 case 103: // g | |
291 this.onGridToggle_(true); | |
292 break; | |
293 case 71: // G | |
294 this.onGridToggle_(false); | |
295 break; | |
296 case 87: // W | |
297 this.zoomBy_(10); | |
298 break; | |
299 case 83: // S | |
300 this.zoomBy_(1 / 10); | |
301 break; | |
302 case 97: // a | |
303 vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1); | |
304 break; | |
305 case 100: // d | |
306 vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1); | |
307 break; | |
308 case 65: // A | |
309 vp.panX += vp.xViewVectorToWorld(viewWidth * 0.5); | |
310 break; | |
311 case 68: // D | |
312 vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.5); | |
313 break; | |
314 } | |
315 }, | |
316 | |
317 // Not all keys send a keypress. | |
318 onKeydown_: function(e) { | |
319 switch (e.keyCode) { | |
320 case 37: // left arrow | |
321 this.selectPrevious_(e); | |
322 e.preventDefault(); | |
323 break; | |
324 case 39: // right arrow | |
325 this.selectNext_(e); | |
326 e.preventDefault(); | |
327 break; | |
328 case 9: // TAB | |
329 if (e.shiftKey) | |
330 this.selectPrevious_(e); | |
331 else | |
332 this.selectNext_(e); | |
333 e.preventDefault(); | |
334 break; | |
335 } | |
336 }, | |
337 | |
338 /** | |
339 * Zoom in or out on the timeline by the given scale factor. | |
340 * @param {integer} scale The scale factor to apply. If <1, zooms out. | |
341 */ | |
342 zoomBy_: function(scale) { | |
343 if (!this.firstCanvas) | |
344 return; | |
345 var vp = this.viewport_; | |
346 var viewWidth = this.firstCanvas.clientWidth; | |
347 var curMouseV = this.lastMouseViewPos_.x; | |
348 var curCenterW = vp.xViewToWorld(curMouseV); | |
349 vp.scaleX = vp.scaleX * scale; | |
350 vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); | |
351 }, | |
352 | |
353 /** Select the next slice on the timeline. Applies to each track. */ | |
354 selectNext_: function(e) { | |
355 this.selectAdjoining_(e, true); | |
356 }, | |
357 | |
358 /** Select the previous slice on the timeline. Applies to each track. */ | |
359 selectPrevious_: function(e) { | |
360 this.selectAdjoining_(e, false); | |
361 }, | |
362 | |
363 /** | |
364 * Helper for selection previous or next. | |
365 * @param {Event} The current event. | |
366 * @param {boolean} forwardp If true, select one forward (next). | |
367 * Else, select previous. | |
368 */ | |
369 selectAdjoining_: function(e, forwardp) { | |
370 var i, track, slice, adjoining; | |
371 var selection = []; | |
372 // Clear old selection; try and select next. | |
373 for (i = 0; i < this.selection_.length; ++i) { | |
374 adjoining = undefined; | |
375 this.selection_[i].slice.selected = false; | |
376 var track = this.selection_[i].track; | |
377 var slice = this.selection_[i].slice; | |
378 if (slice) { | |
379 if (forwardp) | |
380 adjoining = track.pickNext(slice); | |
381 else | |
382 adjoining = track.pickPrevious(slice); | |
383 } | |
384 if (adjoining != undefined) | |
385 selection.push({track: track, slice: adjoining}); | |
386 } | |
387 // Activate the new selection. | |
388 this.selection_ = selection; | |
389 for (i = 0; i < this.selection_.length; ++i) | |
390 this.selection_[i].slice.selected = true; | |
391 cr.dispatchSimpleEvent(this, 'selectionChange'); | |
392 this.invalidate(); // Cause tracks to redraw. | |
393 e.preventDefault(); | |
394 }, | |
395 | |
396 get keyHelp() { | |
397 return 'Keyboard shortcuts:\n' + | |
398 ' w/s : Zoom in/out (with shift: go faster)\n' + | |
399 ' a/d : Pan left/right\n' + | |
400 ' e : Center on mouse\n' + | |
401 ' g/G : Shows grid at the start/end of the selected task\n' + | |
402 ' <-,^TAB : Select previous event on current timeline\n' + | |
403 ' ->, TAB : Select next event on current timeline\n' + | |
404 '\n' + | |
405 'Dbl-click to zoom in; Shift dbl-click to zoom out\n'; | |
406 | |
407 | |
408 }, | |
409 | |
410 get selection() { | |
411 return this.selection_; | |
412 }, | |
413 | |
414 get firstCanvas() { | |
415 return this.tracks_.firstChild ? | |
416 this.tracks_.firstChild.firstCanvas : undefined; | |
417 }, | |
418 | |
419 showDragBox_: function() { | |
420 this.dragBox_.hidden = false; | |
421 }, | |
422 | |
423 hideDragBox_: function() { | |
424 this.dragBox_.style.left = '-1000px'; | |
425 this.dragBox_.style.top = '-1000px'; | |
426 this.dragBox_.style.width = 0; | |
427 this.dragBox_.style.height = 0; | |
428 this.dragBox_.hidden = true; | |
429 }, | |
430 | |
431 get dragBoxVisible_() { | |
432 return this.dragBox_.hidden == false; | |
433 }, | |
434 | |
435 setDragBoxPosition_: function(eDown, eCur) { | |
436 var loX = Math.min(eDown.clientX, eCur.clientX); | |
437 var hiX = Math.max(eDown.clientX, eCur.clientX); | |
438 var loY = Math.min(eDown.clientY, eCur.clientY); | |
439 var hiY = Math.max(eDown.clientY, eCur.clientY); | |
440 | |
441 this.dragBox_.style.left = loX + 'px'; | |
442 this.dragBox_.style.top = loY + 'px'; | |
443 this.dragBox_.style.width = hiX - loX + 'px'; | |
444 this.dragBox_.style.height = hiY - loY + 'px'; | |
445 | |
446 var canv = this.firstCanvas; | |
447 var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft); | |
448 var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft); | |
449 | |
450 var roundedDuration = Math.round((hiWX - loWX) * 100) / 100; | |
451 this.dragBox_.textContent = roundedDuration + 'ms'; | |
452 | |
453 var e = new cr.Event('selectionChanging'); | |
454 e.loWX = loWX; | |
455 e.hiWX = hiWX; | |
456 this.dispatchEvent(e); | |
457 }, | |
458 | |
459 onGridToggle_: function(left) { | |
460 var tb; | |
461 if (left) | |
462 tb = Math.min.apply(Math, this.selection_.map( | |
463 function(x) { return x.slice.start; })); | |
464 else | |
465 tb = Math.max.apply(Math, this.selection_.map( | |
466 function(x) { return x.slice.end; })); | |
467 | |
468 // Shift the timebase left until its just left of minTimestamp. | |
469 var numInterfvalsSinceStart = Math.ceil((tb - this.model_.minTimestamp) / | |
470 this.viewport_.gridStep_); | |
471 this.viewport_.gridTimebase = tb - | |
472 (numInterfvalsSinceStart + 1) * this.viewport_.gridStep_; | |
473 this.viewport_.gridEnabled = true; | |
474 }, | |
475 | |
476 onMouseDown_: function(e) { | |
477 if (e.clientX < this.offsetLeft || | |
478 e.clientX >= this.offsetLeft + this.offsetWidth || | |
479 e.clientY < this.offsetTop || | |
480 e.clientY >= this.offsetTop + this.offsetHeight) | |
481 return; | |
482 | |
483 var canv = this.firstCanvas; | |
484 var pos = { | |
485 x: e.clientX - canv.offsetLeft, | |
486 y: e.clientY - canv.offsetTop | |
487 }; | |
488 | |
489 var wX = this.viewport_.xViewToWorld(pos.x); | |
490 | |
491 this.dragBeginEvent_ = e; | |
492 e.preventDefault(); | |
493 }, | |
494 | |
495 onMouseMove_: function(e) { | |
496 if (!this.firstCanvas) | |
497 return; | |
498 var canv = this.firstCanvas; | |
499 var pos = { | |
500 x: e.clientX - canv.offsetLeft, | |
501 y: e.clientY - canv.offsetTop | |
502 }; | |
503 | |
504 // Remember position. Used during keyboard zooming. | |
505 this.lastMouseViewPos_ = pos; | |
506 | |
507 // Initiate the drag box if needed. | |
508 if (this.dragBeginEvent_ && !this.dragBoxVisible_) { | |
509 this.showDragBox_(); | |
510 this.setDragBoxPosition_(e, e); | |
511 } | |
512 | |
513 // Update the drag box | |
514 if (this.dragBeginEvent_) { | |
515 this.setDragBoxPosition_(this.dragBeginEvent_, e); | |
516 } | |
517 }, | |
518 | |
519 onMouseUp_: function(e) { | |
520 var i; | |
521 if (this.dragBeginEvent_) { | |
522 // Stop the dragging. | |
523 this.hideDragBox_(); | |
524 var eDown = this.dragBeginEvent_; | |
525 this.dragBeginEvent_ = null; | |
526 | |
527 // Figure out extents of the drag. | |
528 var loX = Math.min(eDown.clientX, e.clientX); | |
529 var hiX = Math.max(eDown.clientX, e.clientX); | |
530 var loY = Math.min(eDown.clientY, e.clientY); | |
531 var hiY = Math.max(eDown.clientY, e.clientY); | |
532 | |
533 // Convert to worldspace. | |
534 var canv = this.firstCanvas; | |
535 var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft); | |
536 var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft); | |
537 | |
538 // Clear old selection. | |
539 for (i = 0; i < this.selection_.length; ++i) { | |
540 this.selection_[i].slice.selected = false; | |
541 } | |
542 | |
543 // Figure out what has been hit. | |
544 var selection = []; | |
545 function addHit(type, track, slice) { | |
546 selection.push({track: track, slice: slice}); | |
547 } | |
548 for (i = 0; i < this.tracks_.children.length; ++i) { | |
549 var track = this.tracks_.children[i]; | |
550 | |
551 // Only check tracks that insersect the rect. | |
552 var trackClientRect = track.getBoundingClientRect(); | |
553 var a = Math.max(loY, trackClientRect.top); | |
554 var b = Math.min(hiY, trackClientRect.bottom); | |
555 if (a <= b) { | |
556 track.pickRange(loWX, hiWX, loY, hiY, addHit); | |
557 } | |
558 } | |
559 // Activate the new selection. | |
560 this.selection_ = selection; | |
561 cr.dispatchSimpleEvent(this, 'selectionChange'); | |
562 for (i = 0; i < this.selection_.length; ++i) { | |
563 this.selection_[i].slice.selected = true; | |
564 } | |
565 this.invalidate(); // Cause tracks to redraw. | |
566 } | |
567 }, | |
568 | |
569 onDblClick_: function(e) { | |
570 var scale = 4; | |
571 if (e.shiftKey) | |
572 scale = 1 / scale; | |
573 this.zoomBy_(scale); | |
574 e.preventDefault(); | |
575 }, | |
576 }; | |
577 | |
578 /** | |
579 * The TimelineModel being viewed by the timeline | |
580 * @type {TimelineModel} | |
581 */ | |
582 cr.defineProperty(Timeline, 'model', cr.PropertyKind.JS); | |
583 | |
584 return { | |
585 Timeline: Timeline | |
586 }; | |
587 }); | |
OLD | NEW |