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 for (var tI = 0; tI < threads.length; tI++) { | |
212 var thread = threads[tI]; | |
213 var track = new TimelineThreadTrack(); | |
214 track.thread = thread; | |
215 track.viewport = this.viewport_; | |
216 this.tracks_.appendChild(track); | |
217 | |
218 } | |
219 | |
220 this.needsViewportReset_ = true; | |
221 }, | |
222 | |
223 viewportChange_: function() { | |
224 this.invalidate(); | |
225 }, | |
226 | |
227 invalidate: function() { | |
228 if (this.invalidatePending_) | |
229 return; | |
230 this.invalidatePending_ = true; | |
231 window.setTimeout(function() { | |
232 this.invalidatePending_ = false; | |
233 this.redrawAllTracks_(); | |
234 }.bind(this), 0); | |
235 }, | |
236 | |
237 onResize: function() { | |
238 for (var i = 0; i < this.tracks_.children.length; ++i) { | |
239 var track = this.tracks_.children[i]; | |
240 track.onResize(); | |
241 } | |
242 }, | |
243 | |
244 redrawAllTracks_: function() { | |
245 if (this.needsViewportReset_ && this.clientWidth != 0) { | |
246 this.needsViewportReset_ = false; | |
247 /* update viewport */ | |
248 var rangeTimestamp = this.model_.maxTimestamp - | |
249 this.model_.minTimestamp; | |
250 var w = this.firstCanvas.width; | |
251 console.log('viewport was reset with w=', w); | |
252 var scaleX = w / rangeTimestamp; | |
253 var panX = -this.model_.minTimestamp; | |
254 this.viewport_.setPanAndScale(panX, scaleX); | |
255 } | |
256 for (var i = 0; i < this.tracks_.children.length; ++i) { | |
257 this.tracks_.children[i].redraw(); | |
258 } | |
259 }, | |
260 | |
261 updateChildViewports_: function() { | |
262 for (var cI = 0; cI < this.tracks_.children.length; ++cI) { | |
263 var child = this.tracks_.children[cI]; | |
264 child.setViewport(this.panX, this.scaleX); | |
265 } | |
266 }, | |
267 | |
268 onKeypress_: function(e) { | |
269 var vp = this.viewport_; | |
270 if (!this.firstCanvas) | |
271 return; | |
272 var viewWidth = this.firstCanvas.clientWidth; | |
273 var curMouseV, curCenterW; | |
274 switch (e.keyCode) { | |
275 case 101: // e | |
276 var vX = this.lastMouseViewPos_.x; | |
277 var wX = vp.xViewToWorld(this.lastMouseViewPos_.x); | |
278 var distFromCenter = vX - (viewWidth / 2); | |
279 var percFromCenter = distFromCenter / viewWidth; | |
280 var percFromCenterSq = percFromCenter * percFromCenter; | |
281 vp.xPanWorldPosToViewPos(wX, 'center', viewWidth); | |
282 break; | |
283 case 119: // w | |
284 this.zoomBy_(1.5); | |
285 break; | |
286 case 115: // s | |
287 this.zoomBy_(1 / 1.5); | |
288 break; | |
289 case 103: // g | |
290 this.onGridToggle_(true); | |
291 break; | |
292 case 71: // G | |
293 this.onGridToggle_(false); | |
294 break; | |
295 case 87: // W | |
296 this.zoomBy_(10); | |
297 break; | |
298 case 83: // S | |
299 this.zoomBy_(1 / 10); | |
300 break; | |
301 case 97: // a | |
302 vp.panX += vp.xViewVectorToWorld(viewWidth * 0.1); | |
303 break; | |
304 case 100: // d | |
305 vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.1); | |
306 break; | |
307 case 65: // A | |
308 vp.panX += vp.xViewVectorToWorld(viewWidth * 0.5); | |
309 break; | |
310 case 68: // D | |
311 vp.panX -= vp.xViewVectorToWorld(viewWidth * 0.5); | |
312 break; | |
313 } | |
314 }, | |
315 | |
316 // Not all keys send a keypress. | |
317 onKeydown_: function(e) { | |
318 switch (e.keyCode) { | |
319 case 37: // left arrow | |
320 this.selectPrevious_(e); | |
321 e.preventDefault(); | |
322 break; | |
323 case 39: // right arrow | |
324 this.selectNext_(e); | |
325 e.preventDefault(); | |
326 break; | |
327 case 9: // TAB | |
328 if (e.shiftKey) | |
329 this.selectPrevious_(e); | |
330 else | |
331 this.selectNext_(e); | |
332 e.preventDefault(); | |
333 break; | |
334 } | |
335 }, | |
336 | |
337 /** | |
338 * Zoom in or out on the timeline by the given scale factor. | |
339 * @param {integer} scale The scale factor to apply. If <1, zooms out. | |
340 */ | |
341 zoomBy_: function(scale) { | |
342 if (!this.firstCanvas) | |
343 return; | |
344 var vp = this.viewport_; | |
345 var viewWidth = this.firstCanvas.clientWidth; | |
346 var curMouseV = this.lastMouseViewPos_.x; | |
347 var curCenterW = vp.xViewToWorld(curMouseV); | |
348 vp.scaleX = vp.scaleX * scale; | |
349 vp.xPanWorldPosToViewPos(curCenterW, curMouseV, viewWidth); | |
350 }, | |
351 | |
352 /** Select the next slice on the timeline. Applies to each track. */ | |
353 selectNext_: function(e) { | |
354 this.selectAdjoining_(e, true); | |
355 }, | |
356 | |
357 /** Select the previous slice on the timeline. Applies to each track. */ | |
358 selectPrevious_: function(e) { | |
359 this.selectAdjoining_(e, false); | |
360 }, | |
361 | |
362 /** | |
363 * Helper for selection previous or next. | |
364 * @param {Event} The current event. | |
365 * @param {boolean} forwardp If true, select one forward (next). | |
366 * Else, select previous. | |
367 */ | |
368 selectAdjoining_: function(e, forwardp) { | |
369 var i, track, slice, adjoining; | |
370 var selection = []; | |
371 // Clear old selection; try and select next. | |
372 for (i = 0; i < this.selection_.length; ++i) { | |
373 adjoining = undefined; | |
374 this.selection_[i].slice.selected = false; | |
375 var track = this.selection_[i].track; | |
376 var slice = this.selection_[i].slice; | |
377 if (slice) { | |
378 if (forwardp) | |
379 adjoining = track.pickNext(slice); | |
380 else | |
381 adjoining = track.pickPrevious(slice); | |
382 } | |
383 if (adjoining != undefined) | |
384 selection.push({track: track, slice: adjoining}); | |
385 } | |
386 // Activate the new selection. | |
387 this.selection_ = selection; | |
388 for (i = 0; i < this.selection_.length; ++i) | |
389 this.selection_[i].slice.selected = true; | |
390 cr.dispatchSimpleEvent(this, 'selectionChange'); | |
391 this.invalidate(); // Cause tracks to redraw. | |
392 e.preventDefault(); | |
393 }, | |
394 | |
395 get keyHelp() { | |
396 return 'Keyboard shortcuts:\n' + | |
397 ' w/s : Zoom in/out (with shift: go faster)\n' + | |
398 ' a/d : Pan left/right\n' + | |
399 ' e : Center on mouse\n' + | |
400 ' g/G : Shows grid at the start/end of the selected task\n' + | |
401 ' <-,^TAB : Select previous event on current timeline\n' + | |
402 ' ->, TAB : Select next event on current timeline\n' + | |
403 '\n' + | |
404 'Dbl-click to zoom in; Shift dbl-click to zoom out\n'; | |
405 | |
406 | |
407 }, | |
408 | |
409 get selection() { | |
410 return this.selection_; | |
411 }, | |
412 | |
413 get firstCanvas() { | |
414 return this.tracks_.firstChild ? | |
415 this.tracks_.firstChild.firstCanvas : undefined; | |
416 }, | |
417 | |
418 showDragBox_: function() { | |
419 this.dragBox_.hidden = false; | |
420 }, | |
421 | |
422 hideDragBox_: function() { | |
423 this.dragBox_.style.left = '-1000px'; | |
424 this.dragBox_.style.top = '-1000px'; | |
425 this.dragBox_.style.width = 0; | |
426 this.dragBox_.style.height = 0; | |
427 this.dragBox_.hidden = true; | |
428 }, | |
429 | |
430 get dragBoxVisible_() { | |
431 return this.dragBox_.hidden == false; | |
432 }, | |
433 | |
434 setDragBoxPosition_: function(eDown, eCur) { | |
435 var loX = Math.min(eDown.clientX, eCur.clientX); | |
436 var hiX = Math.max(eDown.clientX, eCur.clientX); | |
437 var loY = Math.min(eDown.clientY, eCur.clientY); | |
438 var hiY = Math.max(eDown.clientY, eCur.clientY); | |
439 | |
440 this.dragBox_.style.left = loX + 'px'; | |
441 this.dragBox_.style.top = loY + 'px'; | |
442 this.dragBox_.style.width = hiX - loX + 'px'; | |
443 this.dragBox_.style.height = hiY - loY + 'px'; | |
444 | |
445 var canv = this.firstCanvas; | |
446 var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft); | |
447 var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft); | |
448 | |
449 var roundedDuration = Math.round((hiWX - loWX) * 100) / 100; | |
450 this.dragBox_.textContent = roundedDuration + 'ms'; | |
451 | |
452 var e = new cr.Event('selectionChanging'); | |
453 e.loWX = loWX; | |
454 e.hiWX = hiWX; | |
455 this.dispatchEvent(e); | |
456 }, | |
457 | |
458 onGridToggle_: function(left) { | |
459 var tb; | |
460 if (left) | |
461 tb = Math.min.apply(Math, this.selection_.map( | |
462 function(x) { return x.slice.start; })); | |
463 else | |
464 tb = Math.max.apply(Math, this.selection_.map( | |
465 function(x) { return x.slice.end; })); | |
466 | |
467 // Shift the timebase left until its just left of minTimestamp. | |
468 var numInterfvalsSinceStart = Math.ceil((tb - this.model_.minTimestamp) / | |
469 this.viewport_.gridStep_); | |
470 this.viewport_.gridTimebase = tb - | |
471 (numInterfvalsSinceStart + 1) * this.viewport_.gridStep_; | |
472 this.viewport_.gridEnabled = true; | |
473 }, | |
474 | |
475 onMouseDown_: function(e) { | |
476 if (e.clientX < this.offsetLeft || | |
477 e.clientX >= this.offsetLeft + this.offsetWidth || | |
478 e.clientY < this.offsetTop || | |
479 e.clientY >= this.offsetTop + this.offsetHeight) | |
480 return; | |
481 | |
482 var canv = this.firstCanvas; | |
483 var pos = { | |
484 x: e.clientX - canv.offsetLeft, | |
485 y: e.clientY - canv.offsetTop | |
486 }; | |
487 | |
488 var wX = this.viewport_.xViewToWorld(pos.x); | |
489 | |
490 this.dragBeginEvent_ = e; | |
491 e.preventDefault(); | |
492 }, | |
493 | |
494 onMouseMove_: function(e) { | |
495 if (!this.firstCanvas) | |
496 return; | |
497 var canv = this.firstCanvas; | |
498 var pos = { | |
499 x: e.clientX - canv.offsetLeft, | |
500 y: e.clientY - canv.offsetTop | |
501 }; | |
502 | |
503 // Remember position. Used during keyboard zooming. | |
504 this.lastMouseViewPos_ = pos; | |
505 | |
506 // Initiate the drag box if needed. | |
507 if (this.dragBeginEvent_ && !this.dragBoxVisible_) { | |
508 this.showDragBox_(); | |
509 this.setDragBoxPosition_(e, e); | |
510 } | |
511 | |
512 // Update the drag box | |
513 if (this.dragBeginEvent_) { | |
514 this.setDragBoxPosition_(this.dragBeginEvent_, e); | |
515 } | |
516 }, | |
517 | |
518 onMouseUp_: function(e) { | |
519 var i; | |
520 if (this.dragBeginEvent_) { | |
521 // Stop the dragging. | |
522 this.hideDragBox_(); | |
523 var eDown = this.dragBeginEvent_; | |
524 this.dragBeginEvent_ = null; | |
525 | |
526 // Figure out extents of the drag. | |
527 var loX = Math.min(eDown.clientX, e.clientX); | |
528 var hiX = Math.max(eDown.clientX, e.clientX); | |
529 var loY = Math.min(eDown.clientY, e.clientY); | |
530 var hiY = Math.max(eDown.clientY, e.clientY); | |
531 | |
532 // Convert to worldspace. | |
533 var canv = this.firstCanvas; | |
534 var loWX = this.viewport_.xViewToWorld(loX - canv.offsetLeft); | |
535 var hiWX = this.viewport_.xViewToWorld(hiX - canv.offsetLeft); | |
536 | |
537 // Clear old selection. | |
538 for (i = 0; i < this.selection_.length; ++i) { | |
539 this.selection_[i].slice.selected = false; | |
540 } | |
541 | |
542 // Figure out what has been hit. | |
543 var selection = []; | |
544 function addHit(type, track, slice) { | |
545 selection.push({track: track, slice: slice}); | |
546 } | |
547 for (i = 0; i < this.tracks_.children.length; ++i) { | |
548 var track = this.tracks_.children[i]; | |
549 | |
550 // Only check tracks that insersect the rect. | |
551 var trackClientRect = track.getBoundingClientRect(); | |
552 var a = Math.max(loY, trackClientRect.top); | |
553 var b = Math.min(hiY, trackClientRect.bottom); | |
554 if (a <= b) { | |
555 track.pickRange(loWX, hiWX, loY, hiY, addHit); | |
556 } | |
557 } | |
558 // Activate the new selection. | |
559 this.selection_ = selection; | |
560 cr.dispatchSimpleEvent(this, 'selectionChange'); | |
561 for (i = 0; i < this.selection_.length; ++i) { | |
562 this.selection_[i].slice.selected = true; | |
563 } | |
564 this.invalidate(); // Cause tracks to redraw. | |
565 } | |
566 }, | |
567 | |
568 onDblClick_: function(e) { | |
569 var scale = 4; | |
570 if (e.shiftKey) | |
571 scale = 1 / scale; | |
572 this.zoomBy_(scale); | |
573 e.preventDefault(); | |
574 }, | |
575 }; | |
576 | |
577 /** | |
578 * The TimelineModel being viewed by the timeline | |
579 * @type {TimelineModel} | |
580 */ | |
581 cr.defineProperty(Timeline, 'model', cr.PropertyKind.JS); | |
582 | |
583 return { | |
584 Timeline: Timeline | |
585 }; | |
586 }); | |
OLD | NEW |