/* * Copyright (C) 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, Input, Output, SimpleChanges, ViewChild, } from '@angular/core'; import {TimelineData} from 'app/timeline_data'; import {assertDefined} from 'common/assert_utils'; import {PersistentStore} from 'common/persistent_store'; import {TimeRange, Timestamp} from 'common/time'; import {TimestampUtils} from 'common/timestamp_utils'; import {Analytics} from 'logging/analytics'; import {Trace} from 'trace/trace'; import {TracePosition} from 'trace/trace_position'; import {TraceTypeUtils} from 'trace/trace_type'; import {MiniTimelineDrawer} from './drawer/mini_timeline_drawer'; import {MiniTimelineDrawerImpl} from './drawer/mini_timeline_drawer_impl'; import {MiniTimelineDrawerInput} from './drawer/mini_timeline_drawer_input'; import {MIN_SLIDER_WIDTH} from './slider_component'; import {Transformer} from './transformer'; @Component({ selector: 'mini-timeline', template: `
{{getToggleBookmarkText()}} Remove all bookmarks
`, styles: [ ` .mini-timeline-outer-wrapper { display: inline-flex; width: 100%; min-height: 5em; height: 100%; } .zoom-buttons { width: fit-content; display: flex; flex-direction: column; align-items: center; justify-content: center; background-color: var(--drawer-color); } .zoom-buttons button { width: fit-content; } #mini-timeline-wrapper { width: 100%; min-height: 5em; height: 100%; } .zoom-control { padding-right: ${MIN_SLIDER_WIDTH / 2}px; margin-top: -10px; } .zoom-control slider { flex-grow: 1; } `, ], }) export class MiniTimelineComponent { @Input() timelineData: TimelineData | undefined; @Input() currentTracePosition: TracePosition | undefined; @Input() selectedTraces: Array> | undefined; @Input() initialZoom: TimeRange | undefined; @Input() expandedTimelineScrollEvent: WheelEvent | undefined; @Input() expandedTimelineMouseXRatio: number | undefined; @Input() bookmarks: Timestamp[] = []; @Input() store: PersistentStore | undefined; @Output() readonly onTracePositionUpdate = new EventEmitter(); @Output() readonly onSeekTimestampUpdate = new EventEmitter< Timestamp | undefined >(); @Output() readonly onRemoveAllBookmarks = new EventEmitter(); @Output() readonly onToggleBookmark = new EventEmitter<{ range: TimeRange; rangeContainsBookmark: boolean; }>(); @Output() readonly onTraceClicked = new EventEmitter>(); @ViewChild('miniTimelineWrapper', {static: false}) miniTimelineWrapper: ElementRef | undefined; @ViewChild('canvas', {static: false}) canvasRef: ElementRef | undefined; getCanvas(): HTMLCanvasElement { return assertDefined(this.canvasRef).nativeElement; } drawer: MiniTimelineDrawer | undefined = undefined; private lastMousePosX: number | undefined; private hoverTimestamp: Timestamp | undefined; private lastMoves: WheelEvent[] = []; private lastRightClickTimeRange: TimeRange | undefined; constructor( @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, ) {} recordClickPosition(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); const lastRightClickPos = {x: event.offsetX, y: event.offsetY}; const drawer = assertDefined(this.drawer); const clickRange = drawer.getClickRange(lastRightClickPos); const zoomRange = assertDefined(this.timelineData).getZoomRange(); const usableRange = drawer.getUsableRange(); const transformer = new Transformer( zoomRange, usableRange, assertDefined(this.timelineData?.getTimestampConverter()), ); this.lastRightClickTimeRange = new TimeRange( transformer.untransform(clickRange.from), transformer.untransform(clickRange.to), ); } private static readonly SLIDER_HORIZONTAL_STEP = 30; private static readonly SENSITIVITY_FACTOR = 5; ngAfterViewInit(): void { this.makeHiPPICanvas(); const updateTimestampCallback = (timestamp: Timestamp) => { this.onSeekTimestampUpdate.emit(undefined); this.onTracePositionUpdate.emit( assertDefined(this.timelineData).makePositionFromActiveTrace(timestamp), ); }; const onClickCallback = ( timestamp: Timestamp, trace: Trace | undefined, ) => { if (trace) { this.onTraceClicked.emit(trace); } updateTimestampCallback(timestamp); }; this.drawer = new MiniTimelineDrawerImpl( this.getCanvas(), () => this.getMiniCanvasDrawerInput(), (position) => this.onSeekTimestampUpdate.emit(position), updateTimestampCallback, onClickCallback, ); if (this.initialZoom !== undefined) { this.onZoomChanged(this.initialZoom); } else { this.resetZoom(); } } ngOnChanges(changes: SimpleChanges) { if (changes['expandedTimelineScrollEvent']?.currentValue) { const event = changes['expandedTimelineScrollEvent'].currentValue; const moveDirection = this.getMoveDirection(event); if (event.deltaY !== 0 && moveDirection === 'y') { this.updateZoomByScrollEvent(event); } if (event.deltaX !== 0 && moveDirection === 'x') { this.updateHorizontalScroll(event); } } else if (this.drawer && changes['expandedTimelineMouseXRatio']) { const mouseXRatio: number | undefined = changes['expandedTimelineMouseXRatio'].currentValue; this.lastMousePosX = mouseXRatio ? mouseXRatio * this.drawer.getWidth() : undefined; this.updateHoverTimestamp(); } else if (this.drawer !== undefined) { this.drawer.draw(); } } getTracesToShow(): Array> { return assertDefined(this.selectedTraces) .slice() .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) .reverse(); // reversed to ensure display is ordered top to bottom } @HostListener('window:resize', ['$event']) onResize(event: Event) { this.makeHiPPICanvas(); this.drawer?.draw(); } trackMousePos(event: MouseEvent) { this.lastMousePosX = event.offsetX; this.updateHoverTimestamp(); } onMouseLeave(event: MouseEvent) { this.lastMousePosX = undefined; this.updateHoverTimestamp(); } updateHoverTimestamp() { if (!this.lastMousePosX) { this.hoverTimestamp = undefined; return; } const timelineData = assertDefined(this.timelineData); this.hoverTimestamp = new Transformer( timelineData.getZoomRange(), assertDefined(this.drawer).getUsableRange(), assertDefined(timelineData.getTimestampConverter()), ).untransform(this.lastMousePosX); } @HostListener('document:keydown', ['$event']) async handleKeyboardEvent(event: KeyboardEvent) { if (event.code === 'KeyA') { this.updateSliderPosition(-MiniTimelineComponent.SLIDER_HORIZONTAL_STEP); } if (event.code === 'KeyD') { this.updateSliderPosition(MiniTimelineComponent.SLIDER_HORIZONTAL_STEP); } if (event.code !== 'KeyW' && event.code !== 'KeyS') { return; } const zoomTo = this.hoverTimestamp; event.code === 'KeyW' ? this.zoomIn(zoomTo) : this.zoomOut(zoomTo); } onZoomChanged(zoom: TimeRange) { const timelineData = assertDefined(this.timelineData); timelineData.setZoom(zoom); timelineData.setSelectionTimeRange(zoom); this.drawer?.draw(); this.changeDetectorRef.detectChanges(); } onSliderZoomChanged(zoom: TimeRange) { this.onZoomChanged(zoom); this.updateHoverTimestamp(); } resetZoom() { Analytics.Navigation.logZoom('reset', 'timeline'); this.onZoomChanged(assertDefined(this.timelineData).getFullTimeRange()); } zoomIn(zoomOn?: Timestamp) { Analytics.Navigation.logZoom(this.getZoomSource(zoomOn), 'timeline', 'in'); this.zoom({nominator: 6n, denominator: 7n}, zoomOn); } zoomOut(zoomOn?: Timestamp) { Analytics.Navigation.logZoom(this.getZoomSource(zoomOn), 'timeline', 'out'); this.zoom({nominator: 8n, denominator: 7n}, zoomOn); } zoom( zoomRatio: {nominator: bigint; denominator: bigint}, zoomOn?: Timestamp, ) { const timelineData = assertDefined(this.timelineData); const fullRange = timelineData.getFullTimeRange(); const currentZoomRange = timelineData.getZoomRange(); const currentZoomWidth = currentZoomRange.to.minus( currentZoomRange.from.getValueNs(), ); const zoomToWidth = currentZoomWidth .times(zoomRatio.nominator) .div(zoomRatio.denominator); const cursorPosition = this.currentTracePosition?.timestamp; const currentMiddle = currentZoomRange.from .add(currentZoomRange.to.getValueNs()) .div(2n); let newFrom: Timestamp; let newTo: Timestamp; let zoomTowards = currentMiddle; if (zoomOn === undefined) { if (cursorPosition !== undefined && cursorPosition.in(currentZoomRange)) { zoomTowards = cursorPosition; } } else if (zoomOn.in(currentZoomRange)) { zoomTowards = zoomOn; } newFrom = zoomTowards.minus( zoomToWidth .times( zoomTowards.minus(currentZoomRange.from.getValueNs()).getValueNs(), ) .div(currentZoomWidth.getValueNs()) .getValueNs(), ); newTo = zoomTowards.add( zoomToWidth .times(currentZoomRange.to.minus(zoomTowards.getValueNs()).getValueNs()) .div(currentZoomWidth.getValueNs()) .getValueNs(), ); if (newFrom.getValueNs() < fullRange.from.getValueNs()) { newTo = TimestampUtils.min( fullRange.to, newFrom.add(zoomToWidth.getValueNs()), ); newFrom = fullRange.from; } if (newTo.getValueNs() > fullRange.to.getValueNs()) { newFrom = TimestampUtils.max( fullRange.from, fullRange.to.minus(zoomToWidth.getValueNs()), ); newTo = fullRange.to; } this.onZoomChanged(new TimeRange(newFrom, newTo)); } @HostListener('wheel', ['$event']) onScroll(event: WheelEvent) { const moveDirection = this.getMoveDirection(event); if ( (event.target as HTMLElement)?.id === 'mini-timeline-canvas' && event.deltaY !== 0 && moveDirection === 'y' ) { this.updateZoomByScrollEvent(event); } if (event.deltaX !== 0 && moveDirection === 'x') { this.updateHorizontalScroll(event); } } toggleBookmark() { if (!this.lastRightClickTimeRange) { return; } this.onToggleBookmark.emit({ range: this.lastRightClickTimeRange, rangeContainsBookmark: this.bookmarks.some((bookmark) => { return assertDefined(this.lastRightClickTimeRange).containsTimestamp( bookmark, ); }), }); } getToggleBookmarkText() { if (!this.lastRightClickTimeRange) { return 'Add/remove bookmark'; } const rangeContainsBookmark = this.bookmarks.some((bookmark) => { return assertDefined(this.lastRightClickTimeRange).containsTimestamp( bookmark, ); }); if (rangeContainsBookmark) { return 'Remove bookmark'; } return 'Add bookmark'; } removeAllBookmarks() { this.onRemoveAllBookmarks.emit(); } private getZoomSource(zoomOn?: Timestamp): 'scroll' | 'button' { if (zoomOn === undefined) { return 'button'; } return 'scroll'; } private getMiniCanvasDrawerInput() { const timelineData = assertDefined(this.timelineData); return new MiniTimelineDrawerInput( timelineData.getFullTimeRange(), assertDefined(this.currentTracePosition).timestamp, timelineData.getSelectionTimeRange(), timelineData.getZoomRange(), this.getTracesToShow(), timelineData, this.bookmarks, this.store?.get('dark-mode') === 'true', ); } private makeHiPPICanvas() { // Reset any size before computing new size to avoid it interfering with size computations const canvas = this.getCanvas(); canvas.width = 0; canvas.height = 0; canvas.style.width = 'auto'; canvas.style.height = 'auto'; const miniTimelineWrapper = assertDefined(this.miniTimelineWrapper); const width = miniTimelineWrapper.nativeElement.offsetWidth; const height = miniTimelineWrapper.nativeElement.offsetHeight; const HiPPIwidth = window.devicePixelRatio * width; const HiPPIheight = window.devicePixelRatio * height; canvas.width = HiPPIwidth; canvas.height = HiPPIheight; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; // ensure all drawing operations are scaled if (window.devicePixelRatio !== 1) { const context = canvas.getContext('2d')!; context.scale(window.devicePixelRatio, window.devicePixelRatio); } } // -1 for x direction, 1 for y direction private getMoveDirection(event: WheelEvent): string { this.lastMoves.push(event); setTimeout(() => this.lastMoves.shift(), 1000); const xMoveAmount = this.lastMoves.reduce( (accumulator, it) => accumulator + it.deltaX, 0, ); const yMoveAmount = this.lastMoves.reduce( (accumulator, it) => accumulator + it.deltaY, 0, ); if (Math.abs(yMoveAmount) > Math.abs(xMoveAmount)) { return 'y'; } else { return 'x'; } } private updateZoomByScrollEvent(event: WheelEvent) { if (!this.hoverTimestamp) { const canvas = event.target as HTMLCanvasElement; const drawer = assertDefined(this.drawer); this.lastMousePosX = (drawer.getWidth() * event.offsetX) / canvas.offsetWidth; this.updateHoverTimestamp(); } if (event.deltaY < 0) { this.zoomIn(this.hoverTimestamp); } else { this.zoomOut(this.hoverTimestamp); } } private updateHorizontalScroll(event: WheelEvent) { const scrollAmount = event.deltaX / MiniTimelineComponent.SENSITIVITY_FACTOR; this.updateSliderPosition(scrollAmount); } private updateSliderPosition(step: number) { const timelineData = assertDefined(this.timelineData); const fullRange = timelineData.getFullTimeRange(); const zoomRange = timelineData.getZoomRange(); const usableRange = assertDefined(this.drawer).getUsableRange(); const transformer = new Transformer( zoomRange, usableRange, assertDefined(timelineData.getTimestampConverter()), ); const shiftAmount = transformer .untransform(usableRange.from + step) .minus(zoomRange.from.getValueNs()); let newFrom = zoomRange.from.add(shiftAmount.getValueNs()); let newTo = zoomRange.to.add(shiftAmount.getValueNs()); if (newFrom.getValueNs() < fullRange.from.getValueNs()) { newTo = newTo.add( fullRange.from.minus(newFrom.getValueNs()).getValueNs(), ); newFrom = fullRange.from; } if (newTo.getValueNs() > fullRange.to.getValueNs()) { newFrom = newFrom.minus( newTo.minus(fullRange.to.getValueNs()).getValueNs(), ); newTo = fullRange.to; } this.onZoomChanged(new TimeRange(newFrom, newTo)); this.updateHoverTimestamp(); } }