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 m from 'mithril'; 16 17import {findRef, toHTMLElement} from '../base/dom_utils'; 18import {clamp} from '../base/math_utils'; 19import {Time} from '../base/time'; 20import {Actions} from '../common/actions'; 21import {TrackCacheEntry} from '../common/track_cache'; 22import {featureFlags} from '../core/feature_flags'; 23import {raf} from '../core/raf_scheduler'; 24import {TrackTags} from '../public'; 25 26import {TRACK_SHELL_WIDTH} from './css_constants'; 27import {globals} from './globals'; 28import {NotesPanel} from './notes_panel'; 29import {OverviewTimelinePanel} from './overview_timeline_panel'; 30import {createPage} from './pages'; 31import {PanAndZoomHandler} from './pan_and_zoom_handler'; 32import {Panel, PanelContainer, PanelOrGroup} from './panel_container'; 33import {publishShowPanningHint} from './publish'; 34import {TabPanel} from './tab_panel'; 35import {TickmarkPanel} from './tickmark_panel'; 36import {TimeAxisPanel} from './time_axis_panel'; 37import {TimeSelectionPanel} from './time_selection_panel'; 38import {DISMISSED_PANNING_HINT_KEY} from './topbar'; 39import {TrackGroupPanel} from './track_group_panel'; 40import {TrackPanel} from './track_panel'; 41import {assertExists} from '../base/logging'; 42 43const OVERVIEW_PANEL_FLAG = featureFlags.register({ 44 id: 'overviewVisible', 45 name: 'Overview Panel', 46 description: 'Show the panel providing an overview of the trace', 47 defaultValue: true, 48}); 49 50// Checks if the mousePos is within 3px of the start or end of the 51// current selected time range. 52function onTimeRangeBoundary(mousePos: number): 'START' | 'END' | null { 53 const selection = globals.state.selection; 54 if (selection.kind === 'area') { 55 // If frontend selectedArea exists then we are in the process of editing the 56 // time range and need to use that value instead. 57 const area = globals.timeline.selectedArea 58 ? globals.timeline.selectedArea 59 : selection; 60 const {visibleTimeScale} = globals.timeline; 61 const start = visibleTimeScale.timeToPx(area.start); 62 const end = visibleTimeScale.timeToPx(area.end); 63 const startDrag = mousePos - TRACK_SHELL_WIDTH; 64 const startDistance = Math.abs(start - startDrag); 65 const endDistance = Math.abs(end - startDrag); 66 const range = 3 * window.devicePixelRatio; 67 // We might be within 3px of both boundaries but we should choose 68 // the closest one. 69 if (startDistance < range && startDistance <= endDistance) return 'START'; 70 if (endDistance < range && endDistance <= startDistance) return 'END'; 71 } 72 return null; 73} 74 75/** 76 * Top-most level component for the viewer page. Holds tracks, brush timeline, 77 * panels, and everything else that's part of the main trace viewer page. 78 */ 79class TraceViewer implements m.ClassComponent { 80 private zoomContent?: PanAndZoomHandler; 81 // Used to prevent global deselection if a pan/drag select occurred. 82 private keepCurrentSelection = false; 83 84 private overviewTimelinePanel = new OverviewTimelinePanel(); 85 private timeAxisPanel = new TimeAxisPanel(); 86 private timeSelectionPanel = new TimeSelectionPanel(); 87 private notesPanel = new NotesPanel(); 88 private tickmarkPanel = new TickmarkPanel(); 89 90 private readonly PAN_ZOOM_CONTENT_REF = 'pan-and-zoom-content'; 91 92 oncreate(vnode: m.CVnodeDOM) { 93 const timeline = globals.timeline; 94 const panZoomElRaw = findRef(vnode.dom, this.PAN_ZOOM_CONTENT_REF); 95 const panZoomEl = toHTMLElement(assertExists(panZoomElRaw)); 96 97 this.zoomContent = new PanAndZoomHandler({ 98 element: panZoomEl, 99 onPanned: (pannedPx: number) => { 100 const {visibleTimeScale} = globals.timeline; 101 102 this.keepCurrentSelection = true; 103 const tDelta = visibleTimeScale.pxDeltaToDuration(pannedPx); 104 timeline.panVisibleWindow(tDelta); 105 106 // If the user has panned they no longer need the hint. 107 localStorage.setItem(DISMISSED_PANNING_HINT_KEY, 'true'); 108 raf.scheduleRedraw(); 109 }, 110 onZoomed: (zoomedPositionPx: number, zoomRatio: number) => { 111 // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH. 112 // TODO(hjd): Improve support for zooming in overview timeline. 113 const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH; 114 const rect = vnode.dom.getBoundingClientRect(); 115 const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH); 116 timeline.zoomVisibleWindow(1 - zoomRatio, centerPoint); 117 raf.scheduleRedraw(); 118 }, 119 editSelection: (currentPx: number) => { 120 return onTimeRangeBoundary(currentPx) !== null; 121 }, 122 onSelection: ( 123 dragStartX: number, 124 dragStartY: number, 125 prevX: number, 126 currentX: number, 127 currentY: number, 128 editing: boolean, 129 ) => { 130 const traceTime = globals.traceContext; 131 const {visibleTimeScale} = timeline; 132 this.keepCurrentSelection = true; 133 if (editing) { 134 const selection = globals.state.selection; 135 if (selection.kind === 'area') { 136 const area = globals.timeline.selectedArea 137 ? globals.timeline.selectedArea 138 : selection; 139 let newTime = visibleTimeScale 140 .pxToHpTime(currentX - TRACK_SHELL_WIDTH) 141 .toTime(); 142 // Have to check again for when one boundary crosses over the other. 143 const curBoundary = onTimeRangeBoundary(prevX); 144 if (curBoundary == null) return; 145 const keepTime = curBoundary === 'START' ? area.end : area.start; 146 // Don't drag selection outside of current screen. 147 if (newTime < keepTime) { 148 newTime = Time.max( 149 newTime, 150 visibleTimeScale.timeSpan.start.toTime(), 151 ); 152 } else { 153 newTime = Time.min( 154 newTime, 155 visibleTimeScale.timeSpan.end.toTime(), 156 ); 157 } 158 // When editing the time range we always use the saved tracks, 159 // since these will not change. 160 timeline.selectArea( 161 Time.max(Time.min(keepTime, newTime), traceTime.start), 162 Time.min(Time.max(keepTime, newTime), traceTime.end), 163 selection.tracks, 164 ); 165 } 166 } else { 167 let startPx = Math.min(dragStartX, currentX) - TRACK_SHELL_WIDTH; 168 let endPx = Math.max(dragStartX, currentX) - TRACK_SHELL_WIDTH; 169 if (startPx < 0 && endPx < 0) return; 170 const {pxSpan} = visibleTimeScale; 171 startPx = clamp(startPx, pxSpan.start, pxSpan.end); 172 endPx = clamp(endPx, pxSpan.start, pxSpan.end); 173 timeline.selectArea( 174 visibleTimeScale.pxToHpTime(startPx).toTime('floor'), 175 visibleTimeScale.pxToHpTime(endPx).toTime('ceil'), 176 ); 177 timeline.areaY.start = dragStartY; 178 timeline.areaY.end = currentY; 179 publishShowPanningHint(); 180 } 181 raf.scheduleRedraw(); 182 }, 183 endSelection: (edit: boolean) => { 184 globals.timeline.areaY.start = undefined; 185 globals.timeline.areaY.end = undefined; 186 const area = globals.timeline.selectedArea; 187 // If we are editing we need to pass the current id through to ensure 188 // the marked area with that id is also updated. 189 if (edit) { 190 const selection = globals.state.selection; 191 if (selection.kind === 'area' && area) { 192 globals.dispatch(Actions.selectArea({...area})); 193 } 194 } else if (area) { 195 globals.makeSelection(Actions.selectArea({...area})); 196 } 197 // Now the selection has ended we stored the final selected area in the 198 // global state and can remove the in progress selection from the 199 // timeline. 200 globals.timeline.deselectArea(); 201 // Full redraw to color track shell. 202 raf.scheduleFullRedraw(); 203 }, 204 }); 205 } 206 207 onremove() { 208 if (this.zoomContent) this.zoomContent.dispose(); 209 } 210 211 view() { 212 const scrollingPanels: PanelOrGroup[] = globals.state.scrollingTracks.map( 213 (key) => { 214 const trackBundle = this.resolveTrack(key); 215 return new TrackPanel({ 216 trackKey: key, 217 title: trackBundle.title, 218 tags: trackBundle.tags, 219 trackFSM: trackBundle.trackFSM, 220 closeable: trackBundle.closeable, 221 }); 222 }, 223 ); 224 225 for (const group of Object.values(globals.state.trackGroups)) { 226 const key = group.summaryTrack; 227 let headerPanel; 228 if (key) { 229 const trackBundle = this.resolveTrack(key); 230 headerPanel = new TrackGroupPanel({ 231 groupKey: group.key, 232 trackFSM: trackBundle.trackFSM, 233 labels: trackBundle.labels, 234 tags: trackBundle.tags, 235 collapsed: group.collapsed, 236 title: group.name, 237 }); 238 } else { 239 headerPanel = new TrackGroupPanel({ 240 groupKey: group.key, 241 collapsed: group.collapsed, 242 title: group.name, 243 }); 244 } 245 246 const childTracks: Panel[] = []; 247 if (!group.collapsed) { 248 for (const key of group.tracks) { 249 const trackBundle = this.resolveTrack(key); 250 const panel = new TrackPanel({ 251 trackKey: key, 252 title: trackBundle.title, 253 tags: trackBundle.tags, 254 trackFSM: trackBundle.trackFSM, 255 closeable: trackBundle.closeable, 256 }); 257 childTracks.push(panel); 258 } 259 } 260 261 scrollingPanels.push({ 262 kind: 'group', 263 collapsed: group.collapsed, 264 childPanels: childTracks, 265 header: headerPanel, 266 }); 267 } 268 269 const overviewPanel = []; 270 if (OVERVIEW_PANEL_FLAG.get()) { 271 overviewPanel.push(this.overviewTimelinePanel); 272 } 273 274 const result = m( 275 '.page.viewer-page', 276 m( 277 '.pan-and-zoom-content', 278 { 279 ref: this.PAN_ZOOM_CONTENT_REF, 280 onclick: () => { 281 // We don't want to deselect when panning/drag selecting. 282 if (this.keepCurrentSelection) { 283 this.keepCurrentSelection = false; 284 return; 285 } 286 globals.clearSelection(); 287 }, 288 }, 289 m( 290 '.pf-timeline-header', 291 m(PanelContainer, { 292 className: 'header-panel-container', 293 panels: [ 294 ...overviewPanel, 295 this.timeAxisPanel, 296 this.timeSelectionPanel, 297 this.notesPanel, 298 this.tickmarkPanel, 299 ], 300 }), 301 m('.scrollbar-spacer-vertical'), 302 ), 303 m(PanelContainer, { 304 className: 'pinned-panel-container', 305 panels: globals.state.pinnedTracks.map((key) => { 306 const trackBundle = this.resolveTrack(key); 307 return new TrackPanel({ 308 trackKey: key, 309 title: trackBundle.title, 310 tags: trackBundle.tags, 311 trackFSM: trackBundle.trackFSM, 312 revealOnCreate: true, 313 closeable: trackBundle.closeable, 314 }); 315 }), 316 }), 317 m(PanelContainer, { 318 className: 'scrolling-panel-container', 319 panels: scrollingPanels, 320 onPanelStackResize: (width) => { 321 const timelineWidth = width - TRACK_SHELL_WIDTH; 322 globals.timeline.updateLocalLimits(0, timelineWidth); 323 }, 324 }), 325 ), 326 this.renderTabPanel(), 327 ); 328 329 globals.trackManager.flushOldTracks(); 330 return result; 331 } 332 333 // Resolve a track and its metadata through the track cache 334 private resolveTrack(key: string): TrackBundle { 335 const trackState = globals.state.tracks[key]; 336 const {uri, name, labels, closeable} = trackState; 337 const trackDesc = globals.trackManager.resolveTrackInfo(uri); 338 const trackCacheEntry = 339 trackDesc && globals.trackManager.resolveTrack(key, trackDesc); 340 const trackFSM = trackCacheEntry; 341 const tags = trackCacheEntry?.desc.tags; 342 const trackIds = trackCacheEntry?.desc.trackIds; 343 return { 344 title: name, 345 tags, 346 trackFSM, 347 labels, 348 trackIds, 349 closeable: closeable ?? false, 350 }; 351 } 352 353 private renderTabPanel() { 354 return m(TabPanel); 355 } 356} 357 358interface TrackBundle { 359 title: string; 360 closeable: boolean; 361 trackFSM?: TrackCacheEntry; 362 tags?: TrackTags; 363 labels?: string[]; 364 trackIds?: number[]; 365} 366 367export const ViewerPage = createPage({ 368 view() { 369 return m(TraceViewer); 370 }, 371}); 372