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