| /* |
| * Copyright (C) 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| import { |
| ChangeDetectorRef, |
| Component, |
| ElementRef, |
| EventEmitter, |
| HostListener, |
| Inject, |
| Input, |
| Output, |
| ViewChild, |
| ViewEncapsulation, |
| } from '@angular/core'; |
| import { |
| AbstractControl, |
| FormControl, |
| FormGroup, |
| ValidationErrors, |
| ValidatorFn, |
| Validators, |
| } from '@angular/forms'; |
| import {DomSanitizer, SafeUrl} from '@angular/platform-browser'; |
| import {TimelineData} from 'app/timeline_data'; |
| import {assertDefined} from 'common/assert_utils'; |
| import {FunctionUtils} from 'common/function_utils'; |
| import {PersistentStore} from 'common/persistent_store'; |
| import {StringUtils} from 'common/string_utils'; |
| import {TimeRange, Timestamp} from 'common/time'; |
| import {TimestampUtils} from 'common/timestamp_utils'; |
| import {Analytics} from 'logging/analytics'; |
| import { |
| ActiveTraceChanged, |
| ExpandedTimelineToggled, |
| TracePositionUpdate, |
| WinscopeEvent, |
| WinscopeEventType, |
| } from 'messaging/winscope_event'; |
| import { |
| EmitEvent, |
| WinscopeEventEmitter, |
| } from 'messaging/winscope_event_emitter'; |
| import {WinscopeEventListener} from 'messaging/winscope_event_listener'; |
| import {TRACE_INFO} from 'trace/trace_info'; |
| import {TracePosition} from 'trace/trace_position'; |
| import {TraceType, TraceTypeUtils} from 'trace/trace_type'; |
| import {multlineTooltip} from 'viewers/components/styles/tooltip.styles'; |
| import {MiniTimelineComponent} from './mini-timeline/mini_timeline_component'; |
| |
| @Component({ |
| selector: 'timeline', |
| encapsulation: ViewEncapsulation.None, |
| template: ` |
| <div id="expanded-nav" *ngIf="expanded"> |
| <div id="video-content" *ngIf="videoUrl !== undefined"> |
| <video |
| *ngIf="getVideoCurrentTime() !== undefined" |
| id="video" |
| [currentTime]="getVideoCurrentTime()" |
| [src]="videoUrl"></video> |
| <div *ngIf="getVideoCurrentTime() === undefined" class="no-video-message"> |
| <p>No screenrecording frame to show</p> |
| <p>Current timestamp before first screenrecording frame.</p> |
| </div> |
| </div> |
| <expanded-timeline |
| [timelineData]="timelineData" |
| (onTracePositionUpdate)="updatePosition($event)" |
| (onScrollEvent)="updateScrollEvent($event)" |
| (onTraceClicked)="onTimelineTraceClicked($event)" |
| (onMouseXRatioUpdate)="updateExpandedTimelineMouseXRatio($event)" |
| id="expanded-timeline"></expanded-timeline> |
| </div> |
| <div class="navbar-toggle"> |
| <div id="toggle" *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()"> |
| <button |
| mat-icon-button |
| [class]="TOGGLE_BUTTON_CLASS" |
| color="basic" |
| aria-label="Toggle Expanded Timeline" |
| (click)="toggleExpand()"> |
| <mat-icon *ngIf="!expanded" class="material-symbols-outlined">expand_circle_up</mat-icon> |
| <mat-icon *ngIf="expanded" class="material-symbols-outlined">expand_circle_down</mat-icon> |
| </button> |
| </div> |
| <div class="navbar" #collapsedTimeline> |
| <ng-template [ngIf]="timelineData.hasMoreThanOneDistinctTimestamp()"> |
| <div id="time-selector"> |
| <form [formGroup]="timestampForm" class="time-selector-form"> |
| <mat-form-field |
| class="time-input human" |
| appearance="fill" |
| (keydown.enter)="onKeydownEnterTimeInputField($event)" |
| (change)="onHumanTimeInputChange($event)"> |
| <mat-icon |
| [matTooltip]="getHumanTimeTooltip()" |
| matTooltipClass="multline-tooltip" |
| matPrefix>schedule</mat-icon> |
| <input |
| matInput |
| name="humanTimeInput" |
| [formControl]="selectedTimeFormControl" /> |
| <div class="field-suffix" matSuffix> |
| <span class="time-difference"> {{ getUTCOffset() }} </span> |
| <button |
| mat-icon-button |
| [matTooltip]="getCopyHumanTimeTooltip()" |
| matTooltipClass="multline-tooltip" |
| [cdkCopyToClipboard]="getHumanTime()" |
| (cdkCopyToClipboardCopied)="onTimeCopied('human')" |
| matSuffix> |
| <mat-icon>content_copy</mat-icon> |
| </button> |
| </div> |
| </mat-form-field> |
| <mat-form-field |
| class="time-input nano" |
| appearance="fill" |
| (keydown.enter)="onKeydownEnterNanosecondsTimeInputField($event)" |
| (change)="onNanosecondsInputTimeChange($event)"> |
| <mat-icon |
| class="bookmark-icon" |
| [class.material-symbols-outlined]="!currentPositionBookmarked()" |
| matTooltip="bookmark timestamp" |
| (click)="toggleBookmarkCurrentPosition($event)" |
| matPrefix>flag</mat-icon> |
| <input matInput name="nsTimeInput" [formControl]="selectedNsFormControl" /> |
| <div class="field-suffix" matSuffix> |
| <button |
| mat-icon-button |
| [matTooltip]="getCopyPositionTooltip(selectedNsFormControl.value)" |
| matTooltipClass="multline-tooltip" |
| [cdkCopyToClipboard]="selectedNsFormControl.value" |
| (cdkCopyToClipboardCopied)="onTimeCopied('ns')" |
| matSuffix> |
| <mat-icon>content_copy</mat-icon> |
| </button> |
| </div> |
| </mat-form-field> |
| </form> |
| <div class="time-controls"> |
| <button |
| mat-icon-button |
| id="prev_entry_button" |
| matTooltip="Go to previous entry" |
| (click)="moveToPreviousEntry()" |
| [class.disabled]="!hasPrevEntry()" |
| [disabled]="!hasPrevEntry()"> |
| <mat-icon>chevron_left</mat-icon> |
| </button> |
| <button |
| mat-icon-button |
| id="next_entry_button" |
| matTooltip="Go to next entry" |
| (click)="moveToNextEntry()" |
| [class.disabled]="!hasNextEntry()" |
| [disabled]="!hasNextEntry()"> |
| <mat-icon>chevron_right</mat-icon> |
| </button> |
| </div> |
| </div> |
| <div id="trace-selector"> |
| <mat-form-field appearance="none"> |
| <mat-select #traceSelector [formControl]="selectedTracesFormControl" multiple> |
| <div class="select-traces-panel"> |
| <div class="tip">Filter traces in the timeline</div> |
| <mat-option |
| *ngFor="let trace of sortedAvailableTraces" |
| [value]="trace" |
| [style]="{ |
| color: 'var(--blue-text-color)', |
| opacity: isOptionDisabled(trace) ? 0.5 : 1.0 |
| }" |
| [disabled]="isOptionDisabled(trace)" |
| (click)="applyNewTraceSelection(trace)"> |
| <mat-icon |
| [style]="{ |
| color: TRACE_INFO[trace].color |
| }" |
| >{{ TRACE_INFO[trace].icon }}</mat-icon> |
| {{ TRACE_INFO[trace].name }} |
| </mat-option> |
| <div class="actions"> |
| <button mat-flat-button color="primary" (click)="traceSelector.close()"> |
| Done |
| </button> |
| </div> |
| </div> |
| <mat-select-trigger class="shown-selection"> |
| <div class="filter-header"> |
| <span class="mat-body-2"> Filter </span> |
| <mat-icon class="material-symbols-outlined">expand_circle_up</mat-icon> |
| </div> |
| |
| <div class="trace-icons"> |
| <mat-icon |
| class="trace-icon" |
| *ngFor="let selectedTrace of getSelectedTracesToShow()" |
| [style]="{color: TRACE_INFO[selectedTrace].color}" |
| [matTooltip]="TRACE_INFO[selectedTrace].name" |
| #tooltip="matTooltip" |
| (mouseenter)="tooltip.disabled = false" |
| (mouseleave)="tooltip.disabled = true"> |
| {{ TRACE_INFO[selectedTrace].icon }} |
| </mat-icon> |
| <mat-icon |
| class="trace-icon" |
| *ngIf="selectedTraces.length > 8"> |
| more_horiz |
| </mat-icon> |
| </div> |
| </mat-select-trigger> |
| </mat-select> |
| </mat-form-field> |
| </div> |
| <mini-timeline |
| [timelineData]="timelineData" |
| [currentTracePosition]="getCurrentTracePosition()" |
| [selectedTraces]="selectedTraces" |
| [initialZoom]="initialZoom" |
| [expandedTimelineScrollEvent]="expandedTimelineScrollEvent" |
| [expandedTimelineMouseXRatio]="expandedTimelineMouseXRatio" |
| [bookmarks]="bookmarks" |
| [store]="store" |
| (onTracePositionUpdate)="updatePosition($event)" |
| (onSeekTimestampUpdate)="updateSeekTimestamp($event)" |
| (onRemoveAllBookmarks)="removeAllBookmarks()" |
| (onToggleBookmark)="toggleBookmarkRange($event.range, $event.rangeContainsBookmark)" |
| (onTraceClicked)="onTimelineTraceClicked($event)" |
| id="mini-timeline" |
| #miniTimeline></mini-timeline> |
| </ng-template> |
| <div *ngIf="!timelineData.hasTimestamps()" class="no-timestamps-msg"> |
| <p class="mat-body-2">No timeline to show!</p> |
| <p class="mat-body-1">All loaded traces contain no timestamps.</p> |
| </div> |
| <div |
| *ngIf="timelineData.hasTimestamps() && !timelineData.hasMoreThanOneDistinctTimestamp()" |
| class="no-timestamps-msg"> |
| <p class="mat-body-2">No timeline to show!</p> |
| <p class="mat-body-1">Only a single timestamp has been recorded.</p> |
| </div> |
| </div> |
| </div> |
| `, |
| styles: [ |
| ` |
| .navbar-toggle { |
| display: flex; |
| flex-direction: column; |
| align-items: end; |
| position: relative; |
| } |
| #toggle { |
| width: fit-content; |
| position: absolute; |
| top: -41px; |
| z-index: 1000; |
| border: 1px solid #3333; |
| border-bottom: 0px; |
| border-right: 0px; |
| border-top-left-radius: 6px; |
| border-top-right-radius: 6px; |
| background-color: var(--drawer-color); |
| } |
| .navbar { |
| display: flex; |
| width: 100%; |
| flex-direction: row; |
| align-items: center; |
| justify-content: center; |
| } |
| #expanded-nav { |
| display: flex; |
| border-bottom: 1px solid #3333; |
| } |
| #time-selector { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| border-radius: 10px; |
| margin-left: 0.5rem; |
| height: 116px; |
| width: 282px; |
| background-color: var(--drawer-block-primary); |
| } |
| #time-selector .mat-form-field-wrapper { |
| width: 100%; |
| } |
| #time-selector .mat-form-field-infix, #trace-selector .mat-form-field-infix { |
| padding: 0 0.75rem 0 0.5rem !important; |
| border-top: unset; |
| } |
| #time-selector .mat-form-field-flex, #time-selector .field-suffix { |
| border-radius: 0; |
| padding: 0; |
| display: flex; |
| align-items: center; |
| } |
| .bookmark-icon { |
| cursor: pointer; |
| } |
| .time-selector-form { |
| display: flex; |
| flex-direction: column; |
| height: 60px; |
| width: 90%; |
| justify-content: center; |
| align-items: center; |
| gap: 5px; |
| } |
| .time-selector-form mat-form-field { |
| margin-bottom: -1.34375em; |
| display: flex; |
| width: 100%; |
| font-size: 12px; |
| } |
| .time-selector-form input { |
| text-overflow: ellipsis; |
| font-weight: bold; |
| } |
| .time-selector-form .time-difference { |
| padding-right: 2px; |
| } |
| #time-selector .time-controls { |
| border-radius: 10px; |
| margin: 0.5rem; |
| display: flex; |
| flex-direction: row; |
| justify-content: space-between; |
| width: 90%; |
| background-color: var(--drawer-block-secondary); |
| } |
| #time-selector .mat-icon-button { |
| width: 24px; |
| height: 24px; |
| padding-left: 3px; |
| padding-right: 3px; |
| } |
| #time-selector .mat-icon { |
| font-size: 18px; |
| width: 18px; |
| height: 18px; |
| line-height: 18px; |
| display: flex; |
| } |
| .shown-selection .trace-icon { |
| font-size: 18px; |
| width: 18px; |
| height: 18px; |
| padding-left: 4px; |
| padding-right: 4px; |
| padding-top: 2px; |
| } |
| #mini-timeline { |
| flex-grow: 1; |
| align-self: stretch; |
| } |
| #video-content { |
| position: relative; |
| min-width: 20rem; |
| min-height: 35rem; |
| align-self: stretch; |
| text-align: center; |
| border: 2px solid black; |
| flex-basis: 0px; |
| flex-grow: 1; |
| display: flex; |
| align-items: center; |
| } |
| #video { |
| position: absolute; |
| left: 0; |
| top: 0; |
| height: 100%; |
| width: 100%; |
| } |
| #expanded-nav { |
| display: flex; |
| flex-direction: row; |
| } |
| #expanded-timeline { |
| flex-grow: 1; |
| } |
| #trace-selector .mat-form-field-infix { |
| width: 80px; |
| } |
| #trace-selector .shown-selection { |
| height: 116px; |
| border-radius: 10px; |
| display: flex; |
| justify-content: center; |
| flex-wrap: wrap; |
| align-content: flex-start; |
| background-color: var(--drawer-block-primary); |
| } |
| #trace-selector .filter-header { |
| padding-top: 4px; |
| display: flex; |
| gap: 2px; |
| } |
| .shown-selection .trace-icons { |
| display: flex; |
| justify-content: center; |
| flex-wrap: wrap; |
| align-content: flex-start; |
| width: 70%; |
| } |
| #trace-selector .mat-select-trigger { |
| height: unset; |
| flex-direction: column-reverse; |
| } |
| #trace-selector .mat-select-arrow-wrapper { |
| display: none; |
| } |
| #trace-selector .mat-form-field-wrapper { |
| padding: 0; |
| } |
| :has(>.select-traces-panel) { |
| max-height: unset !important; |
| font-family: 'Roboto', sans-serif; |
| position: relative; |
| bottom: 120px; |
| } |
| .tip { |
| padding: 16px; |
| font-weight: 300; |
| } |
| .actions { |
| width: 100%; |
| padding: 1.5rem; |
| float: right; |
| display: flex; |
| justify-content: flex-end; |
| } |
| .no-video-message { |
| padding: 1rem; |
| font-family: 'Roboto', sans-serif; |
| } |
| .no-timestamps-msg { |
| padding: 1rem; |
| align-items: center; |
| display: flex; |
| flex-direction: column; |
| } |
| `, |
| multlineTooltip, |
| ], |
| }) |
| export class TimelineComponent |
| implements WinscopeEventEmitter, WinscopeEventListener |
| { |
| readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion'; |
| readonly MAX_SELECTED_TRACES = 3; |
| |
| @Input() set activeViewTraceType(type: TraceType | undefined) { |
| if (type === undefined) { |
| return; |
| } |
| |
| this.internalActiveTrace = type; |
| |
| if (!this.selectedTraces.includes(this.internalActiveTrace)) { |
| // Create new object to make sure we trigger an update on Mini Timeline child component |
| this.selectedTraces = [...this.selectedTraces, this.internalActiveTrace]; |
| this.selectedTracesFormControl.setValue(this.selectedTraces); |
| } |
| } |
| |
| @Input() timelineData: TimelineData | undefined; |
| @Input() availableTraces: TraceType[] = []; |
| @Input() store: PersistentStore | undefined; |
| |
| @Output() readonly collapsedTimelineSizeChanged = new EventEmitter<number>(); |
| |
| @ViewChild('collapsedTimeline') private collapsedTimelineRef: |
| | ElementRef |
| | undefined; |
| |
| @ViewChild('miniTimeline') private miniTimeline: |
| | MiniTimelineComponent |
| | undefined; |
| |
| videoUrl: SafeUrl | undefined; |
| |
| internalActiveTrace: TraceType | undefined = undefined; |
| initialZoom: TimeRange | undefined = undefined; |
| selectedTraces: TraceType[] = []; |
| sortedAvailableTraces: TraceType[] = []; |
| selectedTracesFormControl = new FormControl<TraceType[]>([]); |
| selectedTimeFormControl = new FormControl('undefined'); |
| selectedNsFormControl = new FormControl( |
| 'undefined', |
| Validators.compose([Validators.required, this.validateNsFormat]), |
| ); |
| timestampForm = new FormGroup({ |
| selectedTime: this.selectedTimeFormControl, |
| selectedNs: this.selectedNsFormControl, |
| }); |
| TRACE_INFO = TRACE_INFO; |
| isInputFormFocused = false; |
| storeKeyDeselectedTraces = 'miniTimeline.deselectedTraces'; |
| bookmarks: Timestamp[] = []; |
| |
| private expanded = false; |
| private emitEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC; |
| private expandedTimelineScrollEvent: WheelEvent | undefined; |
| private expandedTimelineMouseXRatio: number | undefined; |
| private seekTracePosition?: TracePosition; |
| |
| constructor( |
| @Inject(DomSanitizer) private sanitizer: DomSanitizer, |
| @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, |
| ) {} |
| |
| ngOnInit() { |
| const timelineData = assertDefined(this.timelineData); |
| if (timelineData.hasTimestamps()) { |
| this.updateTimeInputValuesToCurrentTimestamp(); |
| } |
| const converter = assertDefined(timelineData.getTimestampConverter()); |
| const validatorFn: ValidatorFn = (control: AbstractControl) => { |
| const valid = converter.validateHumanInput(control.value ?? ''); |
| return !valid ? {invalidInput: control.value} : null; |
| }; |
| this.selectedTimeFormControl.addValidators( |
| assertDefined(Validators.compose([Validators.required, validatorFn])), |
| ); |
| |
| const screenRecordingVideo = timelineData.getScreenRecordingVideo(); |
| if (screenRecordingVideo) { |
| this.videoUrl = this.sanitizer.bypassSecurityTrustUrl( |
| URL.createObjectURL(screenRecordingVideo), |
| ); |
| } |
| |
| this.sortedAvailableTraces = this.availableTraces.sort((a, b) => |
| TraceTypeUtils.compareByDisplayOrder(a, b), |
| ); // to display in fixed order corresponding to viewer tabs |
| |
| const storedDeselectedTraces = this.getStoredDeselectedTraces(); |
| this.selectedTraces = this.sortedAvailableTraces.filter( |
| (availableTrace) => { |
| return !storedDeselectedTraces.includes(availableTrace); |
| }, |
| ); |
| this.selectedTracesFormControl = new FormControl<TraceType[]>( |
| this.selectedTraces, |
| ); |
| |
| const initialTraceToCropZoom = this.sortedAvailableTraces.find((type) => { |
| return ( |
| type !== TraceType.SCREEN_RECORDING && |
| TraceTypeUtils.isTraceTypeWithViewer(type) && |
| timelineData.getTraces().getTrace(type) |
| ); |
| }); |
| if (initialTraceToCropZoom !== undefined) { |
| const trace = assertDefined( |
| timelineData.getTraces().getTrace(initialTraceToCropZoom), |
| ); |
| this.initialZoom = new TimeRange( |
| trace.getEntry(0).getTimestamp(), |
| timelineData.getFullTimeRange().to, |
| ); |
| } |
| } |
| |
| ngAfterViewInit() { |
| const height = assertDefined(this.collapsedTimelineRef).nativeElement |
| .offsetHeight; |
| this.collapsedTimelineSizeChanged.emit(height); |
| } |
| |
| setEmitEvent(callback: EmitEvent) { |
| this.emitEvent = callback; |
| } |
| |
| getVideoCurrentTime() { |
| return assertDefined( |
| this.timelineData, |
| ).searchCorrespondingScreenRecordingTimeSeconds( |
| this.getCurrentTracePosition(), |
| ); |
| } |
| |
| getCurrentTracePosition(): TracePosition { |
| if (this.seekTracePosition) { |
| return this.seekTracePosition; |
| } |
| |
| const position = assertDefined(this.timelineData).getCurrentPosition(); |
| if (position === undefined) { |
| throw Error( |
| 'A trace position should be available by the time the timeline is loaded', |
| ); |
| } |
| |
| return position; |
| } |
| |
| getSelectedTracesToShow(): TraceType[] { |
| const sortedSelectedTraces = this.getSelectedTracesSortedByDisplayOrder(); |
| return sortedSelectedTraces.length > 8 |
| ? sortedSelectedTraces.slice(0, 7) |
| : sortedSelectedTraces.slice(0, 8); |
| } |
| |
| async onWinscopeEvent(event: WinscopeEvent) { |
| await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async () => { |
| this.updateTimeInputValuesToCurrentTimestamp(); |
| }); |
| await event.visit(WinscopeEventType.ACTIVE_TRACE_CHANGED, async (event) => { |
| await this.miniTimeline?.drawer?.draw(); |
| this.activeViewTraceType = event.traceType; |
| }); |
| await event.visit(WinscopeEventType.DARK_MODE_TOGGLED, async (event) => { |
| const activeTraceType = this.timelineData?.getActiveViewTraceType(); |
| if (activeTraceType === undefined) { |
| return; |
| } |
| await this.miniTimeline?.drawer?.draw(); |
| }); |
| } |
| |
| async toggleExpand() { |
| this.expanded = !this.expanded; |
| this.changeDetectorRef.detectChanges(); |
| if (this.expanded) { |
| Analytics.Navigation.logExpandedTimelineOpened(); |
| } |
| await this.emitEvent(new ExpandedTimelineToggled(this.expanded)); |
| } |
| |
| async updatePosition(position: TracePosition) { |
| assertDefined(this.timelineData).setPosition(position); |
| await this.emitEvent(new TracePositionUpdate(position)); |
| } |
| |
| updateSeekTimestamp(timestamp: Timestamp | undefined) { |
| if (timestamp) { |
| this.seekTracePosition = assertDefined( |
| this.timelineData, |
| ).makePositionFromActiveTrace(timestamp); |
| } else { |
| this.seekTracePosition = undefined; |
| } |
| this.updateTimeInputValuesToCurrentTimestamp(); |
| } |
| |
| isOptionDisabled(trace: TraceType) { |
| return this.internalActiveTrace === trace; |
| } |
| |
| applyNewTraceSelection(clickedType: TraceType) { |
| this.selectedTraces = |
| this.selectedTracesFormControl.value ?? this.sortedAvailableTraces; |
| this.updateStoredDeselectedTraces(clickedType); |
| } |
| |
| @HostListener('document:focusin', ['$event']) |
| handleFocusInEvent(event: FocusEvent) { |
| if ( |
| (event.target as HTMLInputElement)?.tagName === 'INPUT' && |
| (event.target as HTMLInputElement)?.type === 'text' |
| ) { |
| //check if text input field focused |
| this.isInputFormFocused = true; |
| } |
| } |
| |
| @HostListener('document:focusout', ['$event']) |
| handleFocusOutEvent(event: FocusEvent) { |
| if ( |
| (event.target as HTMLInputElement)?.tagName === 'INPUT' && |
| (event.target as HTMLInputElement)?.type === 'text' |
| ) { |
| //check if text input field focused |
| this.isInputFormFocused = false; |
| } |
| } |
| |
| @HostListener('document:keydown', ['$event']) |
| async handleKeyboardEvent(event: KeyboardEvent) { |
| if ( |
| this.isInputFormFocused || |
| !assertDefined(this.timelineData).hasTimestamps() |
| ) { |
| return; |
| } |
| if (event.key === 'ArrowLeft') { |
| await this.moveToPreviousEntry(); |
| } else if (event.key === 'ArrowRight') { |
| await this.moveToNextEntry(); |
| } |
| } |
| |
| hasPrevEntry(): boolean { |
| if (this.internalActiveTrace === undefined) { |
| return false; |
| } |
| if ( |
| assertDefined(this.timelineData) |
| .getTraces() |
| .getTrace(this.internalActiveTrace) === undefined |
| ) { |
| return false; |
| } |
| return ( |
| assertDefined(this.timelineData).getPreviousEntryFor( |
| this.internalActiveTrace, |
| ) !== undefined |
| ); |
| } |
| |
| hasNextEntry(): boolean { |
| if (this.internalActiveTrace === undefined) { |
| return false; |
| } |
| if ( |
| assertDefined(this.timelineData) |
| .getTraces() |
| .getTrace(this.internalActiveTrace) === undefined |
| ) { |
| return false; |
| } |
| return ( |
| assertDefined(this.timelineData).getNextEntryFor( |
| this.internalActiveTrace, |
| ) !== undefined |
| ); |
| } |
| |
| async moveToPreviousEntry() { |
| if (this.internalActiveTrace === undefined) { |
| return; |
| } |
| const timelineData = assertDefined(this.timelineData); |
| timelineData.moveToPreviousEntryFor(this.internalActiveTrace); |
| const position = assertDefined(timelineData.getCurrentPosition()); |
| await this.emitEvent(new TracePositionUpdate(position)); |
| } |
| |
| async moveToNextEntry() { |
| if (this.internalActiveTrace === undefined) { |
| return; |
| } |
| const timelineData = assertDefined(this.timelineData); |
| timelineData.moveToNextEntryFor(this.internalActiveTrace); |
| const position = assertDefined(timelineData.getCurrentPosition()); |
| await this.emitEvent(new TracePositionUpdate(position)); |
| } |
| |
| async onHumanTimeInputChange(event: Event) { |
| if (event.type !== 'change' || !this.selectedTimeFormControl.valid) { |
| return; |
| } |
| const target = event.target as HTMLInputElement; |
| let input = target.value; |
| // if hh:mm:ss.zz format, append date of current timestamp |
| if (TimestampUtils.isRealTimeOnlyFormat(input)) { |
| const date = assertDefined( |
| TimestampUtils.extractDateFromHumanTimestamp( |
| this.getCurrentTracePosition().timestamp.format(), |
| ), |
| ); |
| input = date + 'T' + input; |
| } |
| const timelineData = assertDefined(this.timelineData); |
| const timestamp = assertDefined( |
| timelineData.getTimestampConverter(), |
| ).makeTimestampFromHuman(input); |
| |
| Analytics.Navigation.logTimeInput('human'); |
| await this.updatePosition( |
| timelineData.makePositionFromActiveTrace(timestamp), |
| ); |
| this.updateTimeInputValuesToCurrentTimestamp(); |
| } |
| |
| async onNanosecondsInputTimeChange(event: Event) { |
| if (event.type !== 'change' || !this.selectedNsFormControl.valid) { |
| return; |
| } |
| const target = event.target as HTMLInputElement; |
| const timelineData = assertDefined(this.timelineData); |
| |
| const timestamp = assertDefined( |
| timelineData.getTimestampConverter(), |
| ).makeTimestampFromNs(StringUtils.parseBigIntStrippingUnit(target.value)); |
| |
| Analytics.Navigation.logTimeInput('ns'); |
| await this.updatePosition( |
| timelineData.makePositionFromActiveTrace(timestamp), |
| ); |
| this.updateTimeInputValuesToCurrentTimestamp(); |
| } |
| |
| onKeydownEnterTimeInputField(event: KeyboardEvent) { |
| if (this.selectedTimeFormControl.valid) { |
| (event.target as HTMLInputElement).blur(); |
| } |
| } |
| |
| onKeydownEnterNanosecondsTimeInputField(event: KeyboardEvent) { |
| if (this.selectedNsFormControl.valid) { |
| (event.target as HTMLInputElement).blur(); |
| } |
| } |
| |
| updateScrollEvent(event: WheelEvent) { |
| this.expandedTimelineScrollEvent = event; |
| } |
| |
| updateExpandedTimelineMouseXRatio(mouseXRatio: number | undefined) { |
| this.expandedTimelineMouseXRatio = mouseXRatio; |
| } |
| |
| getCopyPositionTooltip(position: string): string { |
| return `Copy current position:\n${position}`; |
| } |
| |
| getHumanTimeTooltip(): string { |
| const [date, time] = this.getCurrentTracePosition() |
| .timestamp.format() |
| .split(', '); |
| return ` |
| Date: ${date} |
| Time: ${time}\xa0\xa0\xa0\xa0${this.getUTCOffset()} |
| |
| Edit field to update position by inputting time as |
| "hh:mm:ss.zz", "YYYY-MM-DDThh:mm:ss.zz", or "YYYY-MM-DD, hh:mm:ss.zz" |
| `; |
| } |
| |
| getCopyHumanTimeTooltip(): string { |
| return this.getCopyPositionTooltip(this.getHumanTime()); |
| } |
| |
| getHumanTime(): string { |
| return this.getCurrentTracePosition().timestamp.format(); |
| } |
| |
| onTimeCopied(type: 'ns' | 'human') { |
| Analytics.Navigation.logTimeCopied(type); |
| } |
| |
| getUTCOffset(): string { |
| return assertDefined( |
| this.timelineData?.getTimestampConverter(), |
| ).getUTCOffset(); |
| } |
| |
| currentPositionBookmarked(): boolean { |
| const currentTimestampNs = |
| this.getCurrentTracePosition().timestamp.getValueNs(); |
| return this.bookmarks.some((bm) => bm.getValueNs() === currentTimestampNs); |
| } |
| |
| toggleBookmarkCurrentPosition(event: PointerEvent) { |
| const currentTimestamp = this.getCurrentTracePosition().timestamp; |
| this.toggleBookmarkRange(new TimeRange(currentTimestamp, currentTimestamp)); |
| event.stopPropagation(); |
| } |
| |
| toggleBookmarkRange(range: TimeRange, rangeContainsBookmark?: boolean) { |
| if (rangeContainsBookmark === undefined) { |
| rangeContainsBookmark = this.bookmarks.some((bookmark) => |
| range.containsTimestamp(bookmark), |
| ); |
| } |
| const clickedNs = (range.from.getValueNs() + range.to.getValueNs()) / 2n; |
| if (rangeContainsBookmark) { |
| const closestBookmark = this.bookmarks.reduce((prev, curr) => { |
| if (clickedNs - curr.getValueNs() < 0) return prev; |
| return Math.abs(Number(curr.getValueNs() - clickedNs)) < |
| Math.abs(Number(prev.getValueNs() - clickedNs)) |
| ? curr |
| : prev; |
| }); |
| this.bookmarks = this.bookmarks.filter( |
| (bm) => bm.getValueNs() !== closestBookmark.getValueNs(), |
| ); |
| } else { |
| this.bookmarks = this.bookmarks.concat([ |
| assertDefined( |
| this.timelineData?.getTimestampConverter(), |
| ).makeTimestampFromNs(clickedNs), |
| ]); |
| } |
| } |
| |
| removeAllBookmarks() { |
| this.bookmarks = []; |
| } |
| |
| async onTimelineTraceClicked(trace: TraceType) { |
| await this.emitEvent(new ActiveTraceChanged(trace)); |
| this.changeDetectorRef.detectChanges(); |
| } |
| |
| private updateTimeInputValuesToCurrentTimestamp() { |
| const currentTimestampNs = |
| this.getCurrentTracePosition().timestamp.getValueNs(); |
| const timelineData = assertDefined(this.timelineData); |
| |
| let formattedCurrentTimestamp = assertDefined( |
| timelineData.getTimestampConverter(), |
| ) |
| .makeTimestampFromNs(currentTimestampNs) |
| .format(); |
| if (TimestampUtils.isHumanRealTimestampFormat(formattedCurrentTimestamp)) { |
| formattedCurrentTimestamp = assertDefined( |
| TimestampUtils.extractTimeFromHumanTimestamp(formattedCurrentTimestamp), |
| ); |
| } |
| this.selectedTimeFormControl.setValue(formattedCurrentTimestamp); |
| this.selectedNsFormControl.setValue(`${currentTimestampNs} ns`); |
| } |
| |
| private getSelectedTracesSortedByDisplayOrder(): TraceType[] { |
| return this.selectedTraces |
| .slice() |
| .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a, b)); |
| } |
| |
| private getStoredDeselectedTraces(): TraceType[] { |
| const storedDeselectedTraces = this.store?.get( |
| this.storeKeyDeselectedTraces, |
| ); |
| return JSON.parse(storedDeselectedTraces ?? '[]'); |
| } |
| |
| private updateStoredDeselectedTraces(clickedType: TraceType) { |
| if (!this.store) { |
| return; |
| } |
| |
| let storedTraces = this.getStoredDeselectedTraces(); |
| if ( |
| this.selectedTraces.includes(clickedType) && |
| storedTraces.includes(clickedType) |
| ) { |
| storedTraces = storedTraces.filter( |
| (storedTrace) => storedTrace !== clickedType, |
| ); |
| } else if ( |
| !this.selectedTraces.includes(clickedType) && |
| !storedTraces.includes(clickedType) |
| ) { |
| Analytics.Navigation.logTraceTimelineDeselected( |
| TRACE_INFO[clickedType].name, |
| ); |
| storedTraces.push(clickedType); |
| } |
| |
| this.store.add(this.storeKeyDeselectedTraces, JSON.stringify(storedTraces)); |
| } |
| |
| private validateNsFormat(control: FormControl): ValidationErrors | null { |
| const valid = TimestampUtils.isNsFormat(control.value ?? ''); |
| return !valid ? {invalidInput: control.value} : null; |
| } |
| } |