// 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 {hex} from 'color-convert'; import m from 'mithril'; import {removeFalsyValues} from '../base/array_utils'; import {canvasClip, canvasSave} from '../base/canvas_utils'; import {findRef, toHTMLElement} from '../base/dom_utils'; import {Size2D, VerticalBounds} from '../base/geom'; import {assertExists} from '../base/logging'; import {clamp} from '../base/math_utils'; import {Time, TimeSpan} from '../base/time'; import {TimeScale} from '../base/time_scale'; import {featureFlags} from '../core/feature_flags'; import {raf} from '../core/raf_scheduler'; import {TrackNode} from '../public/workspace'; import {TRACK_BORDER_COLOR, TRACK_SHELL_WIDTH} from './css_constants'; import {renderFlows} from './flow_events_renderer'; import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper'; import {NotesPanel} from './notes_panel'; import {OverviewTimelinePanel} from './overview_timeline_panel'; import {PanAndZoomHandler} from './pan_and_zoom_handler'; import { PanelContainer, PanelOrGroup, RenderedPanelInfo, } from './panel_container'; import {TabPanel} from './tab_panel'; import {TickmarkPanel} from './tickmark_panel'; import {TimeAxisPanel} from './time_axis_panel'; import {TimeSelectionPanel} from './time_selection_panel'; import {TrackPanel} from './track_panel'; import {drawVerticalLineAtTime} from './vertical_line_helper'; import {TraceImpl} from '../core/trace_impl'; import {PageWithTraceImplAttrs} from '../core/page_manager'; import {AppImpl} from '../core/app_impl'; 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( trace: TraceImpl, timescale: TimeScale, mousePos: number, ): 'START' | 'END' | null { const selection = trace.selection.selection; if (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 = trace.timeline.selectedArea ? trace.timeline.selectedArea : selection; const start = timescale.timeToPx(area.start); const end = timescale.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; } interface SelectedContainer { readonly containerClass: string; readonly dragStartAbsY: number; readonly dragEndAbsY: number; } /** * 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. */ export class ViewerPage implements m.ClassComponent { private zoomContent?: PanAndZoomHandler; // Used to prevent global deselection if a pan/drag select occurred. private keepCurrentSelection = false; private overviewTimelinePanel: OverviewTimelinePanel; private timeAxisPanel: TimeAxisPanel; private timeSelectionPanel: TimeSelectionPanel; private notesPanel: NotesPanel; private tickmarkPanel: TickmarkPanel; private timelineWidthPx?: number; private selectedContainer?: SelectedContainer; private showPanningHint = false; private readonly PAN_ZOOM_CONTENT_REF = 'pan-and-zoom-content'; constructor(vnode: m.CVnode) { this.notesPanel = new NotesPanel(vnode.attrs.trace); this.timeAxisPanel = new TimeAxisPanel(vnode.attrs.trace); this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace); this.tickmarkPanel = new TickmarkPanel(vnode.attrs.trace); this.overviewTimelinePanel = new OverviewTimelinePanel(vnode.attrs.trace); this.notesPanel = new NotesPanel(vnode.attrs.trace); this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace); } oncreate({dom, attrs}: m.CVnodeDOM) { const panZoomElRaw = findRef(dom, this.PAN_ZOOM_CONTENT_REF); const panZoomEl = toHTMLElement(assertExists(panZoomElRaw)); const {top: panTop} = panZoomEl.getBoundingClientRect(); this.zoomContent = new PanAndZoomHandler({ element: panZoomEl, onPanned: (pannedPx: number) => { const timeline = attrs.trace.timeline; if (this.timelineWidthPx === undefined) return; this.keepCurrentSelection = true; const timescale = new TimeScale(timeline.visibleWindow, { left: 0, right: this.timelineWidthPx, }); const tDelta = timescale.pxToDuration(pannedPx); timeline.panVisibleWindow(tDelta); }, onZoomed: (zoomedPositionPx: number, zoomRatio: number) => { const timeline = attrs.trace.timeline; // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH. // TODO(hjd): Improve support for zooming in overview timeline. const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH; const rect = dom.getBoundingClientRect(); const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH); timeline.zoomVisibleWindow(1 - zoomRatio, centerPoint); raf.scheduleCanvasRedraw(); }, editSelection: (currentPx: number) => { if (this.timelineWidthPx === undefined) return false; const timescale = new TimeScale(attrs.trace.timeline.visibleWindow, { left: 0, right: this.timelineWidthPx, }); return onTimeRangeBoundary(attrs.trace, timescale, currentPx) !== null; }, onSelection: ( dragStartX: number, dragStartY: number, prevX: number, currentX: number, currentY: number, editing: boolean, ) => { const traceTime = attrs.trace.traceInfo; const timeline = attrs.trace.timeline; if (this.timelineWidthPx === undefined) return; // TODO(stevegolton): Don't get the windowSpan from globals, get it from // here! const {visibleWindow} = timeline; const timespan = visibleWindow.toTimeSpan(); this.keepCurrentSelection = true; const timescale = new TimeScale(timeline.visibleWindow, { left: 0, right: this.timelineWidthPx, }); if (editing) { const selection = attrs.trace.selection.selection; if (selection.kind === 'area') { const area = attrs.trace.timeline.selectedArea ? attrs.trace.timeline.selectedArea : selection; let newTime = timescale .pxToHpTime(currentX - TRACK_SHELL_WIDTH) .toTime(); // Have to check again for when one boundary crosses over the other. const curBoundary = onTimeRangeBoundary( attrs.trace, timescale, 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, timespan.start); } else { newTime = Time.min(newTime, timespan.end); } // 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), selection.trackUris, ); } } 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; startPx = clamp(startPx, 0, this.timelineWidthPx); endPx = clamp(endPx, 0, this.timelineWidthPx); timeline.selectArea( timescale.pxToHpTime(startPx).toTime('floor'), timescale.pxToHpTime(endPx).toTime('ceil'), ); const absStartY = dragStartY + panTop; const absCurrentY = currentY + panTop; if (this.selectedContainer === undefined) { for (const c of dom.querySelectorAll('.pf-panel-container')) { const {top, bottom} = c.getBoundingClientRect(); if (top <= absStartY && absCurrentY <= bottom) { const stack = assertExists(c.querySelector('.pf-panel-stack')); const stackTop = stack.getBoundingClientRect().top; this.selectedContainer = { containerClass: Array.from(c.classList).filter( (x) => x !== 'pf-panel-container', )[0], dragStartAbsY: -stackTop + absStartY, dragEndAbsY: -stackTop + absCurrentY, }; break; } } } else { const c = assertExists( dom.querySelector(`.${this.selectedContainer.containerClass}`), ); const {top, bottom} = c.getBoundingClientRect(); const boundedCurrentY = Math.min( Math.max(top, absCurrentY), bottom, ); const stack = assertExists(c.querySelector('.pf-panel-stack')); const stackTop = stack.getBoundingClientRect().top; this.selectedContainer = { ...this.selectedContainer, dragEndAbsY: -stackTop + boundedCurrentY, }; } this.showPanningHint = true; } raf.scheduleCanvasRedraw(); }, endSelection: (edit: boolean) => { this.selectedContainer = undefined; const area = attrs.trace.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 = attrs.trace.selection.selection; if (selection.kind === 'area' && area) { attrs.trace.selection.selectArea({...area}); } } else if (area) { attrs.trace.selection.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. attrs.trace.timeline.deselectArea(); // Full redraw to color track shell. raf.scheduleFullRedraw(); }, }); } onremove() { if (this.zoomContent) this.zoomContent[Symbol.dispose](); } view({attrs}: m.CVnode) { const scrollingPanels = renderToplevelPanels(attrs.trace); 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; } attrs.trace.selection.clear(); }, }, m( '.pf-timeline-header', m(PanelContainer, { trace: attrs.trace, className: 'header-panel-container', panels: removeFalsyValues([ OVERVIEW_PANEL_FLAG.get() && this.overviewTimelinePanel, this.timeAxisPanel, this.timeSelectionPanel, this.notesPanel, this.tickmarkPanel, ]), selectedYRange: this.getYRange('header-panel-container'), }), m('.scrollbar-spacer-vertical'), ), m(PanelContainer, { trace: attrs.trace, className: 'pinned-panel-container', panels: AppImpl.instance.isLoadingTrace ? [] : attrs.trace.workspace.pinnedTracks.map((trackNode) => { if (trackNode.uri) { const tr = attrs.trace.tracks.getTrackRenderer(trackNode.uri); return new TrackPanel({ trace: attrs.trace, reorderable: true, node: trackNode, trackRenderer: tr, revealOnCreate: true, indentationLevel: 0, topOffsetPx: 0, }); } else { return new TrackPanel({ trace: attrs.trace, node: trackNode, revealOnCreate: true, indentationLevel: 0, topOffsetPx: 0, }); } }), renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size), renderOverlay: (ctx, size, panels) => renderOverlay( attrs.trace, ctx, size, panels, attrs.trace.workspace.pinnedTracksNode, ), selectedYRange: this.getYRange('pinned-panel-container'), }), m(PanelContainer, { trace: attrs.trace, className: 'scrolling-panel-container', panels: AppImpl.instance.isLoadingTrace ? [] : scrollingPanels, onPanelStackResize: (width) => { const timelineWidth = width - TRACK_SHELL_WIDTH; this.timelineWidthPx = timelineWidth; }, renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size), renderOverlay: (ctx, size, panels) => renderOverlay( attrs.trace, ctx, size, panels, attrs.trace.workspace.tracks, ), selectedYRange: this.getYRange('scrolling-panel-container'), }), ), m(TabPanel, { trace: attrs.trace, }), this.showPanningHint && m(HelpPanningNotification), ); attrs.trace.tracks.flushOldTracks(); return result; } private getYRange(cls: string): VerticalBounds | undefined { if (this.selectedContainer?.containerClass !== cls) { return undefined; } const {dragStartAbsY, dragEndAbsY} = this.selectedContainer; return { top: Math.min(dragStartAbsY, dragEndAbsY), bottom: Math.max(dragStartAbsY, dragEndAbsY), }; } } function renderUnderlay( trace: TraceImpl, ctx: CanvasRenderingContext2D, canvasSize: Size2D, ): void { const size = { width: canvasSize.width - TRACK_SHELL_WIDTH, height: canvasSize.height, }; using _ = canvasSave(ctx); ctx.translate(TRACK_SHELL_WIDTH, 0); const timewindow = trace.timeline.visibleWindow; const timescale = new TimeScale(timewindow, {left: 0, right: size.width}); // Just render the gridlines - these should appear underneath all tracks drawGridLines(trace, ctx, timewindow.toTimeSpan(), timescale, size); } function renderOverlay( trace: TraceImpl, ctx: CanvasRenderingContext2D, canvasSize: Size2D, panels: ReadonlyArray, trackContainer: TrackNode, ): void { const size = { width: canvasSize.width - TRACK_SHELL_WIDTH, height: canvasSize.height, }; using _ = canvasSave(ctx); ctx.translate(TRACK_SHELL_WIDTH, 0); canvasClip(ctx, 0, 0, size.width, size.height); // TODO(primiano): plumb the TraceImpl obj throughout the viwer page. renderFlows(trace, ctx, size, panels, trackContainer); const timewindow = trace.timeline.visibleWindow; const timescale = new TimeScale(timewindow, {left: 0, right: size.width}); renderHoveredNoteVertical(trace, ctx, timescale, size); renderHoveredCursorVertical(trace, ctx, timescale, size); renderWakeupVertical(trace, ctx, timescale, size); renderNoteVerticals(trace, ctx, timescale, size); } // Render the toplevel "scrolling" tracks and track groups function renderToplevelPanels(trace: TraceImpl): PanelOrGroup[] { return renderNodes(trace, trace.workspace.children, 0, 0); } // Given a list of tracks and a filter term, return a list pf panels filtered by // the filter term function renderNodes( trace: TraceImpl, nodes: ReadonlyArray, indent: number, topOffsetPx: number, ): PanelOrGroup[] { return nodes.flatMap((node) => { if (node.headless) { // Render children as if this node doesn't exist return renderNodes(trace, node.children, indent, topOffsetPx); } else if (node.children.length === 0) { return renderTrackPanel(trace, node, indent, topOffsetPx); } else { const headerPanel = renderTrackPanel(trace, node, indent, topOffsetPx); const isSticky = node.isSummary; const nextTopOffsetPx = isSticky ? topOffsetPx + headerPanel.heightPx : topOffsetPx; return { kind: 'group', collapsed: node.collapsed, header: headerPanel, sticky: isSticky, // && node.collapsed?? topOffsetPx, childPanels: node.collapsed ? [] : renderNodes(trace, node.children, indent + 1, nextTopOffsetPx), }; } }); } function renderTrackPanel( trace: TraceImpl, trackNode: TrackNode, indent: number, topOffsetPx: number, ) { let tr = undefined; if (trackNode.uri) { tr = trace.tracks.getTrackRenderer(trackNode.uri); } return new TrackPanel({ trace, node: trackNode, trackRenderer: tr, indentationLevel: indent, topOffsetPx, }); } export function drawGridLines( trace: TraceImpl, ctx: CanvasRenderingContext2D, timespan: TimeSpan, timescale: TimeScale, size: Size2D, ): void { ctx.strokeStyle = TRACK_BORDER_COLOR; ctx.lineWidth = 1; if (size.width > 0 && timespan.duration > 0n) { const maxMajorTicks = getMaxMajorTicks(size.width); const offset = trace.timeline.timestampOffset(); for (const {type, time} of generateTicks(timespan, maxMajorTicks, offset)) { const px = Math.floor(timescale.timeToPx(time)); if (type === TickType.MAJOR) { ctx.beginPath(); ctx.moveTo(px + 0.5, 0); ctx.lineTo(px + 0.5, size.height); ctx.stroke(); } } } } export function renderHoveredCursorVertical( trace: TraceImpl, ctx: CanvasRenderingContext2D, timescale: TimeScale, size: Size2D, ) { if (trace.timeline.hoverCursorTimestamp !== undefined) { drawVerticalLineAtTime( ctx, timescale, trace.timeline.hoverCursorTimestamp, size.height, `#344596`, ); } } export function renderHoveredNoteVertical( trace: TraceImpl, ctx: CanvasRenderingContext2D, timescale: TimeScale, size: Size2D, ) { if (trace.timeline.hoveredNoteTimestamp !== undefined) { drawVerticalLineAtTime( ctx, timescale, trace.timeline.hoveredNoteTimestamp, size.height, `#aaa`, ); } } export function renderWakeupVertical( trace: TraceImpl, ctx: CanvasRenderingContext2D, timescale: TimeScale, size: Size2D, ) { const selection = trace.selection.selection; if (selection.kind === 'track_event' && selection.wakeupTs) { drawVerticalLineAtTime( ctx, timescale, selection.wakeupTs, size.height, `black`, ); } } export function renderNoteVerticals( trace: TraceImpl, ctx: CanvasRenderingContext2D, timescale: TimeScale, size: Size2D, ) { // All marked areas should have semi-transparent vertical lines // marking the start and end. for (const note of trace.notes.notes.values()) { if (note.noteType === 'SPAN') { const transparentNoteColor = 'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)'; drawVerticalLineAtTime( ctx, timescale, note.start, size.height, transparentNoteColor, 1, ); drawVerticalLineAtTime( ctx, timescale, note.end, size.height, transparentNoteColor, 1, ); } else if (note.noteType === 'DEFAULT') { drawVerticalLineAtTime( ctx, timescale, note.timestamp, size.height, note.color, ); } } } class HelpPanningNotification implements m.ClassComponent { private readonly PANNING_HINT_KEY = 'dismissedPanningHint'; private dismissed = localStorage.getItem(this.PANNING_HINT_KEY) === 'true'; view() { // Do not show the help notification in embedded mode because local storage // does not persist for iFrames. The host is responsible for communicating // to users that they can press '?' for help. if (AppImpl.instance.embeddedMode || this.dismissed) { return; } return m( '.helpful-hint', m( '.hint-text', 'Are you trying to pan? Use the WASD keys or hold shift to click ' + "and drag. Press '?' for more help.", ), m( 'button.hint-dismiss-button', { onclick: () => { this.dismissed = true; localStorage.setItem(this.PANNING_HINT_KEY, 'true'); raf.scheduleFullRedraw(); }, }, 'Dismiss', ), ); } }