xref: /aosp_15_r20/external/perfetto/ui/src/core/scroll_helper.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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