|  | // Copyright (C) 2018 The Android Open Source Project | 
|  | // | 
|  | // Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | // you may not use this file except in compliance with the License. | 
|  | // You may obtain a copy of the License at | 
|  | // | 
|  | //      http://www.apache.org/licenses/LICENSE-2.0 | 
|  | // | 
|  | // Unless required by applicable law or agreed to in writing, software | 
|  | // distributed under the License is distributed on an "AS IS" BASIS, | 
|  | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | // See the License for the specific language governing permissions and | 
|  | // limitations under the License. | 
|  |  | 
|  | import m from 'mithril'; | 
|  |  | 
|  | import {findRef, toHTMLElement} from '../base/dom_utils'; | 
|  | import {clamp} from '../base/math_utils'; | 
|  | import {Time} from '../base/time'; | 
|  | import {Actions} from '../common/actions'; | 
|  | import {TrackCacheEntry} from '../common/track_cache'; | 
|  | import {featureFlags} from '../core/feature_flags'; | 
|  | import {raf} from '../core/raf_scheduler'; | 
|  | import {TrackTags} from '../public'; | 
|  |  | 
|  | import {TRACK_SHELL_WIDTH} from './css_constants'; | 
|  | import {globals} from './globals'; | 
|  | import {NotesPanel} from './notes_panel'; | 
|  | import {OverviewTimelinePanel} from './overview_timeline_panel'; | 
|  | import {createPage} from './pages'; | 
|  | import {PanAndZoomHandler} from './pan_and_zoom_handler'; | 
|  | import {Panel, PanelContainer, PanelOrGroup} from './panel_container'; | 
|  | import {publishShowPanningHint} from './publish'; | 
|  | import {TabPanel} from './tab_panel'; | 
|  | import {TickmarkPanel} from './tickmark_panel'; | 
|  | import {TimeAxisPanel} from './time_axis_panel'; | 
|  | import {TimeSelectionPanel} from './time_selection_panel'; | 
|  | import {DISMISSED_PANNING_HINT_KEY} from './topbar'; | 
|  | import {TrackGroupPanel} from './track_group_panel'; | 
|  | import {TrackPanel} from './track_panel'; | 
|  | import {assertExists} from '../base/logging'; | 
|  | import {getLegacySelection} from '../common/state'; | 
|  |  | 
|  | const OVERVIEW_PANEL_FLAG = featureFlags.register({ | 
|  | id: 'overviewVisible', | 
|  | name: 'Overview Panel', | 
|  | description: 'Show the panel providing an overview of the trace', | 
|  | defaultValue: true, | 
|  | }); | 
|  |  | 
|  | // Checks if the mousePos is within 3px of the start or end of the | 
|  | // current selected time range. | 
|  | function onTimeRangeBoundary(mousePos: number): 'START' | 'END' | null { | 
|  | const selection = getLegacySelection(globals.state); | 
|  | if (selection !== null && selection.kind === 'AREA') { | 
|  | // If frontend selectedArea exists then we are in the process of editing the | 
|  | // time range and need to use that value instead. | 
|  | const area = globals.timeline.selectedArea | 
|  | ? globals.timeline.selectedArea | 
|  | : globals.state.areas[selection.areaId]; | 
|  | const {visibleTimeScale} = globals.timeline; | 
|  | const start = visibleTimeScale.timeToPx(area.start); | 
|  | const end = visibleTimeScale.timeToPx(area.end); | 
|  | const startDrag = mousePos - TRACK_SHELL_WIDTH; | 
|  | const startDistance = Math.abs(start - startDrag); | 
|  | const endDistance = Math.abs(end - startDrag); | 
|  | const range = 3 * window.devicePixelRatio; | 
|  | // We might be within 3px of both boundaries but we should choose | 
|  | // the closest one. | 
|  | if (startDistance < range && startDistance <= endDistance) return 'START'; | 
|  | if (endDistance < range && endDistance <= startDistance) return 'END'; | 
|  | } | 
|  | return null; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Top-most level component for the viewer page. Holds tracks, brush timeline, | 
|  | * panels, and everything else that's part of the main trace viewer page. | 
|  | */ | 
|  | class TraceViewer implements m.ClassComponent { | 
|  | private zoomContent?: PanAndZoomHandler; | 
|  | // Used to prevent global deselection if a pan/drag select occurred. | 
|  | private keepCurrentSelection = false; | 
|  |  | 
|  | private overviewTimelinePanel = new OverviewTimelinePanel('overview'); | 
|  | private timeAxisPanel = new TimeAxisPanel('timeaxis'); | 
|  | private timeSelectionPanel = new TimeSelectionPanel('timeselection'); | 
|  | private notesPanel = new NotesPanel('notes'); | 
|  | private tickmarkPanel = new TickmarkPanel('searchTickmarks'); | 
|  |  | 
|  | private readonly PAN_ZOOM_CONTENT_REF = 'pan-and-zoom-content'; | 
|  |  | 
|  | oncreate(vnode: m.CVnodeDOM) { | 
|  | const timeline = globals.timeline; | 
|  | const panZoomElRaw = findRef(vnode.dom, this.PAN_ZOOM_CONTENT_REF); | 
|  | const panZoomEl = toHTMLElement(assertExists(panZoomElRaw)); | 
|  |  | 
|  | this.zoomContent = new PanAndZoomHandler({ | 
|  | element: panZoomEl, | 
|  | onPanned: (pannedPx: number) => { | 
|  | const {visibleTimeScale} = globals.timeline; | 
|  |  | 
|  | this.keepCurrentSelection = true; | 
|  | const tDelta = visibleTimeScale.pxDeltaToDuration(pannedPx); | 
|  | timeline.panVisibleWindow(tDelta); | 
|  |  | 
|  | // If the user has panned they no longer need the hint. | 
|  | localStorage.setItem(DISMISSED_PANNING_HINT_KEY, 'true'); | 
|  | raf.scheduleRedraw(); | 
|  | }, | 
|  | onZoomed: (zoomedPositionPx: number, zoomRatio: number) => { | 
|  | // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH. | 
|  | // TODO(hjd): Improve support for zooming in overview timeline. | 
|  | const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH; | 
|  | const rect = vnode.dom.getBoundingClientRect(); | 
|  | const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH); | 
|  | timeline.zoomVisibleWindow(1 - zoomRatio, centerPoint); | 
|  | raf.scheduleRedraw(); | 
|  | }, | 
|  | editSelection: (currentPx: number) => { | 
|  | return onTimeRangeBoundary(currentPx) !== null; | 
|  | }, | 
|  | onSelection: ( | 
|  | dragStartX: number, | 
|  | dragStartY: number, | 
|  | prevX: number, | 
|  | currentX: number, | 
|  | currentY: number, | 
|  | editing: boolean, | 
|  | ) => { | 
|  | const traceTime = globals.state.traceTime; | 
|  | const {visibleTimeScale} = timeline; | 
|  | this.keepCurrentSelection = true; | 
|  | if (editing) { | 
|  | const selection = getLegacySelection(globals.state); | 
|  | if (selection !== null && selection.kind === 'AREA') { | 
|  | const area = globals.timeline.selectedArea | 
|  | ? globals.timeline.selectedArea | 
|  | : globals.state.areas[selection.areaId]; | 
|  | let newTime = visibleTimeScale | 
|  | .pxToHpTime(currentX - TRACK_SHELL_WIDTH) | 
|  | .toTime(); | 
|  | // Have to check again for when one boundary crosses over the other. | 
|  | const curBoundary = onTimeRangeBoundary(prevX); | 
|  | if (curBoundary == null) return; | 
|  | const keepTime = curBoundary === 'START' ? area.end : area.start; | 
|  | // Don't drag selection outside of current screen. | 
|  | if (newTime < keepTime) { | 
|  | newTime = Time.max( | 
|  | newTime, | 
|  | visibleTimeScale.timeSpan.start.toTime(), | 
|  | ); | 
|  | } else { | 
|  | newTime = Time.min( | 
|  | newTime, | 
|  | visibleTimeScale.timeSpan.end.toTime(), | 
|  | ); | 
|  | } | 
|  | // When editing the time range we always use the saved tracks, | 
|  | // since these will not change. | 
|  | timeline.selectArea( | 
|  | Time.max(Time.min(keepTime, newTime), traceTime.start), | 
|  | Time.min(Time.max(keepTime, newTime), traceTime.end), | 
|  | globals.state.areas[selection.areaId].tracks, | 
|  | ); | 
|  | } | 
|  | } else { | 
|  | let startPx = Math.min(dragStartX, currentX) - TRACK_SHELL_WIDTH; | 
|  | let endPx = Math.max(dragStartX, currentX) - TRACK_SHELL_WIDTH; | 
|  | if (startPx < 0 && endPx < 0) return; | 
|  | const {pxSpan} = visibleTimeScale; | 
|  | startPx = clamp(startPx, pxSpan.start, pxSpan.end); | 
|  | endPx = clamp(endPx, pxSpan.start, pxSpan.end); | 
|  | timeline.selectArea( | 
|  | visibleTimeScale.pxToHpTime(startPx).toTime('floor'), | 
|  | visibleTimeScale.pxToHpTime(endPx).toTime('ceil'), | 
|  | ); | 
|  | timeline.areaY.start = dragStartY; | 
|  | timeline.areaY.end = currentY; | 
|  | publishShowPanningHint(); | 
|  | } | 
|  | raf.scheduleRedraw(); | 
|  | }, | 
|  | endSelection: (edit: boolean) => { | 
|  | globals.timeline.areaY.start = undefined; | 
|  | globals.timeline.areaY.end = undefined; | 
|  | const area = globals.timeline.selectedArea; | 
|  | // If we are editing we need to pass the current id through to ensure | 
|  | // the marked area with that id is also updated. | 
|  | if (edit) { | 
|  | const selection = getLegacySelection(globals.state); | 
|  | if (selection !== null && selection.kind === 'AREA' && area) { | 
|  | globals.dispatch( | 
|  | Actions.editArea({area, areaId: selection.areaId}), | 
|  | ); | 
|  | } | 
|  | } else if (area) { | 
|  | globals.makeSelection(Actions.selectArea({area})); | 
|  | } | 
|  | // Now the selection has ended we stored the final selected area in the | 
|  | // global state and can remove the in progress selection from the | 
|  | // timeline. | 
|  | globals.timeline.deselectArea(); | 
|  | // Full redraw to color track shell. | 
|  | raf.scheduleFullRedraw(); | 
|  | }, | 
|  | }); | 
|  | } | 
|  |  | 
|  | onremove() { | 
|  | if (this.zoomContent) this.zoomContent.dispose(); | 
|  | } | 
|  |  | 
|  | view() { | 
|  | const scrollingPanels: PanelOrGroup[] = globals.state.scrollingTracks.map( | 
|  | (key) => { | 
|  | const trackBundle = this.resolveTrack(key); | 
|  | return new TrackPanel({ | 
|  | trackKey: key, | 
|  | title: trackBundle.title, | 
|  | tags: trackBundle.tags, | 
|  | trackFSM: trackBundle.trackFSM, | 
|  | closeable: trackBundle.closeable, | 
|  | }); | 
|  | }, | 
|  | ); | 
|  |  | 
|  | for (const group of Object.values(globals.state.trackGroups)) { | 
|  | const key = group.summaryTrack; | 
|  | let headerPanel; | 
|  | if (key) { | 
|  | const trackBundle = this.resolveTrack(key); | 
|  | headerPanel = new TrackGroupPanel({ | 
|  | trackGroupId: group.id, | 
|  | key: `trackgroup-${group.id}`, | 
|  | trackFSM: trackBundle.trackFSM, | 
|  | labels: trackBundle.labels, | 
|  | tags: trackBundle.tags, | 
|  | collapsed: group.collapsed, | 
|  | title: group.name, | 
|  | }); | 
|  | } else { | 
|  | headerPanel = new TrackGroupPanel({ | 
|  | trackGroupId: group.id, | 
|  | key: `trackgroup-${group.id}`, | 
|  | collapsed: group.collapsed, | 
|  | title: group.name, | 
|  | }); | 
|  | } | 
|  |  | 
|  | const childTracks: Panel[] = []; | 
|  | if (!group.collapsed) { | 
|  | for (const key of group.tracks) { | 
|  | const trackBundle = this.resolveTrack(key); | 
|  | const panel = new TrackPanel({ | 
|  | trackKey: key, | 
|  | title: trackBundle.title, | 
|  | tags: trackBundle.tags, | 
|  | trackFSM: trackBundle.trackFSM, | 
|  | closeable: trackBundle.closeable, | 
|  | }); | 
|  | childTracks.push(panel); | 
|  | } | 
|  | } | 
|  |  | 
|  | scrollingPanels.push({ | 
|  | kind: 'group', | 
|  | collapsed: group.collapsed, | 
|  | childPanels: childTracks, | 
|  | header: headerPanel, | 
|  | trackGroupId: group.id, | 
|  | }); | 
|  | } | 
|  |  | 
|  | const overviewPanel = []; | 
|  | if (OVERVIEW_PANEL_FLAG.get()) { | 
|  | overviewPanel.push(this.overviewTimelinePanel); | 
|  | } | 
|  |  | 
|  | const result = m( | 
|  | '.page.viewer-page', | 
|  | m( | 
|  | '.pan-and-zoom-content', | 
|  | { | 
|  | ref: this.PAN_ZOOM_CONTENT_REF, | 
|  | onclick: () => { | 
|  | // We don't want to deselect when panning/drag selecting. | 
|  | if (this.keepCurrentSelection) { | 
|  | this.keepCurrentSelection = false; | 
|  | return; | 
|  | } | 
|  | globals.clearSelection(); | 
|  | }, | 
|  | }, | 
|  | m( | 
|  | '.header', | 
|  | m(PanelContainer, { | 
|  | className: 'header-panel-container', | 
|  | panels: [ | 
|  | ...overviewPanel, | 
|  | this.timeAxisPanel, | 
|  | this.timeSelectionPanel, | 
|  | this.notesPanel, | 
|  | this.tickmarkPanel, | 
|  | ], | 
|  | }), | 
|  | m('.scrollbar-spacer-vertical'), | 
|  | ), | 
|  | m(PanelContainer, { | 
|  | className: 'pinned-panel-container', | 
|  | panels: globals.state.pinnedTracks.map((key) => { | 
|  | const trackBundle = this.resolveTrack(key); | 
|  | return new TrackPanel({ | 
|  | trackKey: key, | 
|  | title: trackBundle.title, | 
|  | tags: trackBundle.tags, | 
|  | trackFSM: trackBundle.trackFSM, | 
|  | revealOnCreate: true, | 
|  | closeable: trackBundle.closeable, | 
|  | }); | 
|  | }), | 
|  | }), | 
|  | m(PanelContainer, { | 
|  | className: 'scrolling-panel-container', | 
|  | panels: scrollingPanels, | 
|  | onPanelStackResize: (width) => { | 
|  | const timelineWidth = width - TRACK_SHELL_WIDTH; | 
|  | globals.timeline.updateLocalLimits(0, timelineWidth); | 
|  | }, | 
|  | }), | 
|  | ), | 
|  | this.renderTabPanel(), | 
|  | ); | 
|  |  | 
|  | globals.trackManager.flushOldTracks(); | 
|  | return result; | 
|  | } | 
|  |  | 
|  | // Resolve a track and its metadata through the track cache | 
|  | private resolveTrack(key: string): TrackBundle { | 
|  | const trackState = globals.state.tracks[key]; | 
|  | const {uri, params, name, labels, closeable} = trackState; | 
|  | const trackDesc = globals.trackManager.resolveTrackInfo(uri); | 
|  | const trackCacheEntry = | 
|  | trackDesc && globals.trackManager.resolveTrack(key, trackDesc, params); | 
|  | const trackFSM = trackCacheEntry; | 
|  | const tags = trackCacheEntry?.desc.tags; | 
|  | const trackIds = trackCacheEntry?.desc.trackIds; | 
|  | return { | 
|  | title: name, | 
|  | tags, | 
|  | trackFSM, | 
|  | labels, | 
|  | trackIds, | 
|  | closeable: closeable ?? false, | 
|  | }; | 
|  | } | 
|  |  | 
|  | private renderTabPanel() { | 
|  | return m(TabPanel); | 
|  | } | 
|  | } | 
|  |  | 
|  | interface TrackBundle { | 
|  | title: string; | 
|  | closeable: boolean; | 
|  | trackFSM?: TrackCacheEntry; | 
|  | tags?: TrackTags; | 
|  | labels?: string[]; | 
|  | trackIds?: number[]; | 
|  | } | 
|  |  | 
|  | export const ViewerPage = createPage({ | 
|  | view() { | 
|  | return m(TraceViewer); | 
|  | }, | 
|  | }); |