xref: /aosp_15_r20/development/tools/winscope/src/app/components/timeline/timeline_component.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
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