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