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/persistent_store'; 32import {TimeRange, Timestamp} from 'common/time'; 33import {TimestampUtils} from 'common/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 (changes['expandedTimelineScrollEvent']?.currentValue) { 225 const event = changes['expandedTimelineScrollEvent'].currentValue; 226 const moveDirection = this.getMoveDirection(event); 227 228 if (event.deltaY !== 0 && moveDirection === 'y') { 229 this.updateZoomByScrollEvent(event); 230 } 231 232 if (event.deltaX !== 0 && moveDirection === 'x') { 233 this.updateHorizontalScroll(event); 234 } 235 } else if (this.drawer && changes['expandedTimelineMouseXRatio']) { 236 const mouseXRatio: number | undefined = 237 changes['expandedTimelineMouseXRatio'].currentValue; 238 this.lastMousePosX = mouseXRatio 239 ? mouseXRatio * this.drawer.getWidth() 240 : undefined; 241 this.updateHoverTimestamp(); 242 } else if (this.drawer !== undefined) { 243 this.drawer.draw(); 244 } 245 } 246 247 getTracesToShow(): Array<Trace<object>> { 248 return assertDefined(this.selectedTraces) 249 .slice() 250 .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) 251 .reverse(); // reversed to ensure display is ordered top to bottom 252 } 253 254 @HostListener('window:resize', ['$event']) 255 onResize(event: Event) { 256 this.makeHiPPICanvas(); 257 this.drawer?.draw(); 258 } 259 260 trackMousePos(event: MouseEvent) { 261 this.lastMousePosX = event.offsetX; 262 this.updateHoverTimestamp(); 263 } 264 265 onMouseLeave(event: MouseEvent) { 266 this.lastMousePosX = undefined; 267 this.updateHoverTimestamp(); 268 } 269 270 updateHoverTimestamp() { 271 if (!this.lastMousePosX) { 272 this.hoverTimestamp = undefined; 273 return; 274 } 275 const timelineData = assertDefined(this.timelineData); 276 this.hoverTimestamp = new Transformer( 277 timelineData.getZoomRange(), 278 assertDefined(this.drawer).getUsableRange(), 279 assertDefined(timelineData.getTimestampConverter()), 280 ).untransform(this.lastMousePosX); 281 } 282 283 @HostListener('document:keydown', ['$event']) 284 async handleKeyboardEvent(event: KeyboardEvent) { 285 if ((event.target as HTMLElement).tagName === 'INPUT') { 286 return; 287 } 288 if (event.code === 'KeyA') { 289 this.updateSliderPosition(-MiniTimelineComponent.SLIDER_HORIZONTAL_STEP); 290 } 291 if (event.code === 'KeyD') { 292 this.updateSliderPosition(MiniTimelineComponent.SLIDER_HORIZONTAL_STEP); 293 } 294 295 if (event.code !== 'KeyW' && event.code !== 'KeyS') { 296 return; 297 } 298 299 const zoomTo = this.hoverTimestamp; 300 const isZoomIn = event.code === 'KeyW'; 301 Analytics.Navigation.logZoom('key', 'timeline', isZoomIn ? 'in' : 'out'); 302 isZoomIn ? this.zoomIn(zoomTo) : this.zoomOut(zoomTo); 303 } 304 305 onZoomChanged(zoom: TimeRange) { 306 const timelineData = assertDefined(this.timelineData); 307 timelineData.setZoom(zoom); 308 timelineData.setSelectionTimeRange(zoom); 309 this.drawer?.draw(); 310 this.changeDetectorRef.detectChanges(); 311 } 312 313 onSliderZoomChanged(zoom: TimeRange) { 314 this.onZoomChanged(zoom); 315 this.updateHoverTimestamp(); 316 } 317 318 resetZoom() { 319 Analytics.Navigation.logZoom('reset', 'timeline'); 320 this.onZoomChanged( 321 this.initialZoom ?? assertDefined(this.timelineData).getFullTimeRange(), 322 ); 323 } 324 325 onZoomInButtonClick() { 326 Analytics.Navigation.logZoom('button', 'timeline', 'in'); 327 this.zoomIn(); 328 } 329 330 onZoomOutButtonClick() { 331 Analytics.Navigation.logZoom('button', 'timeline', 'out'); 332 this.zoomOut(); 333 } 334 335 @HostListener('wheel', ['$event']) 336 onScroll(event: WheelEvent) { 337 const moveDirection = this.getMoveDirection(event); 338 339 if ( 340 (event.target as HTMLElement)?.id === 'mini-timeline-canvas' && 341 event.deltaY !== 0 && 342 moveDirection === 'y' 343 ) { 344 this.updateZoomByScrollEvent(event); 345 } 346 347 if (event.deltaX !== 0 && moveDirection === 'x') { 348 this.updateHorizontalScroll(event); 349 } 350 } 351 352 toggleBookmark() { 353 if (!this.lastRightClickTimeRange) { 354 return; 355 } 356 this.onToggleBookmark.emit({ 357 range: this.lastRightClickTimeRange, 358 rangeContainsBookmark: this.bookmarks.some((bookmark) => { 359 return assertDefined(this.lastRightClickTimeRange).containsTimestamp( 360 bookmark, 361 ); 362 }), 363 }); 364 } 365 366 getToggleBookmarkText() { 367 if (!this.lastRightClickTimeRange) { 368 return 'Add/remove bookmark'; 369 } 370 371 const rangeContainsBookmark = this.bookmarks.some((bookmark) => { 372 return assertDefined(this.lastRightClickTimeRange).containsTimestamp( 373 bookmark, 374 ); 375 }); 376 if (rangeContainsBookmark) { 377 return 'Remove bookmark'; 378 } 379 380 return 'Add bookmark'; 381 } 382 383 removeAllBookmarks() { 384 this.onRemoveAllBookmarks.emit(); 385 } 386 387 private getMiniCanvasDrawerInput() { 388 const timelineData = assertDefined(this.timelineData); 389 return new MiniTimelineDrawerInput( 390 timelineData.getFullTimeRange(), 391 assertDefined(this.currentTracePosition).timestamp, 392 timelineData.getSelectionTimeRange(), 393 timelineData.getZoomRange(), 394 this.getTracesToShow(), 395 timelineData, 396 this.bookmarks, 397 this.store?.get('dark-mode') === 'true', 398 ); 399 } 400 401 private makeHiPPICanvas() { 402 // Reset any size before computing new size to avoid it interfering with size computations 403 const canvas = this.getCanvas(); 404 canvas.width = 0; 405 canvas.height = 0; 406 canvas.style.width = 'auto'; 407 canvas.style.height = 'auto'; 408 409 const miniTimelineWrapper = assertDefined(this.miniTimelineWrapper); 410 const width = miniTimelineWrapper.nativeElement.offsetWidth; 411 const height = miniTimelineWrapper.nativeElement.offsetHeight; 412 413 const HiPPIwidth = window.devicePixelRatio * width; 414 const HiPPIheight = window.devicePixelRatio * height; 415 416 canvas.width = HiPPIwidth; 417 canvas.height = HiPPIheight; 418 canvas.style.width = width + 'px'; 419 canvas.style.height = height + 'px'; 420 421 // ensure all drawing operations are scaled 422 if (window.devicePixelRatio !== 1) { 423 const context = canvas.getContext('2d')!; 424 context.scale(window.devicePixelRatio, window.devicePixelRatio); 425 } 426 } 427 428 // -1 for x direction, 1 for y direction 429 private getMoveDirection(event: WheelEvent): string { 430 this.lastMoves.push(event); 431 setTimeout(() => this.lastMoves.shift(), 1000); 432 433 const xMoveAmount = this.lastMoves.reduce( 434 (accumulator, it) => accumulator + it.deltaX, 435 0, 436 ); 437 const yMoveAmount = this.lastMoves.reduce( 438 (accumulator, it) => accumulator + it.deltaY, 439 0, 440 ); 441 442 if (Math.abs(yMoveAmount) > Math.abs(xMoveAmount)) { 443 return 'y'; 444 } else { 445 return 'x'; 446 } 447 } 448 449 private updateZoomByScrollEvent(event: WheelEvent) { 450 if (!this.hoverTimestamp) { 451 const canvas = event.target as HTMLCanvasElement; 452 const drawer = assertDefined(this.drawer); 453 this.lastMousePosX = 454 (drawer.getWidth() * event.offsetX) / canvas.offsetWidth; 455 this.updateHoverTimestamp(); 456 } 457 const isZoomIn = event.deltaY < 0; 458 Analytics.Navigation.logZoom('scroll', 'timeline', isZoomIn ? 'in' : 'out'); 459 if (isZoomIn) { 460 this.zoomIn(this.hoverTimestamp); 461 } else { 462 this.zoomOut(this.hoverTimestamp); 463 } 464 } 465 466 private updateHorizontalScroll(event: WheelEvent) { 467 const scrollAmount = 468 event.deltaX / MiniTimelineComponent.SENSITIVITY_FACTOR; 469 this.updateSliderPosition(scrollAmount); 470 } 471 472 private updateSliderPosition(step: number) { 473 const timelineData = assertDefined(this.timelineData); 474 const fullRange = timelineData.getFullTimeRange(); 475 const zoomRange = timelineData.getZoomRange(); 476 477 const usableRange = assertDefined(this.drawer).getUsableRange(); 478 const transformer = new Transformer( 479 zoomRange, 480 usableRange, 481 assertDefined(timelineData.getTimestampConverter()), 482 ); 483 const shiftAmount = transformer 484 .untransform(usableRange.from + step) 485 .minus(zoomRange.from.getValueNs()); 486 487 let newFrom = zoomRange.from.add(shiftAmount.getValueNs()); 488 let newTo = zoomRange.to.add(shiftAmount.getValueNs()); 489 490 if (newFrom.getValueNs() < fullRange.from.getValueNs()) { 491 newTo = newTo.add( 492 fullRange.from.minus(newFrom.getValueNs()).getValueNs(), 493 ); 494 newFrom = fullRange.from; 495 } 496 497 if (newTo.getValueNs() > fullRange.to.getValueNs()) { 498 newFrom = newFrom.minus( 499 newTo.minus(fullRange.to.getValueNs()).getValueNs(), 500 ); 501 newTo = fullRange.to; 502 } 503 504 this.onZoomChanged(new TimeRange(newFrom, newTo)); 505 this.updateHoverTimestamp(); 506 } 507 508 private zoomIn(zoomOn?: Timestamp) { 509 this.zoom({nominator: 6n, denominator: 7n}, zoomOn); 510 } 511 512 private zoomOut(zoomOn?: Timestamp) { 513 this.zoom({nominator: 8n, denominator: 7n}, zoomOn); 514 } 515 516 private zoom( 517 zoomRatio: {nominator: bigint; denominator: bigint}, 518 zoomOn?: Timestamp, 519 ) { 520 const timelineData = assertDefined(this.timelineData); 521 const fullRange = timelineData.getFullTimeRange(); 522 const currentZoomRange = timelineData.getZoomRange(); 523 const currentZoomWidth = currentZoomRange.to.minus( 524 currentZoomRange.from.getValueNs(), 525 ); 526 const zoomToWidth = currentZoomWidth 527 .times(zoomRatio.nominator) 528 .div(zoomRatio.denominator); 529 530 const cursorPosition = this.currentTracePosition?.timestamp; 531 const currentMiddle = currentZoomRange.from 532 .add(currentZoomRange.to.getValueNs()) 533 .div(2n); 534 535 let newFrom: Timestamp; 536 let newTo: Timestamp; 537 538 let zoomTowards = currentMiddle; 539 if (zoomOn === undefined) { 540 if (cursorPosition !== undefined && cursorPosition.in(currentZoomRange)) { 541 zoomTowards = cursorPosition; 542 } 543 } else if (zoomOn.in(currentZoomRange)) { 544 zoomTowards = zoomOn; 545 } 546 547 newFrom = zoomTowards.minus( 548 zoomToWidth 549 .times( 550 zoomTowards.minus(currentZoomRange.from.getValueNs()).getValueNs(), 551 ) 552 .div(currentZoomWidth.getValueNs()) 553 .getValueNs(), 554 ); 555 556 newTo = zoomTowards.add( 557 zoomToWidth 558 .times(currentZoomRange.to.minus(zoomTowards.getValueNs()).getValueNs()) 559 .div(currentZoomWidth.getValueNs()) 560 .getValueNs(), 561 ); 562 563 if (newFrom.getValueNs() < fullRange.from.getValueNs()) { 564 newTo = TimestampUtils.min( 565 fullRange.to, 566 newFrom.add(zoomToWidth.getValueNs()), 567 ); 568 newFrom = fullRange.from; 569 } 570 571 if (newTo.getValueNs() > fullRange.to.getValueNs()) { 572 newFrom = TimestampUtils.max( 573 fullRange.from, 574 fullRange.to.minus(zoomToWidth.getValueNs()), 575 ); 576 newTo = fullRange.to; 577 } 578 579 this.onZoomChanged(new TimeRange(newFrom, newTo)); 580 } 581} 582