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