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