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  SimpleChanges,
27  ViewChild,
28} from '@angular/core';
29import {TimelineData} from 'app/timeline_data';
30import {assertDefined} from 'common/assert_utils';
31import {PersistentStore} from 'common/persistent_store';
32import {TimeRange, Timestamp} from 'common/time';
33import {TimestampUtils} from 'common/timestamp_utils';
34import {Analytics} from 'logging/analytics';
35import {Trace} from 'trace/trace';
36import {TracePosition} from 'trace/trace_position';
37import {TraceTypeUtils} from 'trace/trace_type';
38import {MiniTimelineDrawer} from './drawer/mini_timeline_drawer';
39import {MiniTimelineDrawerImpl} from './drawer/mini_timeline_drawer_impl';
40import {MiniTimelineDrawerInput} from './drawer/mini_timeline_drawer_input';
41import {MIN_SLIDER_WIDTH} from './slider_component';
42import {Transformer} from './transformer';
43
44@Component({
45  selector: 'mini-timeline',
46  template: `
47    <div class="mini-timeline-outer-wrapper">
48      <div class="zoom-buttons">
49        <button mat-icon-button id="zoom-in-btn" (click)="zoomIn()">
50          <mat-icon>zoom_in</mat-icon>
51        </button>
52        <button mat-icon-button id="zoom-out-btn" (click)="zoomOut()">
53          <mat-icon>zoom_out</mat-icon>
54        </button>
55        <button mat-icon-button id="reset-zoom-btn" (click)="resetZoom()">
56          <mat-icon>refresh</mat-icon>
57        </button>
58      </div>
59      <div id="mini-timeline-wrapper" #miniTimelineWrapper>
60        <canvas
61          #canvas
62          id="mini-timeline-canvas"
63          (mousemove)="trackMousePos($event)"
64          (mouseleave)="onMouseLeave($event)"
65          (contextmenu)="recordClickPosition($event)"
66          [cdkContextMenuTriggerFor]="timeline_context_menu"
67          #menuTrigger = "cdkContextMenuTriggerFor"
68          ></canvas>
69        <div class="zoom-control">
70          <slider
71            [fullRange]="timelineData.getFullTimeRange()"
72            [zoomRange]="timelineData.getZoomRange()"
73            [currentPosition]="currentTracePosition"
74            [timestampConverter]="timelineData.getTimestampConverter()"
75            (onZoomChanged)="onSliderZoomChanged($event)"></slider>
76        </div>
77      </div>
78    </div>
79
80    <ng-template #timeline_context_menu>
81      <div class="context-menu" cdkMenu #timelineMenu="cdkMenu">
82        <div class="context-menu-item-container">
83          <span class="context-menu-item" (click)="toggleBookmark()" cdkMenuItem> {{getToggleBookmarkText()}} </span>
84          <span class="context-menu-item" (click)="removeAllBookmarks()" cdkMenuItem>Remove all bookmarks</span>
85        </div>
86      </div>
87    </ng-template>
88  `,
89  styles: [
90    `
91      .mini-timeline-outer-wrapper {
92        display: inline-flex;
93        width: 100%;
94        min-height: 5em;
95        height: 100%;
96      }
97      .zoom-buttons {
98        width: fit-content;
99        display: flex;
100        flex-direction: column;
101        align-items: center;
102        justify-content: center;
103        background-color: var(--drawer-color);
104      }
105      .zoom-buttons button {
106        width: fit-content;
107      }
108      #mini-timeline-wrapper {
109        width: 100%;
110        min-height: 5em;
111        height: 100%;
112      }
113      .zoom-control {
114        padding-right: ${MIN_SLIDER_WIDTH / 2}px;
115        margin-top: -10px;
116      }
117      .zoom-control slider {
118        flex-grow: 1;
119      }
120    `,
121  ],
122})
123export class MiniTimelineComponent {
124  @Input() timelineData: TimelineData | undefined;
125  @Input() currentTracePosition: TracePosition | undefined;
126  @Input() selectedTraces: Array<Trace<object>> | undefined;
127  @Input() initialZoom: TimeRange | undefined;
128  @Input() expandedTimelineScrollEvent: WheelEvent | undefined;
129  @Input() expandedTimelineMouseXRatio: number | undefined;
130  @Input() bookmarks: Timestamp[] = [];
131  @Input() store: PersistentStore | undefined;
132
133  @Output() readonly onTracePositionUpdate = new EventEmitter<TracePosition>();
134  @Output() readonly onSeekTimestampUpdate = new EventEmitter<
135    Timestamp | undefined
136  >();
137  @Output() readonly onRemoveAllBookmarks = new EventEmitter<void>();
138  @Output() readonly onToggleBookmark = new EventEmitter<{
139    range: TimeRange;
140    rangeContainsBookmark: boolean;
141  }>();
142  @Output() readonly onTraceClicked = new EventEmitter<Trace<object>>();
143
144  @ViewChild('miniTimelineWrapper', {static: false})
145  miniTimelineWrapper: ElementRef | undefined;
146  @ViewChild('canvas', {static: false}) canvasRef: ElementRef | undefined;
147
148  getCanvas(): HTMLCanvasElement {
149    return assertDefined(this.canvasRef).nativeElement;
150  }
151
152  drawer: MiniTimelineDrawer | undefined = undefined;
153  private lastMousePosX: number | undefined;
154  private hoverTimestamp: Timestamp | undefined;
155  private lastMoves: WheelEvent[] = [];
156  private lastRightClickTimeRange: TimeRange | undefined;
157
158  constructor(
159    @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef,
160  ) {}
161
162  recordClickPosition(event: MouseEvent) {
163    event.preventDefault();
164    event.stopPropagation();
165    const lastRightClickPos = {x: event.offsetX, y: event.offsetY};
166    const drawer = assertDefined(this.drawer);
167    const clickRange = drawer.getClickRange(lastRightClickPos);
168    const zoomRange = assertDefined(this.timelineData).getZoomRange();
169    const usableRange = drawer.getUsableRange();
170    const transformer = new Transformer(
171      zoomRange,
172      usableRange,
173      assertDefined(this.timelineData?.getTimestampConverter()),
174    );
175    this.lastRightClickTimeRange = new TimeRange(
176      transformer.untransform(clickRange.from),
177      transformer.untransform(clickRange.to),
178    );
179  }
180
181  private static readonly SLIDER_HORIZONTAL_STEP = 30;
182  private static readonly SENSITIVITY_FACTOR = 5;
183
184  ngAfterViewInit(): void {
185    this.makeHiPPICanvas();
186
187    const updateTimestampCallback = (timestamp: Timestamp) => {
188      this.onSeekTimestampUpdate.emit(undefined);
189      this.onTracePositionUpdate.emit(
190        assertDefined(this.timelineData).makePositionFromActiveTrace(timestamp),
191      );
192    };
193
194    const onClickCallback = (
195      timestamp: Timestamp,
196      trace: Trace<object> | undefined,
197    ) => {
198      if (trace) {
199        this.onTraceClicked.emit(trace);
200      }
201      updateTimestampCallback(timestamp);
202    };
203
204    this.drawer = new MiniTimelineDrawerImpl(
205      this.getCanvas(),
206      () => this.getMiniCanvasDrawerInput(),
207      (position) => this.onSeekTimestampUpdate.emit(position),
208      updateTimestampCallback,
209      onClickCallback,
210    );
211
212    if (this.initialZoom !== undefined) {
213      this.onZoomChanged(this.initialZoom);
214    } else {
215      this.resetZoom();
216    }
217  }
218
219  ngOnChanges(changes: SimpleChanges) {
220    if (changes['expandedTimelineScrollEvent']?.currentValue) {
221      const event = changes['expandedTimelineScrollEvent'].currentValue;
222      const moveDirection = this.getMoveDirection(event);
223
224      if (event.deltaY !== 0 && moveDirection === 'y') {
225        this.updateZoomByScrollEvent(event);
226      }
227
228      if (event.deltaX !== 0 && moveDirection === 'x') {
229        this.updateHorizontalScroll(event);
230      }
231    } else if (this.drawer && changes['expandedTimelineMouseXRatio']) {
232      const mouseXRatio: number | undefined =
233        changes['expandedTimelineMouseXRatio'].currentValue;
234      this.lastMousePosX = mouseXRatio
235        ? mouseXRatio * this.drawer.getWidth()
236        : undefined;
237      this.updateHoverTimestamp();
238    } else if (this.drawer !== undefined) {
239      this.drawer.draw();
240    }
241  }
242
243  getTracesToShow(): Array<Trace<object>> {
244    return assertDefined(this.selectedTraces)
245      .slice()
246      .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type))
247      .reverse(); // reversed to ensure display is ordered top to bottom
248  }
249
250  @HostListener('window:resize', ['$event'])
251  onResize(event: Event) {
252    this.makeHiPPICanvas();
253    this.drawer?.draw();
254  }
255
256  trackMousePos(event: MouseEvent) {
257    this.lastMousePosX = event.offsetX;
258    this.updateHoverTimestamp();
259  }
260
261  onMouseLeave(event: MouseEvent) {
262    this.lastMousePosX = undefined;
263    this.updateHoverTimestamp();
264  }
265
266  updateHoverTimestamp() {
267    if (!this.lastMousePosX) {
268      this.hoverTimestamp = undefined;
269      return;
270    }
271    const timelineData = assertDefined(this.timelineData);
272    this.hoverTimestamp = new Transformer(
273      timelineData.getZoomRange(),
274      assertDefined(this.drawer).getUsableRange(),
275      assertDefined(timelineData.getTimestampConverter()),
276    ).untransform(this.lastMousePosX);
277  }
278
279  @HostListener('document:keydown', ['$event'])
280  async handleKeyboardEvent(event: KeyboardEvent) {
281    if (event.code === 'KeyA') {
282      this.updateSliderPosition(-MiniTimelineComponent.SLIDER_HORIZONTAL_STEP);
283    }
284    if (event.code === 'KeyD') {
285      this.updateSliderPosition(MiniTimelineComponent.SLIDER_HORIZONTAL_STEP);
286    }
287
288    if (event.code !== 'KeyW' && event.code !== 'KeyS') {
289      return;
290    }
291
292    const zoomTo = this.hoverTimestamp;
293    event.code === 'KeyW' ? this.zoomIn(zoomTo) : this.zoomOut(zoomTo);
294  }
295
296  onZoomChanged(zoom: TimeRange) {
297    const timelineData = assertDefined(this.timelineData);
298    timelineData.setZoom(zoom);
299    timelineData.setSelectionTimeRange(zoom);
300    this.drawer?.draw();
301    this.changeDetectorRef.detectChanges();
302  }
303
304  onSliderZoomChanged(zoom: TimeRange) {
305    this.onZoomChanged(zoom);
306    this.updateHoverTimestamp();
307  }
308
309  resetZoom() {
310    Analytics.Navigation.logZoom('reset', 'timeline');
311    this.onZoomChanged(assertDefined(this.timelineData).getFullTimeRange());
312  }
313
314  zoomIn(zoomOn?: Timestamp) {
315    Analytics.Navigation.logZoom(this.getZoomSource(zoomOn), 'timeline', 'in');
316    this.zoom({nominator: 6n, denominator: 7n}, zoomOn);
317  }
318
319  zoomOut(zoomOn?: Timestamp) {
320    Analytics.Navigation.logZoom(this.getZoomSource(zoomOn), 'timeline', 'out');
321    this.zoom({nominator: 8n, denominator: 7n}, zoomOn);
322  }
323
324  zoom(
325    zoomRatio: {nominator: bigint; denominator: bigint},
326    zoomOn?: Timestamp,
327  ) {
328    const timelineData = assertDefined(this.timelineData);
329    const fullRange = timelineData.getFullTimeRange();
330    const currentZoomRange = timelineData.getZoomRange();
331    const currentZoomWidth = currentZoomRange.to.minus(
332      currentZoomRange.from.getValueNs(),
333    );
334    const zoomToWidth = currentZoomWidth
335      .times(zoomRatio.nominator)
336      .div(zoomRatio.denominator);
337
338    const cursorPosition = this.currentTracePosition?.timestamp;
339    const currentMiddle = currentZoomRange.from
340      .add(currentZoomRange.to.getValueNs())
341      .div(2n);
342
343    let newFrom: Timestamp;
344    let newTo: Timestamp;
345
346    let zoomTowards = currentMiddle;
347    if (zoomOn === undefined) {
348      if (cursorPosition !== undefined && cursorPosition.in(currentZoomRange)) {
349        zoomTowards = cursorPosition;
350      }
351    } else if (zoomOn.in(currentZoomRange)) {
352      zoomTowards = zoomOn;
353    }
354
355    newFrom = zoomTowards.minus(
356      zoomToWidth
357        .times(
358          zoomTowards.minus(currentZoomRange.from.getValueNs()).getValueNs(),
359        )
360        .div(currentZoomWidth.getValueNs())
361        .getValueNs(),
362    );
363
364    newTo = zoomTowards.add(
365      zoomToWidth
366        .times(currentZoomRange.to.minus(zoomTowards.getValueNs()).getValueNs())
367        .div(currentZoomWidth.getValueNs())
368        .getValueNs(),
369    );
370
371    if (newFrom.getValueNs() < fullRange.from.getValueNs()) {
372      newTo = TimestampUtils.min(
373        fullRange.to,
374        newFrom.add(zoomToWidth.getValueNs()),
375      );
376      newFrom = fullRange.from;
377    }
378
379    if (newTo.getValueNs() > fullRange.to.getValueNs()) {
380      newFrom = TimestampUtils.max(
381        fullRange.from,
382        fullRange.to.minus(zoomToWidth.getValueNs()),
383      );
384      newTo = fullRange.to;
385    }
386
387    this.onZoomChanged(new TimeRange(newFrom, newTo));
388  }
389
390  @HostListener('wheel', ['$event'])
391  onScroll(event: WheelEvent) {
392    const moveDirection = this.getMoveDirection(event);
393
394    if (
395      (event.target as HTMLElement)?.id === 'mini-timeline-canvas' &&
396      event.deltaY !== 0 &&
397      moveDirection === 'y'
398    ) {
399      this.updateZoomByScrollEvent(event);
400    }
401
402    if (event.deltaX !== 0 && moveDirection === 'x') {
403      this.updateHorizontalScroll(event);
404    }
405  }
406
407  toggleBookmark() {
408    if (!this.lastRightClickTimeRange) {
409      return;
410    }
411    this.onToggleBookmark.emit({
412      range: this.lastRightClickTimeRange,
413      rangeContainsBookmark: this.bookmarks.some((bookmark) => {
414        return assertDefined(this.lastRightClickTimeRange).containsTimestamp(
415          bookmark,
416        );
417      }),
418    });
419  }
420
421  getToggleBookmarkText() {
422    if (!this.lastRightClickTimeRange) {
423      return 'Add/remove bookmark';
424    }
425
426    const rangeContainsBookmark = this.bookmarks.some((bookmark) => {
427      return assertDefined(this.lastRightClickTimeRange).containsTimestamp(
428        bookmark,
429      );
430    });
431    if (rangeContainsBookmark) {
432      return 'Remove bookmark';
433    }
434
435    return 'Add bookmark';
436  }
437
438  removeAllBookmarks() {
439    this.onRemoveAllBookmarks.emit();
440  }
441
442  private getZoomSource(zoomOn?: Timestamp): 'scroll' | 'button' {
443    if (zoomOn === undefined) {
444      return 'button';
445    }
446
447    return 'scroll';
448  }
449
450  private getMiniCanvasDrawerInput() {
451    const timelineData = assertDefined(this.timelineData);
452    return new MiniTimelineDrawerInput(
453      timelineData.getFullTimeRange(),
454      assertDefined(this.currentTracePosition).timestamp,
455      timelineData.getSelectionTimeRange(),
456      timelineData.getZoomRange(),
457      this.getTracesToShow(),
458      timelineData,
459      this.bookmarks,
460      this.store?.get('dark-mode') === 'true',
461    );
462  }
463
464  private makeHiPPICanvas() {
465    // Reset any size before computing new size to avoid it interfering with size computations
466    const canvas = this.getCanvas();
467    canvas.width = 0;
468    canvas.height = 0;
469    canvas.style.width = 'auto';
470    canvas.style.height = 'auto';
471
472    const miniTimelineWrapper = assertDefined(this.miniTimelineWrapper);
473    const width = miniTimelineWrapper.nativeElement.offsetWidth;
474    const height = miniTimelineWrapper.nativeElement.offsetHeight;
475
476    const HiPPIwidth = window.devicePixelRatio * width;
477    const HiPPIheight = window.devicePixelRatio * height;
478
479    canvas.width = HiPPIwidth;
480    canvas.height = HiPPIheight;
481    canvas.style.width = width + 'px';
482    canvas.style.height = height + 'px';
483
484    // ensure all drawing operations are scaled
485    if (window.devicePixelRatio !== 1) {
486      const context = canvas.getContext('2d')!;
487      context.scale(window.devicePixelRatio, window.devicePixelRatio);
488    }
489  }
490
491  // -1 for x direction, 1 for y direction
492  private getMoveDirection(event: WheelEvent): string {
493    this.lastMoves.push(event);
494    setTimeout(() => this.lastMoves.shift(), 1000);
495
496    const xMoveAmount = this.lastMoves.reduce(
497      (accumulator, it) => accumulator + it.deltaX,
498      0,
499    );
500    const yMoveAmount = this.lastMoves.reduce(
501      (accumulator, it) => accumulator + it.deltaY,
502      0,
503    );
504
505    if (Math.abs(yMoveAmount) > Math.abs(xMoveAmount)) {
506      return 'y';
507    } else {
508      return 'x';
509    }
510  }
511
512  private updateZoomByScrollEvent(event: WheelEvent) {
513    if (!this.hoverTimestamp) {
514      const canvas = event.target as HTMLCanvasElement;
515      const drawer = assertDefined(this.drawer);
516      this.lastMousePosX =
517        (drawer.getWidth() * event.offsetX) / canvas.offsetWidth;
518      this.updateHoverTimestamp();
519    }
520    if (event.deltaY < 0) {
521      this.zoomIn(this.hoverTimestamp);
522    } else {
523      this.zoomOut(this.hoverTimestamp);
524    }
525  }
526
527  private updateHorizontalScroll(event: WheelEvent) {
528    const scrollAmount =
529      event.deltaX / MiniTimelineComponent.SENSITIVITY_FACTOR;
530    this.updateSliderPosition(scrollAmount);
531  }
532
533  private updateSliderPosition(step: number) {
534    const timelineData = assertDefined(this.timelineData);
535    const fullRange = timelineData.getFullTimeRange();
536    const zoomRange = timelineData.getZoomRange();
537
538    const usableRange = assertDefined(this.drawer).getUsableRange();
539    const transformer = new Transformer(
540      zoomRange,
541      usableRange,
542      assertDefined(timelineData.getTimestampConverter()),
543    );
544    const shiftAmount = transformer
545      .untransform(usableRange.from + step)
546      .minus(zoomRange.from.getValueNs());
547
548    let newFrom = zoomRange.from.add(shiftAmount.getValueNs());
549    let newTo = zoomRange.to.add(shiftAmount.getValueNs());
550
551    if (newFrom.getValueNs() < fullRange.from.getValueNs()) {
552      newTo = newTo.add(
553        fullRange.from.minus(newFrom.getValueNs()).getValueNs(),
554      );
555      newFrom = fullRange.from;
556    }
557
558    if (newTo.getValueNs() > fullRange.to.getValueNs()) {
559      newFrom = newFrom.minus(
560        newTo.minus(fullRange.to.getValueNs()).getValueNs(),
561      );
562      newTo = fullRange.to;
563    }
564
565    this.onZoomChanged(new TimeRange(newFrom, newTo));
566    this.updateHoverTimestamp();
567  }
568}
569