1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {HighPrecisionTimeSpan} from '../base/high_precision_time_span'; 16import {time} from '../base/time'; 17import {ScrollToArgs} from '../public/scroll_helper'; 18import {TraceInfo} from '../public/trace_info'; 19import {Workspace} from '../public/workspace'; 20import {raf} from './raf_scheduler'; 21import {TimelineImpl} from './timeline'; 22import {TrackManagerImpl} from './track_manager'; 23 24// A helper class to help jumping to tracks and time ranges. 25// This class must NOT alter in any way the selection status. That 26// responsibility belongs to SelectionManager (which uses this). 27export class ScrollHelper { 28 constructor( 29 private traceInfo: TraceInfo, 30 private timeline: TimelineImpl, 31 private workspace: Workspace, 32 private trackManager: TrackManagerImpl, 33 ) {} 34 35 // See comments in ScrollToArgs for the intended semantics. 36 scrollTo(args: ScrollToArgs) { 37 const {time, track} = args; 38 raf.scheduleCanvasRedraw(); 39 40 if (time !== undefined) { 41 if (time.end === undefined) { 42 this.timeline.panToTimestamp(time.start); 43 } else if (time.viewPercentage !== undefined) { 44 this.focusHorizontalRangePercentage( 45 time.start, 46 time.end, 47 time.viewPercentage, 48 ); 49 } else { 50 this.focusHorizontalRange(time.start, time.end); 51 } 52 } 53 54 if (track !== undefined) { 55 this.verticalScrollToTrack(track.uri, track.expandGroup ?? false); 56 } 57 } 58 59 private focusHorizontalRangePercentage( 60 start: time, 61 end: time, 62 viewPercentage: number, 63 ): void { 64 const aoi = HighPrecisionTimeSpan.fromTime(start, end); 65 66 if (viewPercentage <= 0.0 || viewPercentage > 1.0) { 67 console.warn( 68 'Invalid value for [viewPercentage]. ' + 69 'Value must be between 0.0 (exclusive) and 1.0 (inclusive).', 70 ); 71 // Default to 50%. 72 viewPercentage = 0.5; 73 } 74 const paddingPercentage = 1.0 - viewPercentage; 75 const halfPaddingTime = (aoi.duration * paddingPercentage) / 2; 76 this.timeline.updateVisibleTimeHP(aoi.pad(halfPaddingTime)); 77 } 78 79 private focusHorizontalRange(start: time, end: time): void { 80 const visible = this.timeline.visibleWindow; 81 const aoi = HighPrecisionTimeSpan.fromTime(start, end); 82 const fillRatio = 5; // Default amount to make the AOI fill the viewport 83 const padRatio = (fillRatio - 1) / 2; 84 85 // If the area of interest already fills more than half the viewport, zoom 86 // out so that the AOI fills 20% of the viewport 87 if (aoi.duration * 2 > visible.duration) { 88 const padded = aoi.pad(aoi.duration * padRatio); 89 this.timeline.updateVisibleTimeHP(padded); 90 } else { 91 // Center visible window on the middle of the AOI, preserving zoom level. 92 const newStart = aoi.midpoint.subNumber(visible.duration / 2); 93 94 // Adjust the new visible window if it intersects with the trace boundaries. 95 // It's needed to make the "update the zoom level if visible window doesn't 96 // change" logic reliable. 97 const newVisibleWindow = new HighPrecisionTimeSpan( 98 newStart, 99 visible.duration, 100 ).fitWithin(this.traceInfo.start, this.traceInfo.end); 101 102 // If preserving the zoom doesn't change the visible window, consider this 103 // to be the "second" hotkey press, so just make the AOI fill 20% of the 104 // viewport 105 if (newVisibleWindow.equals(visible)) { 106 const padded = aoi.pad(aoi.duration * padRatio); 107 this.timeline.updateVisibleTimeHP(padded); 108 } else { 109 this.timeline.updateVisibleTimeHP(newVisibleWindow); 110 } 111 } 112 } 113 114 private verticalScrollToTrack(trackUri: string, openGroup: boolean) { 115 // Find the actual track node that uses that URI, we need various properties 116 // from it. 117 const trackNode = this.workspace.findTrackByUri(trackUri); 118 if (!trackNode) return; 119 120 // Try finding the track directly. 121 const element = document.getElementById(trackNode.id); 122 if (element) { 123 // block: 'nearest' means that it will only scroll if the track is not 124 // currently in view. 125 element.scrollIntoView({behavior: 'smooth', block: 'nearest'}); 126 return; 127 } 128 129 // If we get here, the element for this track was not present in the DOM, 130 // which might be because it's inside a collapsed group. 131 if (openGroup) { 132 // Try to reveal the track node in the workspace by opening up all 133 // ancestor groups, and mark the track URI to be scrolled to in the 134 // future. 135 trackNode.reveal(); 136 this.trackManager.scrollToTrackNodeId = trackNode.id; 137 } else { 138 // Find the closest visible ancestor of our target track and scroll to 139 // that instead. 140 const container = trackNode.findClosestVisibleAncestor(); 141 document 142 .getElementById(container.id) 143 ?.scrollIntoView({behavior: 'smooth', block: 'nearest'}); 144 } 145 } 146} 147