1/* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import { 18 ChangeDetectorRef, 19 Component, 20 ElementRef, 21 EventEmitter, 22 HostListener, 23 Inject, 24 Input, 25 Output, 26 SimpleChanges, 27 ViewChild, 28} from '@angular/core'; 29import {TimelineData} from 'app/timeline_data'; 30import {assertDefined} from 'common/assert_utils'; 31import {PersistentStore} from 'common/store/persistent_store'; 32import {TimeRange, Timestamp} from 'common/time/time'; 33import {TimestampUtils} from 'common/time/timestamp_utils'; 34import {Analytics} from 'logging/analytics'; 35import {Trace} from 'trace/trace'; 36import {TracePosition} from 'trace/trace_position'; 37import {TraceTypeUtils} from 'trace/trace_type'; 38import {MiniTimelineDrawer} from './drawer/mini_timeline_drawer'; 39import {MiniTimelineDrawerImpl} from './drawer/mini_timeline_drawer_impl'; 40import {MiniTimelineDrawerInput} from './drawer/mini_timeline_drawer_input'; 41import {MIN_SLIDER_WIDTH} from './slider_component'; 42import {Transformer} from './transformer'; 43 44@Component({ 45 selector: 'mini-timeline', 46 template: ` 47 <div class="mini-timeline-outer-wrapper"> 48 <div class="zoom-buttons"> 49 <button mat-icon-button id="zoom-in-btn" (click)="onZoomInButtonClick()"> 50 <mat-icon>zoom_in</mat-icon> 51 </button> 52 <button mat-icon-button id="zoom-out-btn" (click)="onZoomOutButtonClick()"> 53 <mat-icon>zoom_out</mat-icon> 54 </button> 55 <button mat-icon-button id="reset-zoom-btn" (click)="resetZoom()"> 56 <mat-icon>refresh</mat-icon> 57 </button> 58 </div> 59 <div id="mini-timeline-wrapper" #miniTimelineWrapper> 60 <canvas 61 #canvas 62 id="mini-timeline-canvas" 63 (mousemove)="trackMousePos($event)" 64 (mouseleave)="onMouseLeave($event)" 65 (contextmenu)="recordClickPosition($event)" 66 [cdkContextMenuTriggerFor]="timeline_context_menu" 67 #menuTrigger = "cdkContextMenuTriggerFor" 68 ></canvas> 69 <div class="zoom-control"> 70 <slider 71 [fullRange]="timelineData.getFullTimeRange()" 72 [zoomRange]="timelineData.getZoomRange()" 73 [currentPosition]="currentTracePosition" 74 [timestampConverter]="timelineData.getTimestampConverter()" 75 (onZoomChanged)="onSliderZoomChanged($event)"></slider> 76 </div> 77 </div> 78 </div> 79 80 <ng-template #timeline_context_menu> 81 <div class="context-menu" cdkMenu #timelineMenu="cdkMenu"> 82 <div class="context-menu-item-container"> 83 <span class="context-menu-item" (click)="toggleBookmark()" cdkMenuItem> {{getToggleBookmarkText()}} </span> 84 <span class="context-menu-item" (click)="removeAllBookmarks()" cdkMenuItem>Remove all bookmarks</span> 85 </div> 86 </div> 87 </ng-template> 88 `, 89 styles: [ 90 ` 91 .mini-timeline-outer-wrapper { 92 display: inline-flex; 93 width: 100%; 94 min-height: 5em; 95 height: 100%; 96 } 97 .zoom-buttons { 98 width: fit-content; 99 display: flex; 100 flex-direction: column; 101 align-items: center; 102 justify-content: center; 103 background-color: var(--drawer-color); 104 } 105 .zoom-buttons button { 106 width: fit-content; 107 } 108 #mini-timeline-wrapper { 109 width: 100%; 110 min-height: 5em; 111 height: 100%; 112 } 113 .zoom-control { 114 padding-right: ${MIN_SLIDER_WIDTH / 2}px; 115 margin-top: -10px; 116 } 117 .zoom-control slider { 118 flex-grow: 1; 119 } 120 `, 121 ], 122}) 123export class MiniTimelineComponent { 124 @Input() timelineData: TimelineData | undefined; 125 @Input() currentTracePosition: TracePosition | undefined; 126 @Input() selectedTraces: Array<Trace<object>> | undefined; 127 @Input() initialZoom: TimeRange | undefined; 128 @Input() expandedTimelineScrollEvent: WheelEvent | undefined; 129 @Input() expandedTimelineMouseXRatio: number | undefined; 130 @Input() bookmarks: Timestamp[] = []; 131 @Input() store: PersistentStore | undefined; 132 133 @Output() readonly onTracePositionUpdate = new EventEmitter<TracePosition>(); 134 @Output() readonly onSeekTimestampUpdate = new EventEmitter< 135 Timestamp | undefined 136 >(); 137 @Output() readonly onRemoveAllBookmarks = new EventEmitter<void>(); 138 @Output() readonly onToggleBookmark = new EventEmitter<{ 139 range: TimeRange; 140 rangeContainsBookmark: boolean; 141 }>(); 142 @Output() readonly onTraceClicked = new EventEmitter< 143 [Trace<object>, Timestamp] 144 >(); 145 146 @ViewChild('miniTimelineWrapper', {static: false}) 147 miniTimelineWrapper: ElementRef | undefined; 148 @ViewChild('canvas', {static: false}) canvasRef: ElementRef | undefined; 149 150 getCanvas(): HTMLCanvasElement { 151 return assertDefined(this.canvasRef).nativeElement; 152 } 153 154 drawer: MiniTimelineDrawer | undefined = undefined; 155 private lastMousePosX: number | undefined; 156 private hoverTimestamp: Timestamp | undefined; 157 private lastMoves: WheelEvent[] = []; 158 private lastRightClickTimeRange: TimeRange | undefined; 159 160 constructor( 161 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, 162 ) {} 163 164 recordClickPosition(event: MouseEvent) { 165 event.preventDefault(); 166 event.stopPropagation(); 167 const lastRightClickPos = {x: event.offsetX, y: event.offsetY}; 168 const drawer = assertDefined(this.drawer); 169 const clickRange = drawer.getClickRange(lastRightClickPos); 170 const zoomRange = assertDefined(this.timelineData).getZoomRange(); 171 const usableRange = drawer.getUsableRange(); 172 const transformer = new Transformer( 173 zoomRange, 174 usableRange, 175 assertDefined(this.timelineData?.getTimestampConverter()), 176 ); 177 this.lastRightClickTimeRange = new TimeRange( 178 transformer.untransform(clickRange.from), 179 transformer.untransform(clickRange.to), 180 ); 181 } 182 183 private static readonly SLIDER_HORIZONTAL_STEP = 30; 184 private static readonly SENSITIVITY_FACTOR = 5; 185 186 ngAfterViewInit(): void { 187 this.makeHiPPICanvas(); 188 189 const updateTimestampCallback = (timestamp: Timestamp) => { 190 this.onSeekTimestampUpdate.emit(undefined); 191 this.onTracePositionUpdate.emit( 192 assertDefined(this.timelineData).makePositionFromActiveTrace(timestamp), 193 ); 194 }; 195 196 const onClickCallback = ( 197 timestamp: Timestamp, 198 trace: Trace<object> | undefined, 199 ) => { 200 if (trace) { 201 this.onTraceClicked.emit([trace, timestamp]); 202 this.onSeekTimestampUpdate.emit(undefined); 203 } else { 204 updateTimestampCallback(timestamp); 205 } 206 }; 207 208 this.drawer = new MiniTimelineDrawerImpl( 209 this.getCanvas(), 210 () => this.getMiniCanvasDrawerInput(), 211 (position) => this.onSeekTimestampUpdate.emit(position), 212 updateTimestampCallback, 213 onClickCallback, 214 ); 215 216 if (this.initialZoom !== undefined) { 217 this.onZoomChanged(this.initialZoom); 218 } else { 219 this.resetZoom(); 220 } 221 } 222 223 ngOnChanges(changes: SimpleChanges) { 224 if (!this.drawer) { 225 return; 226 } 227 const singleChange = Object.keys(changes).length === 1; 228 if (changes['expandedTimelineMouseXRatio']) { 229 const mouseXRatio: number | undefined = 230 changes['expandedTimelineMouseXRatio'].currentValue; 231 this.lastMousePosX = mouseXRatio 232 ? mouseXRatio * this.drawer.getWidth() 233 : undefined; 234 this.updateHoverTimestamp(); 235 if (singleChange) { 236 return; 237 } 238 } 239 if (changes['expandedTimelineScrollEvent']?.currentValue) { 240 const event = changes['expandedTimelineScrollEvent'].currentValue; 241 const moveDirection = this.getMoveDirection(event); 242 243 if (event.deltaY !== 0 && moveDirection === 'y') { 244 this.updateZoomByScrollEvent(event); 245 } 246 247 if (event.deltaX !== 0 && moveDirection === 'x') { 248 this.updateHorizontalScroll(event); 249 } 250 if (singleChange) { 251 return; 252 } 253 } 254 this.drawer.draw(); 255 } 256 257 getTracesToShow(): Array<Trace<object>> { 258 return assertDefined(this.selectedTraces) 259 .slice() 260 .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) 261 .reverse(); // reversed to ensure display is ordered top to bottom 262 } 263 264 @HostListener('window:resize', ['$event']) 265 onResize(event: Event) { 266 this.makeHiPPICanvas(); 267 this.drawer?.draw(); 268 } 269 270 trackMousePos(event: MouseEvent) { 271 this.lastMousePosX = event.offsetX; 272 this.updateHoverTimestamp(); 273 } 274 275 onMouseLeave(event: MouseEvent) { 276 this.lastMousePosX = undefined; 277 this.updateHoverTimestamp(); 278 } 279 280 updateHoverTimestamp() { 281 if (!this.lastMousePosX) { 282 this.hoverTimestamp = undefined; 283 return; 284 } 285 const timelineData = assertDefined(this.timelineData); 286 this.hoverTimestamp = new Transformer( 287 timelineData.getZoomRange(), 288 assertDefined(this.drawer).getUsableRange(), 289 assertDefined(timelineData.getTimestampConverter()), 290 ).untransform(this.lastMousePosX); 291 } 292 293 @HostListener('document:keydown', ['$event']) 294 async handleKeyboardEvent(event: KeyboardEvent) { 295 if ((event.target as HTMLElement).tagName === 'INPUT') { 296 return; 297 } 298 if (event.code === 'KeyA') { 299 this.updateSliderPosition(-MiniTimelineComponent.SLIDER_HORIZONTAL_STEP); 300 } 301 if (event.code === 'KeyD') { 302 this.updateSliderPosition(MiniTimelineComponent.SLIDER_HORIZONTAL_STEP); 303 } 304 305 if (event.code !== 'KeyW' && event.code !== 'KeyS') { 306 return; 307 } 308 309 const zoomTo = this.hoverTimestamp; 310 const isZoomIn = event.code === 'KeyW'; 311 Analytics.Navigation.logZoom('key', 'timeline', isZoomIn ? 'in' : 'out'); 312 isZoomIn ? this.zoomIn(zoomTo) : this.zoomOut(zoomTo); 313 } 314 315 onZoomChanged(zoom: TimeRange) { 316 const timelineData = assertDefined(this.timelineData); 317 timelineData.setZoom(zoom); 318 timelineData.setSelectionTimeRange(zoom); 319 this.drawer?.draw(); 320 this.changeDetectorRef.detectChanges(); 321 } 322 323 onSliderZoomChanged(zoom: TimeRange) { 324 this.onZoomChanged(zoom); 325 this.updateHoverTimestamp(); 326 } 327 328 resetZoom() { 329 Analytics.Navigation.logZoom('reset', 'timeline'); 330 this.onZoomChanged( 331 this.initialZoom ?? assertDefined(this.timelineData).getFullTimeRange(), 332 ); 333 } 334 335 onZoomInButtonClick() { 336 Analytics.Navigation.logZoom('button', 'timeline', 'in'); 337 this.zoomIn(); 338 } 339 340 onZoomOutButtonClick() { 341 Analytics.Navigation.logZoom('button', 'timeline', 'out'); 342 this.zoomOut(); 343 } 344 345 @HostListener('wheel', ['$event']) 346 onScroll(event: WheelEvent) { 347 const moveDirection = this.getMoveDirection(event); 348 349 if ( 350 (event.target as HTMLElement)?.id === 'mini-timeline-canvas' && 351 event.deltaY !== 0 && 352 moveDirection === 'y' 353 ) { 354 this.updateZoomByScrollEvent(event); 355 } 356 357 if (event.deltaX !== 0 && moveDirection === 'x') { 358 this.updateHorizontalScroll(event); 359 } 360 } 361 362 toggleBookmark() { 363 if (!this.lastRightClickTimeRange) { 364 return; 365 } 366 this.onToggleBookmark.emit({ 367 range: this.lastRightClickTimeRange, 368 rangeContainsBookmark: this.bookmarks.some((bookmark) => { 369 return assertDefined(this.lastRightClickTimeRange).containsTimestamp( 370 bookmark, 371 ); 372 }), 373 }); 374 } 375 376 getToggleBookmarkText() { 377 if (!this.lastRightClickTimeRange) { 378 return 'Add/remove bookmark'; 379 } 380 381 const rangeContainsBookmark = this.bookmarks.some((bookmark) => { 382 return assertDefined(this.lastRightClickTimeRange).containsTimestamp( 383 bookmark, 384 ); 385 }); 386 if (rangeContainsBookmark) { 387 return 'Remove bookmark'; 388 } 389 390 return 'Add bookmark'; 391 } 392 393 removeAllBookmarks() { 394 this.onRemoveAllBookmarks.emit(); 395 } 396 397 private getMiniCanvasDrawerInput() { 398 const timelineData = assertDefined(this.timelineData); 399 return new MiniTimelineDrawerInput( 400 timelineData.getFullTimeRange(), 401 assertDefined(this.currentTracePosition).timestamp, 402 timelineData.getSelectionTimeRange(), 403 timelineData.getZoomRange(), 404 this.getTracesToShow(), 405 timelineData, 406 this.bookmarks, 407 this.store?.get('dark-mode') === 'true', 408 ); 409 } 410 411 private makeHiPPICanvas() { 412 // Reset any size before computing new size to avoid it interfering with size computations 413 const canvas = this.getCanvas(); 414 canvas.width = 0; 415 canvas.height = 0; 416 canvas.style.width = 'auto'; 417 canvas.style.height = 'auto'; 418 419 const miniTimelineWrapper = assertDefined(this.miniTimelineWrapper); 420 const width = miniTimelineWrapper.nativeElement.offsetWidth; 421 const height = miniTimelineWrapper.nativeElement.offsetHeight; 422 423 const HiPPIwidth = window.devicePixelRatio * width; 424 const HiPPIheight = window.devicePixelRatio * height; 425 426 canvas.width = HiPPIwidth; 427 canvas.height = HiPPIheight; 428 canvas.style.width = width + 'px'; 429 canvas.style.height = height + 'px'; 430 431 // ensure all drawing operations are scaled 432 if (window.devicePixelRatio !== 1) { 433 const context = canvas.getContext('2d')!; 434 context.scale(window.devicePixelRatio, window.devicePixelRatio); 435 } 436 } 437 438 // -1 for x direction, 1 for y direction 439 private getMoveDirection(event: WheelEvent): string { 440 this.lastMoves.push(event); 441 setTimeout(() => this.lastMoves.shift(), 1000); 442 443 const xMoveAmount = this.lastMoves.reduce( 444 (accumulator, it) => accumulator + it.deltaX, 445 0, 446 ); 447 const yMoveAmount = this.lastMoves.reduce( 448 (accumulator, it) => accumulator + it.deltaY, 449 0, 450 ); 451 452 if (Math.abs(yMoveAmount) > Math.abs(xMoveAmount)) { 453 return 'y'; 454 } else { 455 return 'x'; 456 } 457 } 458 459 private updateZoomByScrollEvent(event: WheelEvent) { 460 if (!this.hoverTimestamp) { 461 const canvas = event.target as HTMLCanvasElement; 462 const drawer = assertDefined(this.drawer); 463 this.lastMousePosX = 464 (drawer.getWidth() * event.offsetX) / canvas.offsetWidth; 465 this.updateHoverTimestamp(); 466 } 467 const isZoomIn = event.deltaY < 0; 468 Analytics.Navigation.logZoom('scroll', 'timeline', isZoomIn ? 'in' : 'out'); 469 if (isZoomIn) { 470 this.zoomIn(this.hoverTimestamp); 471 } else { 472 this.zoomOut(this.hoverTimestamp); 473 } 474 } 475 476 private updateHorizontalScroll(event: WheelEvent) { 477 const scrollAmount = 478 event.deltaX / MiniTimelineComponent.SENSITIVITY_FACTOR; 479 this.updateSliderPosition(scrollAmount); 480 } 481 482 private updateSliderPosition(step: number) { 483 const timelineData = assertDefined(this.timelineData); 484 const fullRange = timelineData.getFullTimeRange(); 485 const zoomRange = timelineData.getZoomRange(); 486 487 const usableRange = assertDefined(this.drawer).getUsableRange(); 488 const transformer = new Transformer( 489 zoomRange, 490 usableRange, 491 assertDefined(timelineData.getTimestampConverter()), 492 ); 493 const shiftAmount = transformer 494 .untransform(usableRange.from + step) 495 .minus(zoomRange.from.getValueNs()); 496 497 let newFrom = zoomRange.from.add(shiftAmount.getValueNs()); 498 let newTo = zoomRange.to.add(shiftAmount.getValueNs()); 499 500 if (newFrom.getValueNs() < fullRange.from.getValueNs()) { 501 newTo = newTo.add( 502 fullRange.from.minus(newFrom.getValueNs()).getValueNs(), 503 ); 504 newFrom = fullRange.from; 505 } 506 507 if (newTo.getValueNs() > fullRange.to.getValueNs()) { 508 newFrom = newFrom.minus( 509 newTo.minus(fullRange.to.getValueNs()).getValueNs(), 510 ); 511 newTo = fullRange.to; 512 } 513 514 this.onZoomChanged(new TimeRange(newFrom, newTo)); 515 this.updateHoverTimestamp(); 516 } 517 518 private zoomIn(zoomOn?: Timestamp) { 519 this.zoom({nominator: 6n, denominator: 7n}, zoomOn); 520 } 521 522 private zoomOut(zoomOn?: Timestamp) { 523 this.zoom({nominator: 8n, denominator: 7n}, zoomOn); 524 } 525 526 private zoom( 527 zoomRatio: {nominator: bigint; denominator: bigint}, 528 zoomOn?: Timestamp, 529 ) { 530 const timelineData = assertDefined(this.timelineData); 531 const fullRange = timelineData.getFullTimeRange(); 532 const currentZoomRange = timelineData.getZoomRange(); 533 const currentZoomWidth = currentZoomRange.to.minus( 534 currentZoomRange.from.getValueNs(), 535 ); 536 const zoomToWidth = currentZoomWidth 537 .times(zoomRatio.nominator) 538 .div(zoomRatio.denominator); 539 540 const cursorPosition = this.currentTracePosition?.timestamp; 541 const currentMiddle = currentZoomRange.from 542 .add(currentZoomRange.to.getValueNs()) 543 .div(2n); 544 545 let newFrom: Timestamp; 546 let newTo: Timestamp; 547 548 let zoomTowards = currentMiddle; 549 if (zoomOn === undefined) { 550 if (cursorPosition !== undefined && cursorPosition.in(currentZoomRange)) { 551 zoomTowards = cursorPosition; 552 } 553 } else if (zoomOn.in(currentZoomRange)) { 554 zoomTowards = zoomOn; 555 } 556 557 newFrom = zoomTowards.minus( 558 zoomToWidth 559 .times( 560 zoomTowards.minus(currentZoomRange.from.getValueNs()).getValueNs(), 561 ) 562 .div(currentZoomWidth.getValueNs()) 563 .getValueNs(), 564 ); 565 566 newTo = zoomTowards.add( 567 zoomToWidth 568 .times(currentZoomRange.to.minus(zoomTowards.getValueNs()).getValueNs()) 569 .div(currentZoomWidth.getValueNs()) 570 .getValueNs(), 571 ); 572 573 if (newFrom.getValueNs() < fullRange.from.getValueNs()) { 574 newTo = TimestampUtils.min( 575 fullRange.to, 576 newFrom.add(zoomToWidth.getValueNs()), 577 ); 578 newFrom = fullRange.from; 579 } 580 581 if (newTo.getValueNs() > fullRange.to.getValueNs()) { 582 newFrom = TimestampUtils.max( 583 fullRange.from, 584 fullRange.to.minus(zoomToWidth.getValueNs()), 585 ); 586 newTo = fullRange.to; 587 } 588 589 this.onZoomChanged(new TimeRange(newFrom, newTo)); 590 } 591} 592