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)="onZoomInButtonClick()">
50          <mat-icon>zoom_in</mat-icon>
51        </button>
52        <button mat-icon-button id="zoom-out-btn" (click)="onZoomOutButtonClick()">
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<
143    [Trace<object>, Timestamp]
144  >();
145
146  @ViewChild('miniTimelineWrapper', {static: false})
147  miniTimelineWrapper: ElementRef | undefined;
148  @ViewChild('canvas', {static: false}) canvasRef: ElementRef | undefined;
149
150  getCanvas(): HTMLCanvasElement {
151    return assertDefined(this.canvasRef).nativeElement;
152  }
153
154  drawer: MiniTimelineDrawer | undefined = undefined;
155  private lastMousePosX: number | undefined;
156  private hoverTimestamp: Timestamp | undefined;
157  private lastMoves: WheelEvent[] = [];
158  private lastRightClickTimeRange: TimeRange | undefined;
159
160  constructor(
161    @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef,
162  ) {}
163
164  recordClickPosition(event: MouseEvent) {
165    event.preventDefault();
166    event.stopPropagation();
167    const lastRightClickPos = {x: event.offsetX, y: event.offsetY};
168    const drawer = assertDefined(this.drawer);
169    const clickRange = drawer.getClickRange(lastRightClickPos);
170    const zoomRange = assertDefined(this.timelineData).getZoomRange();
171    const usableRange = drawer.getUsableRange();
172    const transformer = new Transformer(
173      zoomRange,
174      usableRange,
175      assertDefined(this.timelineData?.getTimestampConverter()),
176    );
177    this.lastRightClickTimeRange = new TimeRange(
178      transformer.untransform(clickRange.from),
179      transformer.untransform(clickRange.to),
180    );
181  }
182
183  private static readonly SLIDER_HORIZONTAL_STEP = 30;
184  private static readonly SENSITIVITY_FACTOR = 5;
185
186  ngAfterViewInit(): void {
187    this.makeHiPPICanvas();
188
189    const updateTimestampCallback = (timestamp: Timestamp) => {
190      this.onSeekTimestampUpdate.emit(undefined);
191      this.onTracePositionUpdate.emit(
192        assertDefined(this.timelineData).makePositionFromActiveTrace(timestamp),
193      );
194    };
195
196    const onClickCallback = (
197      timestamp: Timestamp,
198      trace: Trace<object> | undefined,
199    ) => {
200      if (trace) {
201        this.onTraceClicked.emit([trace, timestamp]);
202        this.onSeekTimestampUpdate.emit(undefined);
203      } else {
204        updateTimestampCallback(timestamp);
205      }
206    };
207
208    this.drawer = new MiniTimelineDrawerImpl(
209      this.getCanvas(),
210      () => this.getMiniCanvasDrawerInput(),
211      (position) => this.onSeekTimestampUpdate.emit(position),
212      updateTimestampCallback,
213      onClickCallback,
214    );
215
216    if (this.initialZoom !== undefined) {
217      this.onZoomChanged(this.initialZoom);
218    } else {
219      this.resetZoom();
220    }
221  }
222
223  ngOnChanges(changes: SimpleChanges) {
224    if (changes['expandedTimelineScrollEvent']?.currentValue) {
225      const event = changes['expandedTimelineScrollEvent'].currentValue;
226      const moveDirection = this.getMoveDirection(event);
227
228      if (event.deltaY !== 0 && moveDirection === 'y') {
229        this.updateZoomByScrollEvent(event);
230      }
231
232      if (event.deltaX !== 0 && moveDirection === 'x') {
233        this.updateHorizontalScroll(event);
234      }
235    } else if (this.drawer && changes['expandedTimelineMouseXRatio']) {
236      const mouseXRatio: number | undefined =
237        changes['expandedTimelineMouseXRatio'].currentValue;
238      this.lastMousePosX = mouseXRatio
239        ? mouseXRatio * this.drawer.getWidth()
240        : undefined;
241      this.updateHoverTimestamp();
242    } else if (this.drawer !== undefined) {
243      this.drawer.draw();
244    }
245  }
246
247  getTracesToShow(): Array<Trace<object>> {
248    return assertDefined(this.selectedTraces)
249      .slice()
250      .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type))
251      .reverse(); // reversed to ensure display is ordered top to bottom
252  }
253
254  @HostListener('window:resize', ['$event'])
255  onResize(event: Event) {
256    this.makeHiPPICanvas();
257    this.drawer?.draw();
258  }
259
260  trackMousePos(event: MouseEvent) {
261    this.lastMousePosX = event.offsetX;
262    this.updateHoverTimestamp();
263  }
264
265  onMouseLeave(event: MouseEvent) {
266    this.lastMousePosX = undefined;
267    this.updateHoverTimestamp();
268  }
269
270  updateHoverTimestamp() {
271    if (!this.lastMousePosX) {
272      this.hoverTimestamp = undefined;
273      return;
274    }
275    const timelineData = assertDefined(this.timelineData);
276    this.hoverTimestamp = new Transformer(
277      timelineData.getZoomRange(),
278      assertDefined(this.drawer).getUsableRange(),
279      assertDefined(timelineData.getTimestampConverter()),
280    ).untransform(this.lastMousePosX);
281  }
282
283  @HostListener('document:keydown', ['$event'])
284  async handleKeyboardEvent(event: KeyboardEvent) {
285    if ((event.target as HTMLElement).tagName === 'INPUT') {
286      return;
287    }
288    if (event.code === 'KeyA') {
289      this.updateSliderPosition(-MiniTimelineComponent.SLIDER_HORIZONTAL_STEP);
290    }
291    if (event.code === 'KeyD') {
292      this.updateSliderPosition(MiniTimelineComponent.SLIDER_HORIZONTAL_STEP);
293    }
294
295    if (event.code !== 'KeyW' && event.code !== 'KeyS') {
296      return;
297    }
298
299    const zoomTo = this.hoverTimestamp;
300    const isZoomIn = event.code === 'KeyW';
301    Analytics.Navigation.logZoom('key', 'timeline', isZoomIn ? 'in' : 'out');
302    isZoomIn ? this.zoomIn(zoomTo) : this.zoomOut(zoomTo);
303  }
304
305  onZoomChanged(zoom: TimeRange) {
306    const timelineData = assertDefined(this.timelineData);
307    timelineData.setZoom(zoom);
308    timelineData.setSelectionTimeRange(zoom);
309    this.drawer?.draw();
310    this.changeDetectorRef.detectChanges();
311  }
312
313  onSliderZoomChanged(zoom: TimeRange) {
314    this.onZoomChanged(zoom);
315    this.updateHoverTimestamp();
316  }
317
318  resetZoom() {
319    Analytics.Navigation.logZoom('reset', 'timeline');
320    this.onZoomChanged(
321      this.initialZoom ?? assertDefined(this.timelineData).getFullTimeRange(),
322    );
323  }
324
325  onZoomInButtonClick() {
326    Analytics.Navigation.logZoom('button', 'timeline', 'in');
327    this.zoomIn();
328  }
329
330  onZoomOutButtonClick() {
331    Analytics.Navigation.logZoom('button', 'timeline', 'out');
332    this.zoomOut();
333  }
334
335  @HostListener('wheel', ['$event'])
336  onScroll(event: WheelEvent) {
337    const moveDirection = this.getMoveDirection(event);
338
339    if (
340      (event.target as HTMLElement)?.id === 'mini-timeline-canvas' &&
341      event.deltaY !== 0 &&
342      moveDirection === 'y'
343    ) {
344      this.updateZoomByScrollEvent(event);
345    }
346
347    if (event.deltaX !== 0 && moveDirection === 'x') {
348      this.updateHorizontalScroll(event);
349    }
350  }
351
352  toggleBookmark() {
353    if (!this.lastRightClickTimeRange) {
354      return;
355    }
356    this.onToggleBookmark.emit({
357      range: this.lastRightClickTimeRange,
358      rangeContainsBookmark: this.bookmarks.some((bookmark) => {
359        return assertDefined(this.lastRightClickTimeRange).containsTimestamp(
360          bookmark,
361        );
362      }),
363    });
364  }
365
366  getToggleBookmarkText() {
367    if (!this.lastRightClickTimeRange) {
368      return 'Add/remove bookmark';
369    }
370
371    const rangeContainsBookmark = this.bookmarks.some((bookmark) => {
372      return assertDefined(this.lastRightClickTimeRange).containsTimestamp(
373        bookmark,
374      );
375    });
376    if (rangeContainsBookmark) {
377      return 'Remove bookmark';
378    }
379
380    return 'Add bookmark';
381  }
382
383  removeAllBookmarks() {
384    this.onRemoveAllBookmarks.emit();
385  }
386
387  private getMiniCanvasDrawerInput() {
388    const timelineData = assertDefined(this.timelineData);
389    return new MiniTimelineDrawerInput(
390      timelineData.getFullTimeRange(),
391      assertDefined(this.currentTracePosition).timestamp,
392      timelineData.getSelectionTimeRange(),
393      timelineData.getZoomRange(),
394      this.getTracesToShow(),
395      timelineData,
396      this.bookmarks,
397      this.store?.get('dark-mode') === 'true',
398    );
399  }
400
401  private makeHiPPICanvas() {
402    // Reset any size before computing new size to avoid it interfering with size computations
403    const canvas = this.getCanvas();
404    canvas.width = 0;
405    canvas.height = 0;
406    canvas.style.width = 'auto';
407    canvas.style.height = 'auto';
408
409    const miniTimelineWrapper = assertDefined(this.miniTimelineWrapper);
410    const width = miniTimelineWrapper.nativeElement.offsetWidth;
411    const height = miniTimelineWrapper.nativeElement.offsetHeight;
412
413    const HiPPIwidth = window.devicePixelRatio * width;
414    const HiPPIheight = window.devicePixelRatio * height;
415
416    canvas.width = HiPPIwidth;
417    canvas.height = HiPPIheight;
418    canvas.style.width = width + 'px';
419    canvas.style.height = height + 'px';
420
421    // ensure all drawing operations are scaled
422    if (window.devicePixelRatio !== 1) {
423      const context = canvas.getContext('2d')!;
424      context.scale(window.devicePixelRatio, window.devicePixelRatio);
425    }
426  }
427
428  // -1 for x direction, 1 for y direction
429  private getMoveDirection(event: WheelEvent): string {
430    this.lastMoves.push(event);
431    setTimeout(() => this.lastMoves.shift(), 1000);
432
433    const xMoveAmount = this.lastMoves.reduce(
434      (accumulator, it) => accumulator + it.deltaX,
435      0,
436    );
437    const yMoveAmount = this.lastMoves.reduce(
438      (accumulator, it) => accumulator + it.deltaY,
439      0,
440    );
441
442    if (Math.abs(yMoveAmount) > Math.abs(xMoveAmount)) {
443      return 'y';
444    } else {
445      return 'x';
446    }
447  }
448
449  private updateZoomByScrollEvent(event: WheelEvent) {
450    if (!this.hoverTimestamp) {
451      const canvas = event.target as HTMLCanvasElement;
452      const drawer = assertDefined(this.drawer);
453      this.lastMousePosX =
454        (drawer.getWidth() * event.offsetX) / canvas.offsetWidth;
455      this.updateHoverTimestamp();
456    }
457    const isZoomIn = event.deltaY < 0;
458    Analytics.Navigation.logZoom('scroll', 'timeline', isZoomIn ? 'in' : 'out');
459    if (isZoomIn) {
460      this.zoomIn(this.hoverTimestamp);
461    } else {
462      this.zoomOut(this.hoverTimestamp);
463    }
464  }
465
466  private updateHorizontalScroll(event: WheelEvent) {
467    const scrollAmount =
468      event.deltaX / MiniTimelineComponent.SENSITIVITY_FACTOR;
469    this.updateSliderPosition(scrollAmount);
470  }
471
472  private updateSliderPosition(step: number) {
473    const timelineData = assertDefined(this.timelineData);
474    const fullRange = timelineData.getFullTimeRange();
475    const zoomRange = timelineData.getZoomRange();
476
477    const usableRange = assertDefined(this.drawer).getUsableRange();
478    const transformer = new Transformer(
479      zoomRange,
480      usableRange,
481      assertDefined(timelineData.getTimestampConverter()),
482    );
483    const shiftAmount = transformer
484      .untransform(usableRange.from + step)
485      .minus(zoomRange.from.getValueNs());
486
487    let newFrom = zoomRange.from.add(shiftAmount.getValueNs());
488    let newTo = zoomRange.to.add(shiftAmount.getValueNs());
489
490    if (newFrom.getValueNs() < fullRange.from.getValueNs()) {
491      newTo = newTo.add(
492        fullRange.from.minus(newFrom.getValueNs()).getValueNs(),
493      );
494      newFrom = fullRange.from;
495    }
496
497    if (newTo.getValueNs() > fullRange.to.getValueNs()) {
498      newFrom = newFrom.minus(
499        newTo.minus(fullRange.to.getValueNs()).getValueNs(),
500      );
501      newTo = fullRange.to;
502    }
503
504    this.onZoomChanged(new TimeRange(newFrom, newTo));
505    this.updateHoverTimestamp();
506  }
507
508  private zoomIn(zoomOn?: Timestamp) {
509    this.zoom({nominator: 6n, denominator: 7n}, zoomOn);
510  }
511
512  private zoomOut(zoomOn?: Timestamp) {
513    this.zoom({nominator: 8n, denominator: 7n}, zoomOn);
514  }
515
516  private zoom(
517    zoomRatio: {nominator: bigint; denominator: bigint},
518    zoomOn?: Timestamp,
519  ) {
520    const timelineData = assertDefined(this.timelineData);
521    const fullRange = timelineData.getFullTimeRange();
522    const currentZoomRange = timelineData.getZoomRange();
523    const currentZoomWidth = currentZoomRange.to.minus(
524      currentZoomRange.from.getValueNs(),
525    );
526    const zoomToWidth = currentZoomWidth
527      .times(zoomRatio.nominator)
528      .div(zoomRatio.denominator);
529
530    const cursorPosition = this.currentTracePosition?.timestamp;
531    const currentMiddle = currentZoomRange.from
532      .add(currentZoomRange.to.getValueNs())
533      .div(2n);
534
535    let newFrom: Timestamp;
536    let newTo: Timestamp;
537
538    let zoomTowards = currentMiddle;
539    if (zoomOn === undefined) {
540      if (cursorPosition !== undefined && cursorPosition.in(currentZoomRange)) {
541        zoomTowards = cursorPosition;
542      }
543    } else if (zoomOn.in(currentZoomRange)) {
544      zoomTowards = zoomOn;
545    }
546
547    newFrom = zoomTowards.minus(
548      zoomToWidth
549        .times(
550          zoomTowards.minus(currentZoomRange.from.getValueNs()).getValueNs(),
551        )
552        .div(currentZoomWidth.getValueNs())
553        .getValueNs(),
554    );
555
556    newTo = zoomTowards.add(
557      zoomToWidth
558        .times(currentZoomRange.to.minus(zoomTowards.getValueNs()).getValueNs())
559        .div(currentZoomWidth.getValueNs())
560        .getValueNs(),
561    );
562
563    if (newFrom.getValueNs() < fullRange.from.getValueNs()) {
564      newTo = TimestampUtils.min(
565        fullRange.to,
566        newFrom.add(zoomToWidth.getValueNs()),
567      );
568      newFrom = fullRange.from;
569    }
570
571    if (newTo.getValueNs() > fullRange.to.getValueNs()) {
572      newFrom = TimestampUtils.max(
573        fullRange.from,
574        fullRange.to.minus(zoomToWidth.getValueNs()),
575      );
576      newTo = fullRange.to;
577    }
578
579    this.onZoomChanged(new TimeRange(newFrom, newTo));
580  }
581}
582