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