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