• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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/store/persistent_store';
42import {StringUtils} from 'common/string_utils';
43import {TimeRange, Timestamp, TimestampFormatType} from 'common/time/time';
44import {TimestampUtils} from 'common/time/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                      [matTooltip]="trace.getDescriptors().join(', ')"
193                      matTooltipPosition="right"
194                      [style]="{
195                        color: 'var(--blue-text-color)',
196                        opacity: isOptionDisabled(trace) ? 0.5 : 1.0
197                      }"
198                      [disabled]="isOptionDisabled(trace)"
199                      (click)="applyNewTraceSelection(trace)">
200                      <mat-icon
201                        [style]="{
202                          color: TRACE_INFO[trace.type].color
203                        }"
204                      >{{ TRACE_INFO[trace.type].icon }}</mat-icon>
205                      {{ getTitle(trace) }}
206                    </mat-option>
207                    <div class="actions">
208                      <button mat-flat-button color="primary" (click)="traceSelector.close()">
209                        Done
210                      </button>
211                    </div>
212                  </div>
213                  <mat-select-trigger class="shown-selection">
214                    <div class="filter-header">
215                      <span class="mat-body-2"> Filter </span>
216                      <mat-icon class="material-symbols-outlined">expand_circle_up</mat-icon>
217                    </div>
218
219                    <div class="trace-icons">
220                      <mat-icon
221                        class="trace-icon"
222                        *ngFor="let selectedTrace of getSelectedTracesToShow()"
223                        [style]="{color: TRACE_INFO[selectedTrace.type].color}"
224                        [matTooltip]="getTraceTooltip(selectedTrace)"
225                        #tooltip="matTooltip"
226                        (mouseenter)="tooltip.disabled = false"
227                        (mouseleave)="tooltip.disabled = true">
228                        {{ TRACE_INFO[selectedTrace.type].icon }}
229                      </mat-icon>
230                      <mat-icon
231                        class="trace-icon"
232                        *ngIf="selectedTraces.length > 8">
233                        more_horiz
234                      </mat-icon>
235                    </div>
236                  </mat-select-trigger>
237                </mat-select>
238              </mat-form-field>
239            </div>
240            <mini-timeline
241              *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()"
242              [timelineData]="timelineData"
243              [currentTracePosition]="getCurrentTracePosition()"
244              [selectedTraces]="selectedTraces"
245              [initialZoom]="initialZoom"
246              [expandedTimelineScrollEvent]="expandedTimelineScrollEvent"
247              [expandedTimelineMouseXRatio]="expandedTimelineMouseXRatio"
248              [bookmarks]="bookmarks"
249              [store]="store"
250              (onTracePositionUpdate)="updatePosition($event)"
251              (onSeekTimestampUpdate)="updateSeekTimestamp($event)"
252              (onRemoveAllBookmarks)="removeAllBookmarks()"
253              (onToggleBookmark)="toggleBookmarkRange($event.range, $event.rangeContainsBookmark)"
254              (onTraceClicked)="onMiniTimelineTraceClicked($event)"
255              id="mini-timeline"
256              #miniTimeline></mini-timeline>
257          </ng-template>
258          <div
259            *ngIf="!timelineData.hasMoreThanOneDistinctTimestamp()"
260            class="no-timeline-msg">
261              <p class="mat-body-2">No timeline to show!</p>
262              <p
263                *ngIf="timelineData.hasTimestamps()"
264                class="mat-body-1">Only a single timestamp has been recorded.</p>
265              <p
266                *ngIf="!timelineData.hasTimestamps()"
267                class="mat-body-1">All loaded traces contain no timestamps.</p>
268          </div>
269        </div>
270      </div>
271    </div>
272  `,
273  styles: [
274    `
275      .navbar-toggle {
276        display: flex;
277        flex-direction: column;
278        align-items: end;
279        position: relative;
280        max-height: 20vh;
281        overflow: auto;
282      }
283      #toggle {
284        width: fit-content;
285        position: absolute;
286        top: -41px;
287        right: 0px;
288        z-index: 1000;
289        border: 1px solid #3333;
290        border-bottom: 0px;
291        border-right: 0px;
292        border-top-left-radius: 6px;
293        border-top-right-radius: 6px;
294        background-color: var(--drawer-color);
295      }
296      .navbar {
297        display: flex;
298        width: 100%;
299        flex-direction: row;
300        align-items: center;
301        justify-content: center;
302      }
303      #expanded-nav {
304        display: flex;
305        flex-direction: row;
306        border-bottom: 1px solid #3333;
307        border-top: 1px solid #3333;
308        max-height: 60vh;
309        overflow: hidden;
310      }
311      #time-selector {
312        display: flex;
313        flex-direction: column;
314        align-items: center;
315        justify-content: center;
316        border-radius: 10px;
317        margin-left: 0.5rem;
318        height: 116px;
319        width: 282px;
320        background-color: var(--drawer-block-primary);
321      }
322      #time-selector .mat-form-field-wrapper {
323        width: 100%;
324      }
325      #time-selector .mat-form-field-infix, #trace-selector .mat-form-field-infix {
326        padding: 0 0.75rem 0 0.5rem !important;
327        border-top: unset;
328      }
329      #time-selector .mat-form-field-flex, #time-selector .field-suffix {
330        border-radius: 0;
331        padding: 0;
332        display: flex;
333        align-items: center;
334      }
335      .bookmark-icon {
336        cursor: pointer;
337      }
338      .time-selector-form {
339        display: flex;
340        flex-direction: column;
341        height: 60px;
342        width: 90%;
343        justify-content: center;
344        align-items: center;
345        gap: 5px;
346      }
347      .time-selector-form mat-form-field {
348        margin-bottom: -1.34375em;
349        display: flex;
350        width: 100%;
351        font-size: 12px;
352      }
353      .time-selector-form input {
354        text-overflow: ellipsis;
355        font-weight: bold;
356      }
357      .time-selector-form .time-difference {
358        padding-right: 2px;
359      }
360      #time-selector .time-controls {
361        border-radius: 10px;
362        margin: 0.5rem;
363        display: flex;
364        flex-direction: row;
365        justify-content: space-between;
366        width: 90%;
367        background-color: var(--drawer-block-secondary);
368      }
369      #time-selector .mat-icon-button {
370        width: 24px;
371        height: 24px;
372        padding-left: 3px;
373        padding-right: 3px;
374      }
375      #time-selector .mat-icon {
376        font-size: 18px;
377        width: 18px;
378        height: 18px;
379        line-height: 18px;
380        display: flex;
381      }
382      .shown-selection .trace-icon {
383        font-size: 18px;
384        width: 18px;
385        height: 18px;
386        padding-left: 4px;
387        padding-right: 4px;
388        padding-top: 2px;
389      }
390      #mini-timeline {
391        flex-grow: 1;
392        align-self: stretch;
393      }
394      #video-content {
395        position: relative;
396        min-width: 20rem;
397        max-height: 60vh;
398        align-self: stretch;
399        text-align: center;
400        border: 2px solid black;
401        flex-basis: 0px;
402        flex-grow: 1;
403        display: flex;
404        align-items: center;
405      }
406      #video {
407        position: absolute;
408        left: 0;
409        top: 0;
410        height: 100%;
411        width: 100%;
412      }
413      #expanded-timeline {
414        flex-grow: 1;
415        overflow-y: auto;
416        overflow-x: hidden;
417      }
418      #trace-selector .mat-form-field-infix {
419        width: 80px;
420      }
421      #trace-selector .shown-selection {
422        height: 116px;
423        border-radius: 10px;
424        display: flex;
425        justify-content: center;
426        flex-wrap: wrap;
427        align-content: flex-start;
428        background-color: var(--drawer-block-primary);
429      }
430      #trace-selector .filter-header {
431        padding-top: 4px;
432        display: flex;
433        gap: 2px;
434      }
435      .shown-selection .trace-icons {
436        display: flex;
437        justify-content: center;
438        flex-wrap: wrap;
439        align-content: flex-start;
440        width: 70%;
441      }
442      #trace-selector .mat-select-trigger {
443        height: unset;
444        flex-direction: column-reverse;
445      }
446      #trace-selector .mat-select-arrow-wrapper {
447        display: none;
448      }
449      #trace-selector .mat-form-field-wrapper {
450        padding: 0;
451      }
452      :has(>.select-traces-panel) {
453        max-height: unset !important;
454        font-family: 'Roboto', sans-serif;
455        position: relative;
456        bottom: 120px;
457      }
458      .select-traces-panel {
459        max-height: 60vh;
460        overflow-y: auto;
461        overflow-x: hidden;
462      }
463      .tip {
464        padding: 16px;
465        font-weight: 300;
466      }
467      .actions {
468        width: 100%;
469        padding: 1.5rem;
470        float: right;
471        display: flex;
472        justify-content: flex-end;
473      }
474      .no-video-message {
475        padding: 1rem;
476        font-family: 'Roboto', sans-serif;
477      }
478      .no-timeline-msg {
479        padding: 1rem;
480        align-items: center;
481        display: flex;
482        flex-direction: column;
483        width: 100%;
484      }
485      .disabled-message {
486        z-index: 100;
487        position: absolute;
488        top: 10%;
489        left: 50%;
490        opacity: 1;
491      }
492    `,
493    multlineTooltip,
494  ],
495})
496export class TimelineComponent
497  implements WinscopeEventEmitter, WinscopeEventListener
498{
499  readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion';
500  readonly MAX_SELECTED_TRACES = 3;
501
502  @Input() timelineData: TimelineData | undefined;
503  @Input() allTraces: Traces | undefined;
504  @Input() store: PersistentStore | undefined;
505
506  @Output() readonly collapsedTimelineSizeChanged = new EventEmitter<number>();
507
508  @ViewChild('collapsedTimeline') private collapsedTimelineRef:
509    | ElementRef
510    | undefined;
511
512  @ViewChild('miniTimeline') miniTimeline: MiniTimelineComponent | undefined;
513
514  videoUrl: SafeUrl | undefined;
515
516  initialZoom: TimeRange | undefined = undefined;
517  selectedTraces: Array<Trace<object>> = [];
518  sortedTraces: Array<Trace<object>> = [];
519  selectedTracesFormControl = new FormControl<Array<Trace<object>>>([]);
520  selectedTimeFormControl = new FormControl('undefined');
521  selectedNsFormControl = new FormControl(
522    'undefined',
523    Validators.compose([Validators.required, this.validateNsFormat]),
524  );
525  timestampForm = new FormGroup({
526    selectedTime: this.selectedTimeFormControl,
527    selectedNs: this.selectedNsFormControl,
528  });
529  TRACE_INFO = TRACE_INFO;
530  isInputFormFocused = false;
531  storeKeyDeselectedTraces = 'miniTimeline.deselectedTraces';
532  bookmarks: Timestamp[] = [];
533  isDisabled = false;
534
535  private expanded = false;
536  private emitEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
537  private expandedTimelineScrollEvent: WheelEvent | undefined;
538  private expandedTimelineMouseXRatio: number | undefined;
539  private seekTracePosition?: TracePosition;
540
541  constructor(
542    @Inject(DomSanitizer) private sanitizer: DomSanitizer,
543    @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef,
544  ) {}
545
546  ngOnInit() {
547    const timelineData = assertDefined(this.timelineData);
548    if (timelineData.hasTimestamps()) {
549      this.updateTimeInputValuesToCurrentTimestamp();
550    }
551    const converter = assertDefined(timelineData.getTimestampConverter());
552    const validatorFn: ValidatorFn = (control: AbstractControl) => {
553      const valid = converter.validateHumanInput(control.value ?? '');
554      return !valid ? {invalidInput: control.value} : null;
555    };
556    this.selectedTimeFormControl.addValidators(
557      assertDefined(Validators.compose([Validators.required, validatorFn])),
558    );
559
560    const screenRecordingVideo = timelineData.getScreenRecordingVideo();
561    if (screenRecordingVideo) {
562      this.videoUrl = this.sanitizer.bypassSecurityTrustUrl(
563        URL.createObjectURL(screenRecordingVideo),
564      );
565    }
566
567    // sorted to be displayed in order corresponding to viewer tabs
568    this.sortedTraces =
569      this.allTraces
570        ?.mapTrace((trace) => trace)
571        .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) ??
572      [];
573
574    const storedDeselectedTraces = this.getStoredDeselectedTraceTypes();
575    this.selectedTraces = this.sortedTraces.filter((trace) => {
576      return (
577        timelineData.hasTrace(trace) &&
578        (!storedDeselectedTraces.includes(trace.type) ||
579          timelineData.getActiveTrace() === trace ||
580          !timelineData.hasMoreThanOneDistinctTimestamp())
581      );
582    });
583    this.selectedTracesFormControl = new FormControl<Array<Trace<object>>>(
584      this.selectedTraces,
585    );
586
587    const initialTraceToCropZoom = this.selectedTraces.find((trace) => {
588      return (
589        trace.type !== TraceType.SCREEN_RECORDING &&
590        TraceTypeUtils.isTraceTypeWithViewer(trace.type) &&
591        trace.lengthEntries > 0
592      );
593    });
594    if (initialTraceToCropZoom) {
595      this.initialZoom = new TimeRange(
596        initialTraceToCropZoom.getEntry(0).getTimestamp(),
597        timelineData.getFullTimeRange().to,
598      );
599    }
600  }
601
602  ngAfterViewInit() {
603    const height = assertDefined(this.collapsedTimelineRef).nativeElement
604      .offsetHeight;
605    this.collapsedTimelineSizeChanged.emit(height);
606  }
607
608  setEmitEvent(callback: EmitEvent) {
609    this.emitEvent = callback;
610  }
611
612  getVideoCurrentTime() {
613    return assertDefined(
614      this.timelineData,
615    ).searchCorrespondingScreenRecordingTimeSeconds(
616      this.getCurrentTracePosition(),
617    );
618  }
619
620  getCurrentTracePosition(): TracePosition {
621    if (this.seekTracePosition) {
622      return this.seekTracePosition;
623    }
624
625    const position = assertDefined(this.timelineData).getCurrentPosition();
626    if (position === undefined) {
627      throw new Error(
628        'A trace position should be available by the time the timeline is loaded',
629      );
630    }
631
632    return position;
633  }
634
635  getSelectedTracesToShow(): Array<Trace<object>> {
636    const sortedSelectedTraces = this.getSelectedTracesSortedByDisplayOrder();
637    return sortedSelectedTraces.length > 8
638      ? sortedSelectedTraces.slice(0, 7)
639      : sortedSelectedTraces.slice(0, 8);
640  }
641
642  async onWinscopeEvent(event: WinscopeEvent) {
643    await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async () => {
644      this.updateTimeInputValuesToCurrentTimestamp();
645    });
646    await event.visit(WinscopeEventType.ACTIVE_TRACE_CHANGED, async (event) => {
647      await this.miniTimeline?.drawer?.draw();
648      this.updateSelectedTraces(event.trace);
649    });
650    await event.visit(WinscopeEventType.DARK_MODE_TOGGLED, async (event) => {
651      const activeTrace = this.timelineData?.getActiveTrace();
652      if (activeTrace === undefined) {
653        return;
654      }
655      await this.miniTimeline?.drawer?.draw();
656    });
657    await event.visit(WinscopeEventType.TRACE_ADD_REQUEST, async (event) => {
658      this.sortedTraces.unshift(event.trace);
659      this.sortedTraces.sort((a, b) =>
660        TraceTypeUtils.compareByDisplayOrder(a.type, b.type),
661      );
662      this.selectedTracesFormControl.setValue(this.sortedTraces);
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    if (
736      trace.type === TraceType.VIEW_CAPTURE ||
737      trace.type === TraceType.SEARCH
738    ) {
739      return TRACE_INFO[trace.type].name + ' ' + trace.getDescriptors()[0];
740    }
741    return TRACE_INFO[trace.type].name + (trace.isDump() ? ' Dump' : '');
742  }
743
744  @HostListener('document:focusin', ['$event'])
745  handleFocusInEvent(event: FocusEvent) {
746    if (
747      (event.target as HTMLInputElement)?.tagName === 'INPUT' &&
748      (event.target as HTMLInputElement)?.type === 'text'
749    ) {
750      //check if text input field focused
751      this.isInputFormFocused = true;
752    }
753  }
754
755  @HostListener('document:focusout', ['$event'])
756  handleFocusOutEvent(event: FocusEvent) {
757    if (
758      (event.target as HTMLInputElement)?.tagName === 'INPUT' &&
759      (event.target as HTMLInputElement)?.type === 'text'
760    ) {
761      //check if text input field focused
762      this.isInputFormFocused = false;
763    }
764  }
765
766  @HostListener('document:keydown', ['$event'])
767  async handleKeyboardEvent(event: KeyboardEvent) {
768    if (
769      this.isDisabled ||
770      this.isInputFormFocused ||
771      !assertDefined(this.timelineData).hasMoreThanOneDistinctTimestamp()
772    ) {
773      return;
774    }
775    if (event.key === 'ArrowLeft') {
776      event.preventDefault();
777      await this.moveToPreviousEntry();
778    } else if (event.key === 'ArrowRight') {
779      event.preventDefault();
780      await this.moveToNextEntry();
781    }
782  }
783
784  hasPrevEntry(): boolean {
785    const activeTrace = this.timelineData?.getActiveTrace();
786    if (!activeTrace) {
787      return false;
788    }
789    return (
790      assertDefined(this.timelineData).getPreviousEntryFor(activeTrace) !==
791      undefined
792    );
793  }
794
795  hasNextEntry(): boolean {
796    const activeTrace = this.timelineData?.getActiveTrace();
797    if (!activeTrace) {
798      return false;
799    }
800    return (
801      assertDefined(this.timelineData).getNextEntryFor(activeTrace) !==
802      undefined
803    );
804  }
805
806  async moveToPreviousEntry() {
807    const activeTrace = this.timelineData?.getActiveTrace();
808    if (!activeTrace) {
809      return;
810    }
811    const timelineData = assertDefined(this.timelineData);
812    timelineData.moveToPreviousEntryFor(activeTrace);
813    const position = assertDefined(timelineData.getCurrentPosition());
814    await this.emitEvent(new TracePositionUpdate(position));
815  }
816
817  async moveToNextEntry() {
818    const activeTrace = this.timelineData?.getActiveTrace();
819    if (!activeTrace) {
820      return;
821    }
822    const timelineData = assertDefined(this.timelineData);
823    timelineData.moveToNextEntryFor(activeTrace);
824    const position = assertDefined(timelineData.getCurrentPosition());
825    await this.emitEvent(new TracePositionUpdate(position));
826  }
827
828  async onHumanTimeInputChange(event: Event) {
829    if (event.type !== 'change' || !this.selectedTimeFormControl.valid) {
830      return;
831    }
832    const target = event.target as HTMLInputElement;
833    let input = target.value;
834    // if hh:mm:ss.zz format, append date of current timestamp
835    if (TimestampUtils.isRealTimeOnlyFormat(input)) {
836      const date = assertDefined(
837        TimestampUtils.extractDateFromHumanTimestamp(
838          this.getCurrentTracePosition().timestamp.format(),
839        ),
840      );
841      input = date + 'T' + input;
842    }
843    const timelineData = assertDefined(this.timelineData);
844    const timestamp = assertDefined(
845      timelineData.getTimestampConverter(),
846    ).makeTimestampFromHuman(input);
847
848    Analytics.Navigation.logTimeInput('human');
849    await this.updatePosition(
850      timelineData.makePositionFromActiveTrace(timestamp),
851    );
852    this.updateTimeInputValuesToCurrentTimestamp();
853  }
854
855  async onNanosecondsInputTimeChange(event: Event) {
856    if (event.type !== 'change' || !this.selectedNsFormControl.valid) {
857      return;
858    }
859    const target = event.target as HTMLInputElement;
860    const timelineData = assertDefined(this.timelineData);
861
862    const timestamp = assertDefined(
863      timelineData.getTimestampConverter(),
864    ).makeTimestampFromNs(StringUtils.parseBigIntStrippingUnit(target.value));
865
866    Analytics.Navigation.logTimeInput('ns');
867    await this.updatePosition(
868      timelineData.makePositionFromActiveTrace(timestamp),
869    );
870    this.updateTimeInputValuesToCurrentTimestamp();
871  }
872
873  onKeydownEnterTimeInputField(event: KeyboardEvent) {
874    if (this.selectedTimeFormControl.valid) {
875      (event.target as HTMLInputElement).blur();
876    }
877  }
878
879  onKeydownEnterNanosecondsTimeInputField(event: KeyboardEvent) {
880    if (this.selectedNsFormControl.valid) {
881      (event.target as HTMLInputElement).blur();
882    }
883  }
884
885  updateScrollEvent(event: WheelEvent) {
886    this.expandedTimelineScrollEvent = event;
887    this.changeDetectorRef.detectChanges();
888  }
889
890  updateExpandedTimelineMouseXRatio(mouseXRatio: number | undefined) {
891    this.expandedTimelineMouseXRatio = mouseXRatio;
892  }
893
894  getCopyPositionTooltip(position: string): string {
895    return `Copy current position:\n${position}`;
896  }
897
898  getHumanTimeTooltip(): string {
899    const [date, time] = this.getCurrentTracePosition()
900      .timestamp.format()
901      .split(', ');
902    return `
903      Date: ${date}
904      Time: ${time}\xa0\xa0\xa0\xa0${this.getUTCOffset()}
905
906      Edit field to update position by inputting time as
907      "hh:mm:ss.zz", "YYYY-MM-DDThh:mm:ss.zz", or "YYYY-MM-DD, hh:mm:ss.zz"
908    `;
909  }
910
911  getCopyHumanTimeTooltip(): string {
912    return this.getCopyPositionTooltip(this.getHumanTime());
913  }
914
915  getHumanTime(): string {
916    return this.getCurrentTracePosition().timestamp.format();
917  }
918
919  onTimeCopied(type: 'ns' | 'human') {
920    Analytics.Navigation.logTimeCopied(type);
921  }
922
923  getUTCOffset(): string {
924    return assertDefined(
925      this.timelineData?.getTimestampConverter(),
926    ).getUTCOffset();
927  }
928
929  currentPositionBookmarked(): boolean {
930    const currentTimestampNs =
931      this.getCurrentTracePosition().timestamp.getValueNs();
932    return this.bookmarks.some((bm) => bm.getValueNs() === currentTimestampNs);
933  }
934
935  toggleBookmarkCurrentPosition(event: PointerEvent) {
936    const currentTimestamp = this.getCurrentTracePosition().timestamp;
937    this.toggleBookmarkRange(new TimeRange(currentTimestamp, currentTimestamp));
938    event.stopPropagation();
939  }
940
941  toggleBookmarkRange(range: TimeRange, rangeContainsBookmark?: boolean) {
942    if (rangeContainsBookmark === undefined) {
943      rangeContainsBookmark = this.bookmarks.some((bookmark) =>
944        range.containsTimestamp(bookmark),
945      );
946    }
947    const clickedNs = (range.from.getValueNs() + range.to.getValueNs()) / 2n;
948    if (rangeContainsBookmark) {
949      const closestBookmark = this.bookmarks.reduce((prev, curr) => {
950        if (clickedNs - curr.getValueNs() < 0) return prev;
951        return Math.abs(Number(curr.getValueNs() - clickedNs)) <
952          Math.abs(Number(prev.getValueNs() - clickedNs))
953          ? curr
954          : prev;
955      });
956      this.bookmarks = this.bookmarks.filter(
957        (bm) => bm.getValueNs() !== closestBookmark.getValueNs(),
958      );
959    } else {
960      this.bookmarks = this.bookmarks.concat([
961        assertDefined(
962          this.timelineData?.getTimestampConverter(),
963        ).makeTimestampFromNs(clickedNs),
964      ]);
965    }
966    Analytics.Navigation.logTimeBookmark();
967  }
968
969  removeAllBookmarks() {
970    this.bookmarks = [];
971  }
972
973  async onMiniTimelineTraceClicked(eventData: [Trace<object>, Timestamp]) {
974    const [trace, timestamp] = eventData;
975    await this.emitEvent(new ActiveTraceChanged(trace));
976    await this.updatePosition(
977      assertDefined(this.timelineData).makePositionFromActiveTrace(timestamp),
978    );
979    this.changeDetectorRef.detectChanges();
980  }
981
982  async onExpandedTimelineTraceClicked(trace: Trace<object>) {
983    await this.emitEvent(new ActiveTraceChanged(trace));
984    this.changeDetectorRef.detectChanges();
985  }
986
987  getTraceTooltip(trace: Trace<object>) {
988    let tooltip = TRACE_INFO[trace.type].name;
989    if (trace.type === TraceType.SCREEN_RECORDING) {
990      tooltip += ' ' + trace.getDescriptors()[0].split('.')[0];
991    }
992    if (trace.type === TraceType.VIEW_CAPTURE) {
993      tooltip += ' ' + trace.getDescriptors()[0];
994    }
995    if (trace.type === TraceType.SEARCH) {
996      tooltip += ' ' + trace.getDescriptors()[0];
997    }
998    return tooltip;
999  }
1000
1001  private updateSelectedTraces(trace: Trace<object> | undefined) {
1002    if (!trace) {
1003      return;
1004    }
1005
1006    if (!this.selectedTraces.includes(trace)) {
1007      // Create new object to make sure we trigger an update on Mini Timeline child component
1008      this.selectedTraces = [...this.selectedTraces, trace];
1009      this.selectedTracesFormControl.setValue(this.selectedTraces);
1010    }
1011  }
1012
1013  private updateTimeInputValuesToCurrentTimestamp() {
1014    const currentTimestampNs =
1015      this.getCurrentTracePosition().timestamp.getValueNs();
1016    const timelineData = assertDefined(this.timelineData);
1017
1018    const formattedCurrentTimestamp = assertDefined(
1019      timelineData.getTimestampConverter(),
1020    )
1021      .makeTimestampFromNs(currentTimestampNs)
1022      .format(TimestampFormatType.DROP_DATE);
1023    this.selectedTimeFormControl.setValue(formattedCurrentTimestamp);
1024    this.selectedNsFormControl.setValue(`${currentTimestampNs} ns`);
1025  }
1026
1027  private getSelectedTracesSortedByDisplayOrder(): Array<Trace<object>> {
1028    return this.selectedTraces
1029      .slice()
1030      .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type));
1031  }
1032
1033  private getStoredDeselectedTraceTypes(): TraceType[] {
1034    const storedDeselectedTraces = this.store?.get(
1035      this.storeKeyDeselectedTraces,
1036    );
1037    return JSON.parse(storedDeselectedTraces ?? '[]');
1038  }
1039
1040  private updateStoredDeselectedTraceTypes(clickedTrace: Trace<object>) {
1041    if (!this.store) {
1042      return;
1043    }
1044
1045    let storedDeselected = this.getStoredDeselectedTraceTypes();
1046    if (
1047      this.selectedTraces.includes(clickedTrace) &&
1048      storedDeselected.includes(clickedTrace.type)
1049    ) {
1050      storedDeselected = storedDeselected.filter(
1051        (stored) => stored !== clickedTrace.type,
1052      );
1053    } else if (
1054      !this.selectedTraces.includes(clickedTrace) &&
1055      !storedDeselected.includes(clickedTrace.type)
1056    ) {
1057      Analytics.Navigation.logTraceTimelineDeselected(
1058        TRACE_INFO[clickedTrace.type].name,
1059      );
1060      storedDeselected.push(clickedTrace.type);
1061    }
1062
1063    this.store.add(
1064      this.storeKeyDeselectedTraces,
1065      JSON.stringify(storedDeselected),
1066    );
1067  }
1068
1069  private validateNsFormat(control: FormControl): ValidationErrors | null {
1070    const valid = TimestampUtils.isNsFormat(control.value ?? '');
1071    return !valid ? {invalidInput: control.value} : null;
1072  }
1073
1074  private setIsDisabled(value: boolean) {
1075    this.isDisabled = value;
1076    this.changeDetectorRef.detectChanges();
1077  }
1078}
1079