1// Copyright (C) 2018 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 * as m from 'mithril';
16
17import {Actions} from '../common/actions';
18import {TimeSpan} from '../common/time';
19
20import {TRACK_SHELL_WIDTH} from './css_constants';
21import {DetailsPanel} from './details_panel';
22import {globals} from './globals';
23import {NotesPanel} from './notes_panel';
24import {OverviewTimelinePanel} from './overview_timeline_panel';
25import {createPage} from './pages';
26import {PanAndZoomHandler} from './pan_and_zoom_handler';
27import {AnyAttrsVnode, PanelContainer} from './panel_container';
28import {TickmarkPanel} from './tickmark_panel';
29import {TimeAxisPanel} from './time_axis_panel';
30import {computeZoom} from './time_scale';
31import {TimeSelectionPanel} from './time_selection_panel';
32import {DISMISSED_PANNING_HINT_KEY} from './topbar';
33import {TrackGroupPanel} from './track_group_panel';
34import {TrackPanel} from './track_panel';
35import {VideoPanel} from './video_panel';
36
37const SIDEBAR_WIDTH = 256;
38
39// Checks if the mousePos is within 3px of the start or end of the
40// current selected time range.
41function onTimeRangeBoundary(mousePos: number): 'START'|'END'|null {
42  const selection = globals.state.currentSelection;
43  if (selection !== null && selection.kind === 'AREA') {
44    // If frontend selectedArea exists then we are in the process of editing the
45    // time range and need to use that value instead.
46    const area = globals.frontendLocalState.selectedArea ?
47        globals.frontendLocalState.selectedArea :
48        globals.state.areas[selection.areaId];
49    const start = globals.frontendLocalState.timeScale.timeToPx(area.startSec);
50    const end = globals.frontendLocalState.timeScale.timeToPx(area.endSec);
51    const startDrag = mousePos - TRACK_SHELL_WIDTH;
52    const startDistance = Math.abs(start - startDrag);
53    const endDistance = Math.abs(end - startDrag);
54    const range = 3 * window.devicePixelRatio;
55    // We might be within 3px of both boundaries but we should choose
56    // the closest one.
57    if (startDistance < range && startDistance <= endDistance) return 'START';
58    if (endDistance < range && endDistance <= startDistance) return 'END';
59  }
60  return null;
61}
62
63/**
64 * Top-most level component for the viewer page. Holds tracks, brush timeline,
65 * panels, and everything else that's part of the main trace viewer page.
66 */
67class TraceViewer implements m.ClassComponent {
68  private onResize: () => void = () => {};
69  private zoomContent?: PanAndZoomHandler;
70  // Used to prevent global deselection if a pan/drag select occurred.
71  private keepCurrentSelection = false;
72
73  oncreate(vnode: m.CVnodeDOM) {
74    const frontendLocalState = globals.frontendLocalState;
75    const updateDimensions = () => {
76      const rect = vnode.dom.getBoundingClientRect();
77      frontendLocalState.updateLocalLimits(
78          0,
79          rect.width - TRACK_SHELL_WIDTH -
80              frontendLocalState.getScrollbarWidth());
81    };
82
83    updateDimensions();
84
85    // TODO: Do resize handling better.
86    this.onResize = () => {
87      updateDimensions();
88      globals.rafScheduler.scheduleFullRedraw();
89    };
90
91    // Once ResizeObservers are out, we can stop accessing the window here.
92    window.addEventListener('resize', this.onResize);
93
94    const panZoomEl =
95        vnode.dom.querySelector('.pan-and-zoom-content') as HTMLElement;
96
97    this.zoomContent = new PanAndZoomHandler({
98      element: panZoomEl,
99      contentOffsetX: SIDEBAR_WIDTH,
100      onPanned: (pannedPx: number) => {
101        this.keepCurrentSelection = true;
102        const traceTime = globals.state.traceTime;
103        const vizTime = globals.frontendLocalState.visibleWindowTime;
104        const origDelta = vizTime.duration;
105        const tDelta = frontendLocalState.timeScale.deltaPxToDuration(pannedPx);
106        let tStart = vizTime.start + tDelta;
107        let tEnd = vizTime.end + tDelta;
108        if (tStart < traceTime.startSec) {
109          tStart = traceTime.startSec;
110          tEnd = tStart + origDelta;
111        } else if (tEnd > traceTime.endSec) {
112          tEnd = traceTime.endSec;
113          tStart = tEnd - origDelta;
114        }
115        frontendLocalState.updateVisibleTime(new TimeSpan(tStart, tEnd));
116        // If the user has panned they no longer need the hint.
117        localStorage.setItem(DISMISSED_PANNING_HINT_KEY, 'true');
118        globals.rafScheduler.scheduleRedraw();
119      },
120      onZoomed: (zoomedPositionPx: number, zoomRatio: number) => {
121        // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH.
122        // TODO(hjd): Improve support for zooming in overview timeline.
123        const span = frontendLocalState.visibleWindowTime;
124        const scale = frontendLocalState.timeScale;
125        const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH;
126        const newSpan = computeZoom(scale, span, 1 - zoomRatio, zoomPx);
127        frontendLocalState.updateVisibleTime(newSpan);
128        globals.rafScheduler.scheduleRedraw();
129      },
130      editSelection: (currentPx: number) => {
131        return onTimeRangeBoundary(currentPx) !== null;
132      },
133      onSelection: (
134          dragStartX: number,
135          dragStartY: number,
136          prevX: number,
137          currentX: number,
138          currentY: number,
139          editing: boolean) => {
140        const traceTime = globals.state.traceTime;
141        const scale = frontendLocalState.timeScale;
142        this.keepCurrentSelection = true;
143        if (editing) {
144          const selection = globals.state.currentSelection;
145          if (selection !== null && selection.kind === 'AREA') {
146            const area = globals.frontendLocalState.selectedArea ?
147                globals.frontendLocalState.selectedArea :
148                globals.state.areas[selection.areaId];
149            let newTime = scale.pxToTime(currentX - TRACK_SHELL_WIDTH);
150            // Have to check again for when one boundary crosses over the other.
151            const curBoundary = onTimeRangeBoundary(prevX);
152            if (curBoundary == null) return;
153            const keepTime =
154                curBoundary === 'START' ? area.endSec : area.startSec;
155            // Don't drag selection outside of current screen.
156            if (newTime < keepTime) {
157              newTime = Math.max(newTime, scale.pxToTime(scale.startPx));
158            } else {
159              newTime = Math.min(newTime, scale.pxToTime(scale.endPx));
160            }
161            // When editing the time range we always use the saved tracks,
162            // since these will not change.
163            frontendLocalState.selectArea(
164                Math.max(Math.min(keepTime, newTime), traceTime.startSec),
165                Math.min(Math.max(keepTime, newTime), traceTime.endSec),
166                globals.state.areas[selection.areaId].tracks);
167          }
168        } else {
169          let startPx = Math.min(dragStartX, currentX) - TRACK_SHELL_WIDTH;
170          let endPx = Math.max(dragStartX, currentX) - TRACK_SHELL_WIDTH;
171          if (startPx < 0 && endPx < 0) return;
172          startPx = Math.max(startPx, scale.startPx);
173          endPx = Math.min(endPx, scale.endPx);
174          frontendLocalState.selectArea(
175              scale.pxToTime(startPx), scale.pxToTime(endPx));
176          frontendLocalState.areaY.start = dragStartY;
177          frontendLocalState.areaY.end = currentY;
178        }
179        globals.rafScheduler.scheduleRedraw();
180      },
181      endSelection: (edit: boolean) => {
182        globals.frontendLocalState.areaY.start = undefined;
183        globals.frontendLocalState.areaY.end = undefined;
184        const area = globals.frontendLocalState.selectedArea;
185        // If we are editing we need to pass the current id through to ensure
186        // the marked area with that id is also updated.
187        if (edit) {
188          const selection = globals.state.currentSelection;
189          if (selection !== null && selection.kind === 'AREA' && area) {
190            globals.dispatch(
191                Actions.editArea({area, areaId: selection.areaId}));
192          }
193        } else if (area) {
194          globals.makeSelection(Actions.selectArea({area}));
195        }
196        // Now the selection has ended we stored the final selected area in the
197        // global state and can remove the in progress selection from the
198        // frontendLocalState.
199        globals.frontendLocalState.deselectArea();
200        // Full redraw to color track shell.
201        globals.rafScheduler.scheduleFullRedraw();
202      }
203    });
204  }
205
206  onremove() {
207    window.removeEventListener('resize', this.onResize);
208    if (this.zoomContent) this.zoomContent.shutdown();
209  }
210
211  view() {
212    const scrollingPanels: AnyAttrsVnode[] = globals.state.scrollingTracks.map(
213        id => m(TrackPanel, {key: id, id, selectable: true}));
214
215    for (const group of Object.values(globals.state.trackGroups)) {
216      scrollingPanels.push(m(TrackGroupPanel, {
217        trackGroupId: group.id,
218        key: `trackgroup-${group.id}`,
219        selectable: true,
220      }));
221      if (group.collapsed) continue;
222      // The first track is the summary track, and is displayed as part of the
223      // group panel, we don't want to display it twice so we start from 1.
224      for (let i = 1; i < group.tracks.length; ++i) {
225        const id = group.tracks[i];
226        scrollingPanels.push(m(TrackPanel, {
227          key: `track-${group.id}-${id}`,
228          id,
229          selectable: true,
230        }));
231      }
232    }
233
234    return m(
235        '.page',
236        m('.split-panel',
237          m('.pan-and-zoom-content',
238            {
239              onclick: () => {
240                // We don't want to deselect when panning/drag selecting.
241                if (this.keepCurrentSelection) {
242                  this.keepCurrentSelection = false;
243                  return;
244                }
245                globals.makeSelection(Actions.deselect({}));
246              }
247            },
248            m('.pinned-panel-container', m(PanelContainer, {
249                doesScroll: false,
250                panels: [
251                  m(OverviewTimelinePanel, {key: 'overview'}),
252                  m(TimeAxisPanel, {key: 'timeaxis'}),
253                  m(TimeSelectionPanel, {key: 'timeselection'}),
254                  m(NotesPanel, {key: 'notes'}),
255                  m(TickmarkPanel, {key: 'searchTickmarks'}),
256                  ...globals.state.pinnedTracks.map(
257                      id => m(TrackPanel, {key: id, id, selectable: true})),
258                ],
259                kind: 'OVERVIEW',
260              })),
261            m('.scrolling-panel-container', m(PanelContainer, {
262                doesScroll: true,
263                panels: scrollingPanels,
264                kind: 'TRACKS',
265              }))),
266          m('.video-panel',
267            (globals.state.videoEnabled && globals.state.video != null) ?
268                m(VideoPanel) :
269                null)),
270        m(DetailsPanel));
271  }
272}
273
274export const ViewerPage = createPage({
275  view() {
276    return m(TraceViewer);
277  }
278});
279