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 ViewChild, 27 ViewEncapsulation, 28} from '@angular/core'; 29import { 30 AbstractControl, 31 FormControl, 32 FormGroup, 33 ValidationErrors, 34 ValidatorFn, 35 Validators, 36} from '@angular/forms'; 37import {DomSanitizer, SafeUrl} from '@angular/platform-browser'; 38import {TimelineData} from 'app/timeline_data'; 39import {assertDefined} from 'common/assert_utils'; 40import {FunctionUtils} from 'common/function_utils'; 41import {PersistentStore} from 'common/persistent_store'; 42import {StringUtils} from 'common/string_utils'; 43import {TimeRange, Timestamp, TimestampFormatType} from 'common/time'; 44import {TimestampUtils} from 'common/timestamp_utils'; 45import {Analytics} from 'logging/analytics'; 46import { 47 ActiveTraceChanged, 48 ExpandedTimelineToggled, 49 TracePositionUpdate, 50 WinscopeEvent, 51 WinscopeEventType, 52} from 'messaging/winscope_event'; 53import { 54 EmitEvent, 55 WinscopeEventEmitter, 56} from 'messaging/winscope_event_emitter'; 57import {WinscopeEventListener} from 'messaging/winscope_event_listener'; 58import {Trace} from 'trace/trace'; 59import {Traces} from 'trace/traces'; 60import {TRACE_INFO} from 'trace/trace_info'; 61import {TracePosition} from 'trace/trace_position'; 62import {TraceType, TraceTypeUtils} from 'trace/trace_type'; 63import {multlineTooltip} from 'viewers/components/styles/tooltip.styles'; 64import {MiniTimelineComponent} from './mini-timeline/mini_timeline_component'; 65 66@Component({ 67 selector: 'timeline', 68 encapsulation: ViewEncapsulation.None, 69 template: ` 70 <div 71 *ngIf="isDisabled" 72 class="disabled-message user-notification mat-body-1"> Timeline disabled due to ongoing search query </div> 73 <div [class.disabled-component]="isDisabled"> 74 <div id="toggle" *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()"> 75 <button 76 mat-icon-button 77 [class]="TOGGLE_BUTTON_CLASS" 78 color="basic" 79 aria-label="Toggle Expanded Timeline" 80 (click)="toggleExpand()"> 81 <mat-icon *ngIf="!expanded" class="material-symbols-outlined">expand_circle_up</mat-icon> 82 <mat-icon *ngIf="expanded" class="material-symbols-outlined">expand_circle_down</mat-icon> 83 </button> 84 </div> 85 <div id="expanded-nav" *ngIf="expanded"> 86 <div id="video-content" *ngIf="videoUrl !== undefined"> 87 <video 88 *ngIf="getVideoCurrentTime() !== undefined" 89 id="video" 90 [currentTime]="getVideoCurrentTime()" 91 [src]="videoUrl"></video> 92 <div *ngIf="getVideoCurrentTime() === undefined" class="no-video-message"> 93 <p>No screenrecording frame to show</p> 94 <p>Current timestamp before first screenrecording frame.</p> 95 </div> 96 </div> 97 <expanded-timeline 98 [timelineData]="timelineData" 99 (onTracePositionUpdate)="updatePosition($event)" 100 (onScrollEvent)="updateScrollEvent($event)" 101 (onTraceClicked)="onExpandedTimelineTraceClicked($event)" 102 (onMouseXRatioUpdate)="updateExpandedTimelineMouseXRatio($event)" 103 id="expanded-timeline"></expanded-timeline> 104 </div> 105 <div class="navbar-toggle"> 106 <div class="navbar" #collapsedTimeline> 107 <ng-template [ngIf]="timelineData.hasTimestamps()"> 108 <div id="time-selector"> 109 <form [formGroup]="timestampForm" class="time-selector-form"> 110 <mat-form-field 111 class="time-input human" 112 appearance="fill" 113 (keydown.esc)="$event.target.blur()" 114 (keydown.enter)="onKeydownEnterTimeInputField($event)" 115 (change)="onHumanTimeInputChange($event)"> 116 <mat-icon 117 [matTooltip]="getHumanTimeTooltip()" 118 matTooltipClass="multline-tooltip" 119 matPrefix>schedule</mat-icon> 120 <input 121 matInput 122 name="humanTimeInput" 123 [formControl]="selectedTimeFormControl" /> 124 <div class="field-suffix" matSuffix> 125 <span class="time-difference"> {{ getUTCOffset() }} </span> 126 <button 127 mat-icon-button 128 [matTooltip]="getCopyHumanTimeTooltip()" 129 matTooltipClass="multline-tooltip" 130 [cdkCopyToClipboard]="getHumanTime()" 131 (cdkCopyToClipboardCopied)="onTimeCopied('human')" 132 matSuffix> 133 <mat-icon>content_copy</mat-icon> 134 </button> 135 </div> 136 </mat-form-field> 137 <mat-form-field 138 class="time-input nano" 139 appearance="fill" 140 (keydown.esc)="$event.target.blur()" 141 (keydown.enter)="onKeydownEnterNanosecondsTimeInputField($event)" 142 (change)="onNanosecondsInputTimeChange($event)"> 143 <mat-icon 144 class="bookmark-icon" 145 [class.material-symbols-outlined]="!currentPositionBookmarked()" 146 matTooltip="bookmark timestamp" 147 (click)="toggleBookmarkCurrentPosition($event)" 148 matPrefix>flag</mat-icon> 149 <input matInput name="nsTimeInput" [formControl]="selectedNsFormControl" /> 150 <div class="field-suffix" matSuffix> 151 <button 152 mat-icon-button 153 [matTooltip]="getCopyPositionTooltip(selectedNsFormControl.value)" 154 matTooltipClass="multline-tooltip" 155 [cdkCopyToClipboard]="selectedNsFormControl.value" 156 (cdkCopyToClipboardCopied)="onTimeCopied('ns')" 157 matSuffix> 158 <mat-icon>content_copy</mat-icon> 159 </button> 160 </div> 161 </mat-form-field> 162 </form> 163 <div class="time-controls"> 164 <button 165 mat-icon-button 166 id="prev_entry_button" 167 matTooltip="Go to previous entry" 168 (click)="moveToPreviousEntry()" 169 [class.disabled]="!hasPrevEntry()" 170 [disabled]="!hasPrevEntry()"> 171 <mat-icon>chevron_left</mat-icon> 172 </button> 173 <button 174 mat-icon-button 175 id="next_entry_button" 176 matTooltip="Go to next entry" 177 (click)="moveToNextEntry()" 178 [class.disabled]="!hasNextEntry()" 179 [disabled]="!hasNextEntry()"> 180 <mat-icon>chevron_right</mat-icon> 181 </button> 182 </div> 183 </div> 184 <div id="trace-selector"> 185 <mat-form-field appearance="none"> 186 <mat-select #traceSelector [formControl]="selectedTracesFormControl" multiple> 187 <div class="select-traces-panel"> 188 <div class="tip">Filter traces in the timeline</div> 189 <mat-option 190 *ngFor="let trace of sortedTraces" 191 [value]="trace" 192 [style]="{ 193 color: 'var(--blue-text-color)', 194 opacity: isOptionDisabled(trace) ? 0.5 : 1.0 195 }" 196 [disabled]="isOptionDisabled(trace)" 197 (click)="applyNewTraceSelection(trace)"> 198 <mat-icon 199 [style]="{ 200 color: TRACE_INFO[trace.type].color 201 }" 202 >{{ TRACE_INFO[trace.type].icon }}</mat-icon> 203 {{ getTitle(trace) }} 204 </mat-option> 205 <div class="actions"> 206 <button mat-flat-button color="primary" (click)="traceSelector.close()"> 207 Done 208 </button> 209 </div> 210 </div> 211 <mat-select-trigger class="shown-selection"> 212 <div class="filter-header"> 213 <span class="mat-body-2"> Filter </span> 214 <mat-icon class="material-symbols-outlined">expand_circle_up</mat-icon> 215 </div> 216 217 <div class="trace-icons"> 218 <mat-icon 219 class="trace-icon" 220 *ngFor="let selectedTrace of getSelectedTracesToShow()" 221 [style]="{color: TRACE_INFO[selectedTrace.type].color}" 222 [matTooltip]="getTraceTooltip(selectedTrace)" 223 #tooltip="matTooltip" 224 (mouseenter)="tooltip.disabled = false" 225 (mouseleave)="tooltip.disabled = true"> 226 {{ TRACE_INFO[selectedTrace.type].icon }} 227 </mat-icon> 228 <mat-icon 229 class="trace-icon" 230 *ngIf="selectedTraces.length > 8"> 231 more_horiz 232 </mat-icon> 233 </div> 234 </mat-select-trigger> 235 </mat-select> 236 </mat-form-field> 237 </div> 238 <mini-timeline 239 *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()" 240 [timelineData]="timelineData" 241 [currentTracePosition]="getCurrentTracePosition()" 242 [selectedTraces]="selectedTraces" 243 [initialZoom]="initialZoom" 244 [expandedTimelineScrollEvent]="expandedTimelineScrollEvent" 245 [expandedTimelineMouseXRatio]="expandedTimelineMouseXRatio" 246 [bookmarks]="bookmarks" 247 [store]="store" 248 (onTracePositionUpdate)="updatePosition($event)" 249 (onSeekTimestampUpdate)="updateSeekTimestamp($event)" 250 (onRemoveAllBookmarks)="removeAllBookmarks()" 251 (onToggleBookmark)="toggleBookmarkRange($event.range, $event.rangeContainsBookmark)" 252 (onTraceClicked)="onMiniTimelineTraceClicked($event)" 253 id="mini-timeline" 254 #miniTimeline></mini-timeline> 255 </ng-template> 256 <div 257 *ngIf="!timelineData.hasMoreThanOneDistinctTimestamp()" 258 class="no-timeline-msg"> 259 <p class="mat-body-2">No timeline to show!</p> 260 <p 261 *ngIf="timelineData.hasTimestamps()" 262 class="mat-body-1">Only a single timestamp has been recorded.</p> 263 <p 264 *ngIf="!timelineData.hasTimestamps()" 265 class="mat-body-1">All loaded traces contain no timestamps.</p> 266 </div> 267 </div> 268 </div> 269 </div> 270 `, 271 styles: [ 272 ` 273 .navbar-toggle { 274 display: flex; 275 flex-direction: column; 276 align-items: end; 277 position: relative; 278 max-height: 20vh; 279 overflow: auto; 280 } 281 #toggle { 282 width: fit-content; 283 position: absolute; 284 top: -41px; 285 right: 0px; 286 z-index: 1000; 287 border: 1px solid #3333; 288 border-bottom: 0px; 289 border-right: 0px; 290 border-top-left-radius: 6px; 291 border-top-right-radius: 6px; 292 background-color: var(--drawer-color); 293 } 294 .navbar { 295 display: flex; 296 width: 100%; 297 flex-direction: row; 298 align-items: center; 299 justify-content: center; 300 } 301 #expanded-nav { 302 display: flex; 303 flex-direction: row; 304 border-bottom: 1px solid #3333; 305 border-top: 1px solid #3333; 306 max-height: 60vh; 307 overflow: hidden; 308 } 309 #time-selector { 310 display: flex; 311 flex-direction: column; 312 align-items: center; 313 justify-content: center; 314 border-radius: 10px; 315 margin-left: 0.5rem; 316 height: 116px; 317 width: 282px; 318 background-color: var(--drawer-block-primary); 319 } 320 #time-selector .mat-form-field-wrapper { 321 width: 100%; 322 } 323 #time-selector .mat-form-field-infix, #trace-selector .mat-form-field-infix { 324 padding: 0 0.75rem 0 0.5rem !important; 325 border-top: unset; 326 } 327 #time-selector .mat-form-field-flex, #time-selector .field-suffix { 328 border-radius: 0; 329 padding: 0; 330 display: flex; 331 align-items: center; 332 } 333 .bookmark-icon { 334 cursor: pointer; 335 } 336 .time-selector-form { 337 display: flex; 338 flex-direction: column; 339 height: 60px; 340 width: 90%; 341 justify-content: center; 342 align-items: center; 343 gap: 5px; 344 } 345 .time-selector-form mat-form-field { 346 margin-bottom: -1.34375em; 347 display: flex; 348 width: 100%; 349 font-size: 12px; 350 } 351 .time-selector-form input { 352 text-overflow: ellipsis; 353 font-weight: bold; 354 } 355 .time-selector-form .time-difference { 356 padding-right: 2px; 357 } 358 #time-selector .time-controls { 359 border-radius: 10px; 360 margin: 0.5rem; 361 display: flex; 362 flex-direction: row; 363 justify-content: space-between; 364 width: 90%; 365 background-color: var(--drawer-block-secondary); 366 } 367 #time-selector .mat-icon-button { 368 width: 24px; 369 height: 24px; 370 padding-left: 3px; 371 padding-right: 3px; 372 } 373 #time-selector .mat-icon { 374 font-size: 18px; 375 width: 18px; 376 height: 18px; 377 line-height: 18px; 378 display: flex; 379 } 380 .shown-selection .trace-icon { 381 font-size: 18px; 382 width: 18px; 383 height: 18px; 384 padding-left: 4px; 385 padding-right: 4px; 386 padding-top: 2px; 387 } 388 #mini-timeline { 389 flex-grow: 1; 390 align-self: stretch; 391 } 392 #video-content { 393 position: relative; 394 min-width: 20rem; 395 max-height: 60vh; 396 align-self: stretch; 397 text-align: center; 398 border: 2px solid black; 399 flex-basis: 0px; 400 flex-grow: 1; 401 display: flex; 402 align-items: center; 403 } 404 #video { 405 position: absolute; 406 left: 0; 407 top: 0; 408 height: 100%; 409 width: 100%; 410 } 411 #expanded-timeline { 412 flex-grow: 1; 413 overflow-y: auto; 414 overflow-x: hidden; 415 } 416 #trace-selector .mat-form-field-infix { 417 width: 80px; 418 } 419 #trace-selector .shown-selection { 420 height: 116px; 421 border-radius: 10px; 422 display: flex; 423 justify-content: center; 424 flex-wrap: wrap; 425 align-content: flex-start; 426 background-color: var(--drawer-block-primary); 427 } 428 #trace-selector .filter-header { 429 padding-top: 4px; 430 display: flex; 431 gap: 2px; 432 } 433 .shown-selection .trace-icons { 434 display: flex; 435 justify-content: center; 436 flex-wrap: wrap; 437 align-content: flex-start; 438 width: 70%; 439 } 440 #trace-selector .mat-select-trigger { 441 height: unset; 442 flex-direction: column-reverse; 443 } 444 #trace-selector .mat-select-arrow-wrapper { 445 display: none; 446 } 447 #trace-selector .mat-form-field-wrapper { 448 padding: 0; 449 } 450 :has(>.select-traces-panel) { 451 max-height: unset !important; 452 font-family: 'Roboto', sans-serif; 453 position: relative; 454 bottom: 120px; 455 } 456 .select-traces-panel { 457 max-height: 60vh; 458 overflow-y: auto; 459 overflow-x: hidden; 460 } 461 .tip { 462 padding: 16px; 463 font-weight: 300; 464 } 465 .actions { 466 width: 100%; 467 padding: 1.5rem; 468 float: right; 469 display: flex; 470 justify-content: flex-end; 471 } 472 .no-video-message { 473 padding: 1rem; 474 font-family: 'Roboto', sans-serif; 475 } 476 .no-timeline-msg { 477 padding: 1rem; 478 align-items: center; 479 display: flex; 480 flex-direction: column; 481 width: 100%; 482 } 483 .disabled-message { 484 z-index: 100; 485 position: absolute; 486 top: 10%; 487 left: 50%; 488 opacity: 1; 489 } 490 `, 491 multlineTooltip, 492 ], 493}) 494export class TimelineComponent 495 implements WinscopeEventEmitter, WinscopeEventListener 496{ 497 readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion'; 498 readonly MAX_SELECTED_TRACES = 3; 499 500 @Input() timelineData: TimelineData | undefined; 501 @Input() allTraces: Traces | undefined; 502 @Input() store: PersistentStore | undefined; 503 504 @Output() readonly collapsedTimelineSizeChanged = new EventEmitter<number>(); 505 506 @ViewChild('collapsedTimeline') private collapsedTimelineRef: 507 | ElementRef 508 | undefined; 509 510 @ViewChild('miniTimeline') miniTimeline: MiniTimelineComponent | undefined; 511 512 videoUrl: SafeUrl | undefined; 513 514 initialZoom: TimeRange | undefined = undefined; 515 selectedTraces: Array<Trace<object>> = []; 516 sortedTraces: Array<Trace<object>> = []; 517 selectedTracesFormControl = new FormControl<Array<Trace<object>>>([]); 518 selectedTimeFormControl = new FormControl('undefined'); 519 selectedNsFormControl = new FormControl( 520 'undefined', 521 Validators.compose([Validators.required, this.validateNsFormat]), 522 ); 523 timestampForm = new FormGroup({ 524 selectedTime: this.selectedTimeFormControl, 525 selectedNs: this.selectedNsFormControl, 526 }); 527 TRACE_INFO = TRACE_INFO; 528 isInputFormFocused = false; 529 storeKeyDeselectedTraces = 'miniTimeline.deselectedTraces'; 530 bookmarks: Timestamp[] = []; 531 isDisabled = false; 532 533 private expanded = false; 534 private emitEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC; 535 private expandedTimelineScrollEvent: WheelEvent | undefined; 536 private expandedTimelineMouseXRatio: number | undefined; 537 private seekTracePosition?: TracePosition; 538 539 constructor( 540 @Inject(DomSanitizer) private sanitizer: DomSanitizer, 541 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, 542 ) {} 543 544 ngOnInit() { 545 const timelineData = assertDefined(this.timelineData); 546 if (timelineData.hasTimestamps()) { 547 this.updateTimeInputValuesToCurrentTimestamp(); 548 } 549 const converter = assertDefined(timelineData.getTimestampConverter()); 550 const validatorFn: ValidatorFn = (control: AbstractControl) => { 551 const valid = converter.validateHumanInput(control.value ?? ''); 552 return !valid ? {invalidInput: control.value} : null; 553 }; 554 this.selectedTimeFormControl.addValidators( 555 assertDefined(Validators.compose([Validators.required, validatorFn])), 556 ); 557 558 const screenRecordingVideo = timelineData.getScreenRecordingVideo(); 559 if (screenRecordingVideo) { 560 this.videoUrl = this.sanitizer.bypassSecurityTrustUrl( 561 URL.createObjectURL(screenRecordingVideo), 562 ); 563 } 564 565 // sorted to be displayed in order corresponding to viewer tabs 566 this.sortedTraces = 567 this.allTraces 568 ?.mapTrace((trace) => trace) 569 .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) ?? 570 []; 571 572 const storedDeselectedTraces = this.getStoredDeselectedTraceTypes(); 573 this.selectedTraces = this.sortedTraces.filter((trace) => { 574 return ( 575 timelineData.hasTrace(trace) && 576 (!storedDeselectedTraces.includes(trace.type) || 577 timelineData.getActiveTrace() === trace || 578 !timelineData.hasMoreThanOneDistinctTimestamp()) 579 ); 580 }); 581 this.selectedTracesFormControl = new FormControl<Array<Trace<object>>>( 582 this.selectedTraces, 583 ); 584 585 const initialTraceToCropZoom = this.selectedTraces.find((trace) => { 586 return ( 587 trace.type !== TraceType.SCREEN_RECORDING && 588 TraceTypeUtils.isTraceTypeWithViewer(trace.type) && 589 trace.lengthEntries > 0 590 ); 591 }); 592 if (initialTraceToCropZoom) { 593 this.initialZoom = new TimeRange( 594 initialTraceToCropZoom.getEntry(0).getTimestamp(), 595 timelineData.getFullTimeRange().to, 596 ); 597 } 598 } 599 600 ngAfterViewInit() { 601 const height = assertDefined(this.collapsedTimelineRef).nativeElement 602 .offsetHeight; 603 this.collapsedTimelineSizeChanged.emit(height); 604 } 605 606 setEmitEvent(callback: EmitEvent) { 607 this.emitEvent = callback; 608 } 609 610 getVideoCurrentTime() { 611 return assertDefined( 612 this.timelineData, 613 ).searchCorrespondingScreenRecordingTimeSeconds( 614 this.getCurrentTracePosition(), 615 ); 616 } 617 618 getCurrentTracePosition(): TracePosition { 619 if (this.seekTracePosition) { 620 return this.seekTracePosition; 621 } 622 623 const position = assertDefined(this.timelineData).getCurrentPosition(); 624 if (position === undefined) { 625 throw new Error( 626 'A trace position should be available by the time the timeline is loaded', 627 ); 628 } 629 630 return position; 631 } 632 633 getSelectedTracesToShow(): Array<Trace<object>> { 634 const sortedSelectedTraces = this.getSelectedTracesSortedByDisplayOrder(); 635 return sortedSelectedTraces.length > 8 636 ? sortedSelectedTraces.slice(0, 7) 637 : sortedSelectedTraces.slice(0, 8); 638 } 639 640 async onWinscopeEvent(event: WinscopeEvent) { 641 await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async () => { 642 this.updateTimeInputValuesToCurrentTimestamp(); 643 }); 644 await event.visit(WinscopeEventType.ACTIVE_TRACE_CHANGED, async (event) => { 645 await this.miniTimeline?.drawer?.draw(); 646 this.updateSelectedTraces(event.trace); 647 }); 648 await event.visit(WinscopeEventType.DARK_MODE_TOGGLED, async (event) => { 649 const activeTrace = this.timelineData?.getActiveTrace(); 650 if (activeTrace === undefined) { 651 return; 652 } 653 await this.miniTimeline?.drawer?.draw(); 654 }); 655 await event.visit(WinscopeEventType.TRACE_ADD_REQUEST, async (event) => { 656 this.sortedTraces.unshift(event.trace); 657 this.sortedTraces.sort((a, b) => 658 TraceTypeUtils.compareByDisplayOrder(a.type, b.type), 659 ); 660 this.selectedTracesFormControl.setValue( 661 (this.selectedTracesFormControl.value ?? []).concat([event.trace]), 662 ); 663 this.applyNewTraceSelection(event.trace); 664 await this.miniTimeline?.drawer?.draw(); 665 }); 666 await event.visit(WinscopeEventType.TRACE_REMOVE_REQUEST, async (event) => { 667 this.sortedTraces = this.sortedTraces.filter( 668 (trace) => trace !== event.trace, 669 ); 670 this.selectedTracesFormControl.setValue( 671 this.selectedTracesFormControl.value?.filter( 672 (trace) => trace !== event.trace, 673 ) ?? [], 674 ); 675 this.applyNewTraceSelection(event.trace); 676 await this.miniTimeline?.drawer?.draw(); 677 }); 678 await event.visit( 679 WinscopeEventType.INITIALIZE_TRACE_SEARCH_REQUEST, 680 async () => this.setIsDisabled(true), 681 ); 682 await event.visit(WinscopeEventType.TRACE_SEARCH_REQUEST, async () => 683 this.setIsDisabled(true), 684 ); 685 await event.visit(WinscopeEventType.TRACE_SEARCH_INITIALIZED, async () => 686 this.setIsDisabled(false), 687 ); 688 await event.visit(WinscopeEventType.TRACE_SEARCH_COMPLETED, async () => 689 this.setIsDisabled(false), 690 ); 691 } 692 693 async toggleExpand() { 694 this.expanded = !this.expanded; 695 this.changeDetectorRef.detectChanges(); 696 if (this.expanded) { 697 Analytics.Navigation.logExpandedTimelineOpened(); 698 } 699 await this.emitEvent(new ExpandedTimelineToggled(this.expanded)); 700 } 701 702 async updatePosition(position: TracePosition) { 703 assertDefined(this.timelineData).setPosition(position); 704 await this.emitEvent(new TracePositionUpdate(position)); 705 } 706 707 updateSeekTimestamp(timestamp: Timestamp | undefined) { 708 if (timestamp) { 709 this.seekTracePosition = assertDefined( 710 this.timelineData, 711 ).makePositionFromActiveTrace(timestamp); 712 } else { 713 this.seekTracePosition = undefined; 714 } 715 this.updateTimeInputValuesToCurrentTimestamp(); 716 } 717 718 isOptionDisabled(trace: Trace<object>) { 719 const timelineData = assertDefined(this.timelineData); 720 return ( 721 !timelineData.hasTrace(trace) || timelineData.getActiveTrace() === trace 722 ); 723 } 724 725 applyNewTraceSelection(clickedTrace: Trace<object>) { 726 this.selectedTraces = 727 this.selectedTracesFormControl.value ?? 728 this.sortedTraces.filter((trace) => { 729 return assertDefined(this.timelineData).hasTrace(trace); 730 }); 731 this.updateStoredDeselectedTraceTypes(clickedTrace); 732 } 733 734 getTitle(trace: Trace<object>): string { 735 return TRACE_INFO[trace.type].name + (trace.isDump() ? ' Dump' : ''); 736 } 737 738 @HostListener('document:focusin', ['$event']) 739 handleFocusInEvent(event: FocusEvent) { 740 if ( 741 (event.target as HTMLInputElement)?.tagName === 'INPUT' && 742 (event.target as HTMLInputElement)?.type === 'text' 743 ) { 744 //check if text input field focused 745 this.isInputFormFocused = true; 746 } 747 } 748 749 @HostListener('document:focusout', ['$event']) 750 handleFocusOutEvent(event: FocusEvent) { 751 if ( 752 (event.target as HTMLInputElement)?.tagName === 'INPUT' && 753 (event.target as HTMLInputElement)?.type === 'text' 754 ) { 755 //check if text input field focused 756 this.isInputFormFocused = false; 757 } 758 } 759 760 @HostListener('document:keydown', ['$event']) 761 async handleKeyboardEvent(event: KeyboardEvent) { 762 if ( 763 this.isDisabled || 764 this.isInputFormFocused || 765 !assertDefined(this.timelineData).hasMoreThanOneDistinctTimestamp() 766 ) { 767 return; 768 } 769 if (event.key === 'ArrowLeft') { 770 await this.moveToPreviousEntry(); 771 } else if (event.key === 'ArrowRight') { 772 await this.moveToNextEntry(); 773 } 774 } 775 776 hasPrevEntry(): boolean { 777 const activeTrace = this.timelineData?.getActiveTrace(); 778 if (!activeTrace) { 779 return false; 780 } 781 return ( 782 assertDefined(this.timelineData).getPreviousEntryFor(activeTrace) !== 783 undefined 784 ); 785 } 786 787 hasNextEntry(): boolean { 788 const activeTrace = this.timelineData?.getActiveTrace(); 789 if (!activeTrace) { 790 return false; 791 } 792 return ( 793 assertDefined(this.timelineData).getNextEntryFor(activeTrace) !== 794 undefined 795 ); 796 } 797 798 async moveToPreviousEntry() { 799 const activeTrace = this.timelineData?.getActiveTrace(); 800 if (!activeTrace) { 801 return; 802 } 803 const timelineData = assertDefined(this.timelineData); 804 timelineData.moveToPreviousEntryFor(activeTrace); 805 const position = assertDefined(timelineData.getCurrentPosition()); 806 await this.emitEvent(new TracePositionUpdate(position)); 807 } 808 809 async moveToNextEntry() { 810 const activeTrace = this.timelineData?.getActiveTrace(); 811 if (!activeTrace) { 812 return; 813 } 814 const timelineData = assertDefined(this.timelineData); 815 timelineData.moveToNextEntryFor(activeTrace); 816 const position = assertDefined(timelineData.getCurrentPosition()); 817 await this.emitEvent(new TracePositionUpdate(position)); 818 } 819 820 async onHumanTimeInputChange(event: Event) { 821 if (event.type !== 'change' || !this.selectedTimeFormControl.valid) { 822 return; 823 } 824 const target = event.target as HTMLInputElement; 825 let input = target.value; 826 // if hh:mm:ss.zz format, append date of current timestamp 827 if (TimestampUtils.isRealTimeOnlyFormat(input)) { 828 const date = assertDefined( 829 TimestampUtils.extractDateFromHumanTimestamp( 830 this.getCurrentTracePosition().timestamp.format(), 831 ), 832 ); 833 input = date + 'T' + input; 834 } 835 const timelineData = assertDefined(this.timelineData); 836 const timestamp = assertDefined( 837 timelineData.getTimestampConverter(), 838 ).makeTimestampFromHuman(input); 839 840 Analytics.Navigation.logTimeInput('human'); 841 await this.updatePosition( 842 timelineData.makePositionFromActiveTrace(timestamp), 843 ); 844 this.updateTimeInputValuesToCurrentTimestamp(); 845 } 846 847 async onNanosecondsInputTimeChange(event: Event) { 848 if (event.type !== 'change' || !this.selectedNsFormControl.valid) { 849 return; 850 } 851 const target = event.target as HTMLInputElement; 852 const timelineData = assertDefined(this.timelineData); 853 854 const timestamp = assertDefined( 855 timelineData.getTimestampConverter(), 856 ).makeTimestampFromNs(StringUtils.parseBigIntStrippingUnit(target.value)); 857 858 Analytics.Navigation.logTimeInput('ns'); 859 await this.updatePosition( 860 timelineData.makePositionFromActiveTrace(timestamp), 861 ); 862 this.updateTimeInputValuesToCurrentTimestamp(); 863 } 864 865 onKeydownEnterTimeInputField(event: KeyboardEvent) { 866 if (this.selectedTimeFormControl.valid) { 867 (event.target as HTMLInputElement).blur(); 868 } 869 } 870 871 onKeydownEnterNanosecondsTimeInputField(event: KeyboardEvent) { 872 if (this.selectedNsFormControl.valid) { 873 (event.target as HTMLInputElement).blur(); 874 } 875 } 876 877 updateScrollEvent(event: WheelEvent) { 878 this.expandedTimelineScrollEvent = event; 879 } 880 881 updateExpandedTimelineMouseXRatio(mouseXRatio: number | undefined) { 882 this.expandedTimelineMouseXRatio = mouseXRatio; 883 } 884 885 getCopyPositionTooltip(position: string): string { 886 return `Copy current position:\n${position}`; 887 } 888 889 getHumanTimeTooltip(): string { 890 const [date, time] = this.getCurrentTracePosition() 891 .timestamp.format() 892 .split(', '); 893 return ` 894 Date: ${date} 895 Time: ${time}\xa0\xa0\xa0\xa0${this.getUTCOffset()} 896 897 Edit field to update position by inputting time as 898 "hh:mm:ss.zz", "YYYY-MM-DDThh:mm:ss.zz", or "YYYY-MM-DD, hh:mm:ss.zz" 899 `; 900 } 901 902 getCopyHumanTimeTooltip(): string { 903 return this.getCopyPositionTooltip(this.getHumanTime()); 904 } 905 906 getHumanTime(): string { 907 return this.getCurrentTracePosition().timestamp.format(); 908 } 909 910 onTimeCopied(type: 'ns' | 'human') { 911 Analytics.Navigation.logTimeCopied(type); 912 } 913 914 getUTCOffset(): string { 915 return assertDefined( 916 this.timelineData?.getTimestampConverter(), 917 ).getUTCOffset(); 918 } 919 920 currentPositionBookmarked(): boolean { 921 const currentTimestampNs = 922 this.getCurrentTracePosition().timestamp.getValueNs(); 923 return this.bookmarks.some((bm) => bm.getValueNs() === currentTimestampNs); 924 } 925 926 toggleBookmarkCurrentPosition(event: PointerEvent) { 927 const currentTimestamp = this.getCurrentTracePosition().timestamp; 928 this.toggleBookmarkRange(new TimeRange(currentTimestamp, currentTimestamp)); 929 event.stopPropagation(); 930 } 931 932 toggleBookmarkRange(range: TimeRange, rangeContainsBookmark?: boolean) { 933 if (rangeContainsBookmark === undefined) { 934 rangeContainsBookmark = this.bookmarks.some((bookmark) => 935 range.containsTimestamp(bookmark), 936 ); 937 } 938 const clickedNs = (range.from.getValueNs() + range.to.getValueNs()) / 2n; 939 if (rangeContainsBookmark) { 940 const closestBookmark = this.bookmarks.reduce((prev, curr) => { 941 if (clickedNs - curr.getValueNs() < 0) return prev; 942 return Math.abs(Number(curr.getValueNs() - clickedNs)) < 943 Math.abs(Number(prev.getValueNs() - clickedNs)) 944 ? curr 945 : prev; 946 }); 947 this.bookmarks = this.bookmarks.filter( 948 (bm) => bm.getValueNs() !== closestBookmark.getValueNs(), 949 ); 950 } else { 951 this.bookmarks = this.bookmarks.concat([ 952 assertDefined( 953 this.timelineData?.getTimestampConverter(), 954 ).makeTimestampFromNs(clickedNs), 955 ]); 956 } 957 Analytics.Navigation.logTimeBookmark(); 958 } 959 960 removeAllBookmarks() { 961 this.bookmarks = []; 962 } 963 964 async onMiniTimelineTraceClicked(eventData: [Trace<object>, Timestamp]) { 965 const [trace, timestamp] = eventData; 966 await this.emitEvent(new ActiveTraceChanged(trace)); 967 await this.updatePosition( 968 assertDefined(this.timelineData).makePositionFromActiveTrace(timestamp), 969 ); 970 this.changeDetectorRef.detectChanges(); 971 } 972 973 async onExpandedTimelineTraceClicked(trace: Trace<object>) { 974 await this.emitEvent(new ActiveTraceChanged(trace)); 975 this.changeDetectorRef.detectChanges(); 976 } 977 978 getTraceTooltip(trace: Trace<object>) { 979 if (trace.type === TraceType.SCREEN_RECORDING) { 980 return trace.getDescriptors()[0].split('.')[0]; 981 } 982 return TRACE_INFO[trace.type].name; 983 } 984 985 private updateSelectedTraces(trace: Trace<object> | undefined) { 986 if (!trace) { 987 return; 988 } 989 990 if (!this.selectedTraces.includes(trace)) { 991 // Create new object to make sure we trigger an update on Mini Timeline child component 992 this.selectedTraces = [...this.selectedTraces, trace]; 993 this.selectedTracesFormControl.setValue(this.selectedTraces); 994 } 995 } 996 997 private updateTimeInputValuesToCurrentTimestamp() { 998 const currentTimestampNs = 999 this.getCurrentTracePosition().timestamp.getValueNs(); 1000 const timelineData = assertDefined(this.timelineData); 1001 1002 const formattedCurrentTimestamp = assertDefined( 1003 timelineData.getTimestampConverter(), 1004 ) 1005 .makeTimestampFromNs(currentTimestampNs) 1006 .format(TimestampFormatType.DROP_DATE); 1007 this.selectedTimeFormControl.setValue(formattedCurrentTimestamp); 1008 this.selectedNsFormControl.setValue(`${currentTimestampNs} ns`); 1009 } 1010 1011 private getSelectedTracesSortedByDisplayOrder(): Array<Trace<object>> { 1012 return this.selectedTraces 1013 .slice() 1014 .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)); 1015 } 1016 1017 private getStoredDeselectedTraceTypes(): TraceType[] { 1018 const storedDeselectedTraces = this.store?.get( 1019 this.storeKeyDeselectedTraces, 1020 ); 1021 return JSON.parse(storedDeselectedTraces ?? '[]'); 1022 } 1023 1024 private updateStoredDeselectedTraceTypes(clickedTrace: Trace<object>) { 1025 if (!this.store) { 1026 return; 1027 } 1028 1029 let storedDeselected = this.getStoredDeselectedTraceTypes(); 1030 if ( 1031 this.selectedTraces.includes(clickedTrace) && 1032 storedDeselected.includes(clickedTrace.type) 1033 ) { 1034 storedDeselected = storedDeselected.filter( 1035 (stored) => stored !== clickedTrace.type, 1036 ); 1037 } else if ( 1038 !this.selectedTraces.includes(clickedTrace) && 1039 !storedDeselected.includes(clickedTrace.type) 1040 ) { 1041 Analytics.Navigation.logTraceTimelineDeselected( 1042 TRACE_INFO[clickedTrace.type].name, 1043 ); 1044 storedDeselected.push(clickedTrace.type); 1045 } 1046 1047 this.store.add( 1048 this.storeKeyDeselectedTraces, 1049 JSON.stringify(storedDeselected), 1050 ); 1051 } 1052 1053 private validateNsFormat(control: FormControl): ValidationErrors | null { 1054 const valid = TimestampUtils.isNsFormat(control.value ?? ''); 1055 return !valid ? {invalidInput: control.value} : null; 1056 } 1057 1058 private setIsDisabled(value: boolean) { 1059 this.isDisabled = value; 1060 this.changeDetectorRef.detectChanges(); 1061 } 1062} 1063