blob: 9509c1dab9385f5939e08e20ac83c6a72e891ce2 [file] [log] [blame]
// 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<PageWithTraceImplAttrs> {
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<PageWithTraceImplAttrs>) {
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<PageWithTraceImplAttrs>) {
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<PageWithTraceImplAttrs>) {
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: 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),
selectedYRange: this.getYRange('pinned-panel-container'),
}),
m(PanelContainer, {
trace: attrs.trace,
className: 'scrolling-panel-container',
panels: 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),
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<RenderedPanelInfo>,
): 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);
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<TrackNode>,
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',
),
);
}
}